add ntfy integration
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Elizabeth Hunt 2025-01-05 15:16:26 -08:00
parent d86746bb0d
commit 2984a715b8
Signed by: simponic
GPG Key ID: 2909B9A7FF6213EE
11 changed files with 209 additions and 89 deletions

View File

@ -10,4 +10,4 @@ RUN go build -o /app/phoneof
EXPOSE 8080
CMD ["/app/phoneof", "--server", "--migrate", "--port", "8080", "--template-path", "/app/templates", "--database-path", "/app/db/phoneof.db", "--static-path", "/app/static", "--scheduler", "--httpsms-endpoint", "https://httpsms.internal.simponic.xyz"]
CMD ["/app/phoneof", "--server", "--migrate", "--port", "8080", "--template-path", "/app/templates", "--database-path", "/app/db/phoneof.db", "--static-path", "/app/static", "--scheduler", "--httpsms-endpoint", "https://httpsms.internal.simponic.xyz", "--ntfy-endpoint", "https://ntfy.simponic.hatecomputers.club", "--ntfy-topic", "sms"]

View File

@ -6,3 +6,4 @@ TODO:
- [ ] pagination for messages
- [ ] full text search?
- [ ] better auth lol
- [ ] bruh

29
adapters/messaging/db.go Normal file
View File

@ -0,0 +1,29 @@
package messaging
import (
"database/sql"
"log"
"time"
"git.simponic.xyz/simponic/phoneof/database"
)
func PersistMessageContinuation(dbConn *sql.DB, frenId string, messageId string, sentAt time.Time, frenSent bool) Continuation {
return func(message Message) ContinuationChain {
log.Printf("persisting message %v %s %s %s %v", message, frenId, messageId, sentAt, frenSent)
return func(success Continuation, failure Continuation) ContinuationChain {
_, err := database.SaveMessage(dbConn, &database.Message{
Id: messageId,
FrenId: frenId,
Message: message.Message,
Time: sentAt,
FrenSent: frenSent,
})
if err != nil {
log.Printf("err when saving message %s", err)
return failure(message)
}
return success(message)
}
}
}

View File

