diff --git a/Dockerfile b/Dockerfile index e84c50b..68c157a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index 8802af4..4c794cd 100644 --- a/README.md +++ b/README.md @@ -6,3 +6,4 @@ TODO: - [ ] pagination for messages - [ ] full text search? - [ ] better auth lol +- [ ] bruh diff --git a/adapters/messaging/db.go b/adapters/messaging/db.go new file mode 100644 index 0000000..4cad3e2 --- /dev/null +++ b/adapters/messaging/db.go @@ -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) + } + } +} diff --git a/adapters/messaging/http_sms.go b/adapters/messaging/http_sms.go index c722f21..8d1c99f 100644 --- a/adapters/messaging/http_sms.go +++ b/adapters/messaging/http_sms.go @@ -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,37 +19,35 @@ 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 (adapter *HttpSmsMessagingAdapter) SendMessage(message string) (string, error) { - url := fmt.Sprintf("%s/v1/messages/send", adapter.Endpoint) - payload := strings.NewReader(adapter.encodeMessage(message)) - - req, _ := http.NewRequest("POST", url, payload) - req.Header.Add("x-api-key", adapter.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) - } - - defer res.Body.Close() - body, _ := io.ReadAll(res.Body) - - var response HttpSmsMessageSendResponse - err = json.Unmarshal(body, &response) - if err != nil { - log.Printf("got error unmarshaling response: %s %s", body, err) - return "", err - } - - return response.Data.RequestId, nil +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) + + 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", apiToken) + 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", message, res, err) + return failure(message) + } + + defer res.Body.Close() + body, _ := io.ReadAll(res.Body) + + var response HttpSmsMessageSendResponse + err = json.Unmarshal(body, &response) + if err != nil { + log.Printf("got error unmarshaling response: %s %s", body, err) + return failure(message) + } + + return success(message) + } + } } diff --git a/adapters/messaging/messaging.go b/adapters/messaging/messaging.go index 607258a..f802503 100644 --- a/adapters/messaging/messaging.go +++ b/adapters/messaging/messaging.go @@ -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 diff --git a/adapters/messaging/ntfy.go b/adapters/messaging/ntfy.go new file mode 100644 index 0000000..837c01b --- /dev/null +++ b/adapters/messaging/ntfy.go @@ -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) + } + } +} diff --git a/api/api.go b/api/api.go index 07db731..cb5101b 100644 --- a/api/api.go +++ b/api/api.go @@ -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) diff --git a/api/chat/chat.go b/api/chat/chat.go index 51ee47d..fcff9f0 100644 --- a/api/chat/chat.go +++ b/api/chat/chat.go @@ -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, i’ll 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 " " - 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) } } diff --git a/args/args.go b/args/args.go index 458d36e..6c112c6 100644 --- a/args/args.go +++ b/args/args.go @@ -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 { diff --git a/static/js/components/chat.js b/static/js/components/chat.js index bdc8ad1..e5d1185 100644 --- a/static/js/components/chat.js +++ b/static/js/components/chat.js @@ -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; diff --git a/utils/quote_str.go b/utils/quote_str.go new file mode 100644 index 0000000..acff5a2 --- /dev/null +++ b/utils/quote_str.go @@ -0,0 +1,7 @@ +package utils + +import "strings" + +func Quote(s string) string { + return strings.Replace(s, `"`, `\"`, -1) +}