@ -11,13 +11,6 @@ import (
"git.simponic.xyz/simponic/phoneof/utils"
)
type HttpSmsMessagingAdapter struct {
ApiToken string
FromPhoneNumber string
ToPhoneNumber string
Endpoint string
}
type HttpSmsMessageData struct {
RequestId string `json:"request_id"`
}
@ -26,26 +19,22 @@ type HttpSmsMessageSendResponse struct {
Data HttpSmsMessageData `json:"data"`
}
func (adapter *HttpSmsMessagingAdapter) encodeMessage(message string) string {
requestId := utils.RandomId()
return fmt.Sprintf(`{"from":"%s","to":"%s","content":"%s","request_id":"%s"}`, adapter.FromPhoneNumber, adapter.ToPhoneNumber, message, requestId)
}
func HttpSmsContinuation(apiToken string, fromPhoneNumber string, toPhoneNumber string, httpSmsEndpoint string) Continuation {
return func(message Message) ContinuationChain {
encodedMsg := fmt.Sprintf(`{"from":"%s","to":"%s","content":"%s"}`, fromPhoneNumber, toPhoneNumber, utils.Quote(message.Encode()))
log.Println(encodedMsg)
func (adapter *HttpSmsMessagingAdapter) SendMessage(message string) (string, error) {
url := fmt.Sprintf("%s/v1/messages/send", adapter.Endpoint)
payload := strings.NewReader(adapter.encodeMessage(message))
return func(success Continuation, failure Continuation) ContinuationChain {
url := fmt.Sprintf("%s/v1/messages/send", httpSmsEndpoint)
payload := strings.NewReader(encodedMsg)
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("x-api-key", adapter.ApiToken)
req.Header.Add("x-api-key", apiToken)
req.Header.Add("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
log.Printf("got err sending message send req %s", err)
return "", err
}
if res.StatusCode/100 != 2 {
return "", fmt.Errorf("error sending message: %s %d", message, res.StatusCode)
if err != nil || res.StatusCode/100 != 2 {
log.Printf("got err sending message send req %s %v %s", message, res, err)
return failure(message)
}
defer res.Body.Close()
@ -55,8 +44,10 @@ func (adapter *HttpSmsMessagingAdapter) SendMessage(message string) (string, err
err = json.Unmarshal(body, &response)
if err != nil {
log.Printf("got error unmarshaling response: %s %s", body, err)
return "", err
return failure(message)
}
return response.Data.RequestId, nil
return success(message)
}
}
}

View File

@ -1,5 +1,61 @@
package messaging
type MessagingAdapter interface {
SendMessage(message string) (string, error)
import (
"fmt"
"log"
"strings"
"time"
)
type Message struct {
FrenName string
Message string
}
func (m *Message) Encode() string {
return m.FrenName + " " + m.Message
}
func Decode(message string) (*Message, error) {
content := strings.SplitN(message, " ", 2)
if len(content) < 2 {
return nil, fmt.Errorf("no space delimiter")
}
return &Message{
FrenName: content[0],
Message: content[1],
}, nil
}
func IdContinuation(message Message) ContinuationChain {
return func(success Continuation, _failure Continuation) ContinuationChain {
return success(message)
}
}
func FailurePassingContinuation(message Message) ContinuationChain {
return func(_success Continuation, failure Continuation) ContinuationChain {
return failure(message)
}
}
func LogContinuation(message Message) ContinuationChain {
return func(success Continuation, _failure Continuation) ContinuationChain {
now := time.Now().UTC()
log.Println(now, message)
return success(message)
}
}
// basically b(a(message)) if and only if b is successful
func Compose(a Continuation, b Continuation) Continuation {
return func(message Message) ContinuationChain {
return func(success Continuation, failure Continuation) ContinuationChain {
return b(message)(a, FailurePassingContinuation)(success, failure)
}
}
}
type Continuation func(Message) ContinuationChain
type ContinuationChain func(Continuation, Continuation) ContinuationChain

View File

@ -0,0 +1,35 @@
package messaging
import (
"fmt"
"log"
"net/http"
"strings"
"git.simponic.xyz/simponic/phoneof/utils"
)
func SendNtfy(topic string, ntfyEndpoint string) Continuation {
return func(message Message) ContinuationChain {
return func(success Continuation, failure Continuation) ContinuationChain {
log.Println(message)
if message.FrenName != "ntfy" {
log.Printf("fren name for message %v is not ntfy so we wont send it there", message)
return success(message)
}
encodedMsg := fmt.Sprintf(`{"message": "%s", "topic": "%s"}`, utils.Quote(message.Message), utils.Quote(topic))
url := ntfyEndpoint
payload := strings.NewReader(encodedMsg)
req, _ := http.NewRequest("PUT", url, payload)
req.Header.Add("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil || res.StatusCode/100 != 2 {
log.Printf("got err sending message send req %s %v %s", encodedMsg, res, err)
return failure(message)
}
return success(message)
}
}
}

View File

@ -99,19 +99,15 @@ func MakeMux(argv *args.Arguments, dbConn *sql.DB) *http.ServeMux {
LogRequestContinuation(requestContext, r, w)(chat.ValidateFren, FailurePassingContinuation)(chat.FetchMessagesContinuation, FailurePassingContinuation)(template.TemplateContinuation("messages.html", false), FailurePassingContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation)
})
messageHandler := messaging.HttpSmsMessagingAdapter{
ApiToken: os.Getenv("HTTPSMS_API_TOKEN"),
FromPhoneNumber: os.Getenv("FROM_PHONE_NUMBER"),
ToPhoneNumber: os.Getenv("TO_PHONE_NUMBER"),
Endpoint: argv.HttpSmsEndpoint,
}
sendMessageContinuation := chat.SendMessageContinuation(&messageHandler)
httpsms := messaging.HttpSmsContinuation(os.Getenv("HTTPSMS_API_TOKEN"), os.Getenv("FROM_PHONE_NUMBER"), os.Getenv("TO_PHONE_NUMBER"), argv.HttpSmsEndpoint)
ntfy := messaging.SendNtfy(argv.NtfyTopic, argv.NtfyEndpoint)
sendMessageContinuation := chat.SendMessageContinuation(messaging.Compose(ntfy, httpsms))
mux.HandleFunc("POST /chat", func(w http.ResponseWriter, r *http.Request) {
requestContext := makeRequestContext()
LogRequestContinuation(requestContext, r, w)(chat.ValidateFren, FailurePassingContinuation)(sendMessageContinuation, FailurePassingContinuation)(template.TemplateContinuation("chat.html", true), FailurePassingContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation)
})
smsEventProcessor := chat.ChatEventProcessorContinuation(os.Getenv("HTTPSMS_SIGNING_KEY"))
smsEventProcessor := chat.ChatEventProcessorContinuation(os.Getenv("TO_PHONE_NUMBER"), os.Getenv("HTTPSMS_SIGNING_KEY"), ntfy)
mux.HandleFunc("POST /chat/event", func(w http.ResponseWriter, r *http.Request) {
requestContext := makeRequestContext()
LogRequestContinuation(requestContext, r, w)(smsEventProcessor, FailurePassingContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation)

View File

@ -2,13 +2,15 @@ package chat
import (
"encoding/json"
"github.com/golang-jwt/jwt/v5"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"git.simponic.xyz/simponic/phoneof/adapters/messaging"
"git.simponic.xyz/simponic/phoneof/api/types"
"git.simponic.xyz/simponic/phoneof/database"
@ -59,28 +61,27 @@ func FetchMessagesContinuation(context *types.RequestContext, req *http.Request,
}
}
func SendMessageContinuation(messagingAdapter messaging.MessagingAdapter) func(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
func SendMessageContinuation(messagingPipeline messaging.Continuation) func(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
return func(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
return func(success types.Continuation, failure types.Continuation) types.ContinuationChain {
rawMessage := req.FormValue("message")
now := time.Now().UTC()
messageRequestId, err := messagingAdapter.SendMessage(context.User.Name + " " + rawMessage)
persist := messaging.PersistMessageContinuation(context.DBConn, context.User.Id, context.Id, now, true)
var err error
messaging.LogContinuation(messaging.Message{
FrenName: context.User.Name,
Message: rawMessage,
})(messagingPipeline, messaging.FailurePassingContinuation)(persist, messaging.FailurePassingContinuation)(messaging.IdContinuation, func(message messaging.Message) messaging.ContinuationChain {
err = fmt.Errorf("err sending message from: %s %s", context.User, rawMessage)
return messaging.FailurePassingContinuation(message)
})
if err != nil {
log.Printf("err sending message %s %s %s", context.User, rawMessage, err)
// yeah this might be a 400 or whatever, ill fix it later
resp.WriteHeader(http.StatusInternalServerError)
return failure(context, req, resp)
}
message, err := database.SaveMessage(context.DBConn, &database.Message{
Id: messageRequestId,
FrenId: context.User.Id,
Message: rawMessage,
Time: now,
FrenSent: true,
})
log.Printf("Saved message %v", message)
return success(context, req, resp)
}
}
@ -120,7 +121,7 @@ type HttpSmsEvent struct {
Id string `json:"id"`
}
func ChatEventProcessorContinuation(signingKey string) func(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
func ChatEventProcessorContinuation(allowedFrom string, signingKey string, messagingPipeline messaging.Continuation) func(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
return func(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
return func(success types.Continuation, failure types.Continuation) types.ContinuationChain {
// check signing
@ -156,38 +157,34 @@ func ChatEventProcessorContinuation(signingKey string) func(context *types.Reque
log.Printf("got non-receive event %s", event.Type)
return success(context, req, resp)
}
// respond to texts with "<fren name> <content>"
content := strings.SplitN(event.Data.Content, " ", 2)
if len(content) < 2 {
log.Printf("no space delimiter")
resp.WriteHeader(http.StatusBadRequest)
return failure(context, req, resp)
if event.Data.Contact != allowedFrom {
log.Printf("someone did something naughty %s", event.Data.Contact)
return success(context, req, resp)
}
name := content[0]
rawMessage := content[1]
fren, err := database.FindFrenByName(context.DBConn, name)
message, err := messaging.Decode(event.Data.Content)
if err != nil {
log.Printf("err when getting fren %s %s", name, err)
log.Printf("err when decoding message %s", err)
resp.WriteHeader(http.StatusBadRequest)
return failure(context, req, resp)
}
// save the message!
_, err = database.SaveMessage(context.DBConn, &database.Message{
Id: event.Id,
FrenId: fren.Id,
Message: rawMessage,
Time: event.Data.Timestamp,
FrenSent: false,
fren, err := database.FindFrenByName(context.DBConn, message.FrenName)
if err != nil {
log.Printf("err when getting fren %s %s", fren.Name, err)
resp.WriteHeader(http.StatusBadRequest)
return failure(context, req, resp)
}
persist := messaging.PersistMessageContinuation(context.DBConn, fren.Id, context.Id, event.Data.Timestamp, false)
messaging.LogContinuation(*message)(messagingPipeline, messaging.FailurePassingContinuation)(persist, messaging.FailurePassingContinuation)(messaging.IdContinuation, func(message messaging.Message) messaging.ContinuationChain {
err = fmt.Errorf("err propagating stuff for message %s", message)
return messaging.FailurePassingContinuation(message)
})
if err != nil {
log.Printf("err when saving message %s %s", name, err)
resp.WriteHeader(http.StatusInternalServerError)
return failure(context, req, resp)
}
return success(context, req, resp)
}
}

View File

@ -17,6 +17,9 @@ type Arguments struct {
HttpSmsEndpoint string
NtfyEndpoint string
NtfyTopic string
Port int
Server bool
}
@ -60,6 +63,9 @@ func GetArgs() (*Arguments, error) {
httpSmsEndpoint := flag.String("httpsms-endpoint", "https://httpsms.com", "HTTPSMS endpoint")
ntfyTopic := flag.String("ntfy-topic", "sms", "NTFY endpoint")
ntfyEndpoint := flag.String("ntfy-endpoint", "https://ntfy.simponic.hatecomputers.club", "HTTPSMS endpoint")
scheduler := flag.Bool("scheduler", false, "Run scheduled jobs via cron")
migrate := flag.Bool("migrate", false, "Run the migrations")
@ -77,6 +83,8 @@ func GetArgs() (*Arguments, error) {
Migrate: *migrate,
Scheduler: *scheduler,
HttpSmsEndpoint: *httpSmsEndpoint,
NtfyTopic: *ntfyTopic,
NtfyEndpoint: *ntfyEndpoint,
}
err := validateArgs(args)
if err != nil {

View File

@ -6,14 +6,14 @@ const runChat = async () => {
r.text(),
);
const { scrollTop, scrollHeight } = document.getElementById(
const { scrollTop, scrollTopMax } = document.getElementById(
"chat-container",
) ?? { scrollTop: 0 };
const isAtEdge = scrollTop === scrollHeight || scrollTop === 0;
const isAtEdge = scrollTop > (0.92 * scrollTopMax) || scrollTop === 0;
document.getElementById("messages").innerHTML = html;
if (isAtEdge) {
document.getElementById("chat-container").scrollTop =
document.getElementById("chat-container").scrollHeight;
document.getElementById("chat-container").scrollTopMax;
} else {
// save the position.
document.getElementById("chat-container").scrollTop = scrollTop;

7
utils/quote_str.go Normal file
View File

@ -0,0 +1,7 @@
package utils
import "strings"
func Quote(s string) string {
return strings.Replace(s, `"`, `\"`, -1)
}