diff --git a/backend/cmd/cmd_email.go b/backend/cmd/cmd_email.go new file mode 100644 index 0000000..b3a2a95 --- /dev/null +++ b/backend/cmd/cmd_email.go @@ -0,0 +1,72 @@ +package cmd + +import ( + "fmt" + "io" + "slices" + + "github.com/RoundRobinHood/cogniflight-cloud/backend/email" + "github.com/RoundRobinHood/cogniflight-cloud/backend/types" + "github.com/RoundRobinHood/cogniflight-cloud/backend/util" + "github.com/RoundRobinHood/sh" +) + +type CmdEmail struct { + EmailConfig email.EmailConfig +} + +func (c CmdEmail) Identifier() string { + return "email" +} + +func (c CmdEmail) Run(ctx sh.CommandContext) int { + tags := util.GetTags(ctx.Ctx) + if !slices.Contains(tags, "sysadmin") && !slices.Contains(tags, "atc") { + fmt.Fprint(ctx.Stderr, "access denied") + return 1 + } + + if len(ctx.Args) == 1 { + fmt.Fprint(ctx.Stderr, "usage: email [-s ] [-c ] ") + return 1 + } + + opts, email, err := util.ParseArgs([]types.OptionDescriptor{ + { + Identifier: "subject", + Aliases: []string{"s", "subject"}, + Default: "", + }, + { + Identifier: "content_type", + Aliases: []string{"c", "content-type"}, + Default: "text/plain", + }, + }, ctx.Args[1:]) + + if err != nil { + fmt.Fprint(ctx.Stderr, err) + return 1 + } + + if len(email) != 1 { + fmt.Fprint(ctx.Stderr, "usage: email [-s ] [-c ] ") + return 1 + } + + subject := opts["subject"].(string) + contentType := opts["content_type"].(string) + + email_bytes, err := io.ReadAll(ctx.Stdin) + if err != nil { + fmt.Fprint(ctx.Stderr, "error getting input: ", err) + return 1 + } + + if err := c.EmailConfig.SendEmail(email[0], subject, contentType, string(email_bytes)); err != nil { + fmt.Fprint(ctx.Stderr, err) + return 1 + } + + return 0 +} diff --git a/backend/cmd/cmd_send-text.go b/backend/cmd/cmd_send-text.go new file mode 100644 index 0000000..517d8b3 --- /dev/null +++ b/backend/cmd/cmd_send-text.go @@ -0,0 +1,76 @@ +package cmd + +import ( + "fmt" + "io" + "slices" + + "github.com/RoundRobinHood/cogniflight-cloud/backend/types" + "github.com/RoundRobinHood/cogniflight-cloud/backend/uazapi" + "github.com/RoundRobinHood/cogniflight-cloud/backend/util" + "github.com/RoundRobinHood/sh" +) + +type CmdSendText struct { + UazapiConfig uazapi.UazapiConfig +} + +func (c *CmdSendText) Identifier() string { + return "send-text" +} + +func (c *CmdSendText) Run(ctx sh.CommandContext) int { + tags := util.GetTags(ctx.Ctx) + if !slices.Contains(tags, "sysadmin") && !slices.Contains(tags, "atc") { + fmt.Fprint(ctx.Stderr, "access denied") + return 1 + } + + if len(ctx.Args) == 1 { + fmt.Fprint(ctx.Stderr, "usage: send-text [-p] ") + return 1 + } + + opts, numbers, err := util.ParseArgs([]types.OptionDescriptor{ + { + Identifier: "preview_links", + Aliases: []string{"p", "preview", "preview-links"}, + Default: false, + }, + }, ctx.Args[1:]) + if err != nil { + fmt.Fprint(ctx.Stderr, err) + return 1 + } + + if len(numbers) == 0 { + fmt.Fprint(ctx.Stderr, "usage: send-text [-p] ") + return 1 + } + + preview_links := opts["preview_links"].(bool) + + message_bytes, err := io.ReadAll(ctx.Stdin) + if err != nil { + fmt.Fprint(ctx.Stderr, "failed to read input: ", err) + return 1 + } + + if len(message_bytes) == 0 { + fmt.Fprint(ctx.Stderr, "error: can't send an empty text message") + } + + message_text := string(message_bytes) + for _, number := range numbers { + if err := c.UazapiConfig.SendTextMessage(uazapi.TxtMessage{ + Number: number, + Text: message_text, + LinkPreview: preview_links, + }); err != nil { + fmt.Fprintf(ctx.Stderr, "failed to send message to %q: %v", number, err) + return 1 + } + } + + return 0 +} diff --git a/backend/cmd/endpoint.go b/backend/cmd/endpoint.go index 1a799c0..1b226fd 100644 --- a/backend/cmd/endpoint.go +++ b/backend/cmd/endpoint.go @@ -8,9 +8,11 @@ import ( "sync" "github.com/RoundRobinHood/cogniflight-cloud/backend/chatbot" + "github.com/RoundRobinHood/cogniflight-cloud/backend/email" "github.com/RoundRobinHood/cogniflight-cloud/backend/filesystem" "github.com/RoundRobinHood/cogniflight-cloud/backend/influx" "github.com/RoundRobinHood/cogniflight-cloud/backend/types" + "github.com/RoundRobinHood/cogniflight-cloud/backend/uazapi" "github.com/RoundRobinHood/cogniflight-cloud/backend/util" "github.com/gin-gonic/gin" "github.com/gorilla/websocket" @@ -24,7 +26,16 @@ var upgrader = websocket.Upgrader{ }, } -func CmdWebhook(filestore filesystem.Store, sessionStore *types.SessionStore, apiKey chatbot.APIKey, jsonConn *jsonrpc2.Conn, mqttEvents *util.EventHandler[types.MQTTMessage], flux_cfg *influx.InfluxDBConfig) gin.HandlerFunc { +func CmdWebhook( + filestore filesystem.Store, + sessionStore *types.SessionStore, + apiKey chatbot.APIKey, + jsonConn *jsonrpc2.Conn, + mqttEvents *util.EventHandler[types.MQTTMessage], + flux_cfg *influx.InfluxDBConfig, + uazapi_cfg uazapi.UazapiConfig, + email_cfg email.EmailConfig, +) gin.HandlerFunc { return func(c *gin.Context) { auth_get, ok := c.Get("auth") if !ok { @@ -35,10 +46,21 @@ func CmdWebhook(filestore filesystem.Store, sessionStore *types.SessionStore, ap auth_status := auth_get.(types.AuthorizationStatus) socketID := util.RandHex(20) session := sessionStore.AttachSession(socketID, auth_status) - available_commands := InitCommands(filestore, filesystem.FSContext{ - Store: filestore, - UserTags: auth_status.Tags, - }, session, sessionStore, apiKey, jsonConn, mqttEvents, flux_cfg) + available_commands := InitCommands( + filestore, + filesystem.FSContext{ + Store: filestore, + UserTags: auth_status.Tags, + }, + session, + sessionStore, + apiKey, + jsonConn, + mqttEvents, + flux_cfg, + uazapi_cfg, + email_cfg, + ) clients := map[string]types.ClientInfo{} client_cancels := map[string]context.CancelFunc{} diff --git a/backend/cmd/init_commands.go b/backend/cmd/init_commands.go index 946948d..660c826 100644 --- a/backend/cmd/init_commands.go +++ b/backend/cmd/init_commands.go @@ -2,15 +2,28 @@ package cmd import ( "github.com/RoundRobinHood/cogniflight-cloud/backend/chatbot" + "github.com/RoundRobinHood/cogniflight-cloud/backend/email" "github.com/RoundRobinHood/cogniflight-cloud/backend/filesystem" "github.com/RoundRobinHood/cogniflight-cloud/backend/influx" "github.com/RoundRobinHood/cogniflight-cloud/backend/types" + "github.com/RoundRobinHood/cogniflight-cloud/backend/uazapi" "github.com/RoundRobinHood/cogniflight-cloud/backend/util" "github.com/RoundRobinHood/sh" "github.com/sourcegraph/jsonrpc2" ) -func InitCommands(filestore filesystem.Store, fsctx filesystem.FSContext, socketSession *types.SocketSession, sessionStore *types.SessionStore, apiKey chatbot.APIKey, jsonConn *jsonrpc2.Conn, mqttEvents *util.EventHandler[types.MQTTMessage], flux_cfg *influx.InfluxDBConfig) []sh.Command { +func InitCommands( + filestore filesystem.Store, + fsctx filesystem.FSContext, + socketSession *types.SocketSession, + sessionStore *types.SessionStore, + apiKey chatbot.APIKey, + jsonConn *jsonrpc2.Conn, + mqttEvents *util.EventHandler[types.MQTTMessage], + flux_cfg *influx.InfluxDBConfig, + uazapi_cfg uazapi.UazapiConfig, + email_cfg email.EmailConfig, +) []sh.Command { commands := []sh.Command{ &CmdWhoami{FileStore: filestore, Session: socketSession}, CmdHelp{}, @@ -46,6 +59,9 @@ func InitCommands(filestore filesystem.Store, fsctx filesystem.FSContext, socket CmdB64{}, CmdHex{}, + + &CmdSendText{UazapiConfig: uazapi_cfg}, + CmdEmail{EmailConfig: email_cfg}, } activate_cmd := &CmdActivate{ diff --git a/backend/email/send_email.go b/backend/email/send_email.go new file mode 100644 index 0000000..201d1b6 --- /dev/null +++ b/backend/email/send_email.go @@ -0,0 +1,22 @@ +package email + +import "gopkg.in/gomail.v2" + +type EmailConfig struct { + Username, Password, Host string + Port int +} + +func (c EmailConfig) SendEmail(receiver, subject, contentType, content string) error { + m := gomail.NewMessage() + m.SetHeader("From", c.Username) + m.SetHeader("To", receiver) + if subject != "" { + m.SetHeader("Subject", subject) + } + m.SetBody(contentType, content) + + d := gomail.NewDialer(c.Host, c.Port, c.Username, c.Password) + + return d.DialAndSend(m) +} diff --git a/backend/go.mod b/backend/go.mod index 3752a83..c5928cf 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -50,5 +50,7 @@ require ( golang.org/x/sys v0.36.0 // indirect golang.org/x/text v0.29.0 // indirect google.golang.org/protobuf v1.36.6 // indirect + gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect + gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index a4a9a72..6d67354 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -145,8 +145,12 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= +gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE= +gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/backend/main.go b/backend/main.go index 4306a27..4602bfc 100644 --- a/backend/main.go +++ b/backend/main.go @@ -14,9 +14,11 @@ import ( "github.com/RoundRobinHood/cogniflight-cloud/backend/auth" "github.com/RoundRobinHood/cogniflight-cloud/backend/chatbot" "github.com/RoundRobinHood/cogniflight-cloud/backend/cmd" + "github.com/RoundRobinHood/cogniflight-cloud/backend/email" "github.com/RoundRobinHood/cogniflight-cloud/backend/filesystem" "github.com/RoundRobinHood/cogniflight-cloud/backend/influx" "github.com/RoundRobinHood/cogniflight-cloud/backend/types" + "github.com/RoundRobinHood/cogniflight-cloud/backend/uazapi" "github.com/RoundRobinHood/jlogging" "github.com/gin-gonic/gin" "github.com/sourcegraph/jsonrpc2" @@ -41,6 +43,38 @@ func main() { log.Fatal("Missing OpenAI key") } + uazapi_url := os.Getenv("UAZAPI_URL") + if uazapi_url == "" { + log.Fatal("Missing UAZAPI URL") + } + + uazapi_key := os.Getenv("UAZAPI_API_KEY") + if uazapi_key == "" { + log.Fatal("Missing UAZAPI API Key") + } + + email_username := os.Getenv("EMAIL_USERNAME") + if email_username == "" { + log.Fatal("Missing email username") + } + + email_password := os.Getenv("EMAIL_PASSWORD") + if email_password == "" { + log.Fatal("Missing email password") + } + + email_host := os.Getenv("EMAIL_HOST") + if email_host == "" { + log.Fatal("Missing email host") + } + + email_port := 465 + if port_str := os.Getenv("EMAIL_PORT"); port_str != "" { + if _, err := fmt.Sscan(port_str, &email_port); err != nil { + log.Fatal("invalid email port: ", err) + } + } + database := client.Database("cogniflight") bucket, err := gridfs.NewBucket(database) if err != nil { @@ -121,11 +155,30 @@ func main() { r.GET("/signup/check-username/:username", auth.SignupCheckUsername(fileStore)) r.POST("/signup", auth.Signup(fileStore)) r.POST("/login", auth.Login(fileStore)) - r.GET("/cmd-socket", auth.AuthMiddleware(fileStore), cmd.CmdWebhook(fileStore, sessionStore, chatbot.APIKey(openAIKey), jsonConn, mqttEvents, &influx.InfluxDBConfig{ - URL: os.Getenv("INFLUX_URL"), - Token: os.Getenv("INFLUX_TOKEN"), - Org: os.Getenv("INFLUX_ORG"), - })) + r.GET("/cmd-socket", auth.AuthMiddleware(fileStore), + cmd.CmdWebhook( + fileStore, + sessionStore, + chatbot.APIKey(openAIKey), + jsonConn, + mqttEvents, + &influx.InfluxDBConfig{ + URL: os.Getenv("INFLUX_URL"), + Token: os.Getenv("INFLUX_TOKEN"), + Org: os.Getenv("INFLUX_ORG"), + }, + uazapi.UazapiConfig{ + BaseURL: uazapi_url, + APIKey: uazapi_key, + HTTPClient: &http.Client{}, + }, + email.EmailConfig{ + Username: email_username, + Password: email_password, + Host: email_host, + Port: email_port, + }, + )) server := &http.Server{ Addr: ":8080", diff --git a/backend/uazapi/send_message.go b/backend/uazapi/send_message.go new file mode 100644 index 0000000..7e9d30f --- /dev/null +++ b/backend/uazapi/send_message.go @@ -0,0 +1,47 @@ +package uazapi + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" +) + +type UazapiConfig struct { + BaseURL, APIKey string + HTTPClient *http.Client +} + +type TxtMessage struct { + Number string `json:"number"` + Text string `json:"text"` + LinkPreview bool `json:"linkPreview"` +} + +func (c *UazapiConfig) SendTextMessage(msg TxtMessage) error { + send_bytes, _ := json.Marshal(msg) + + req, _ := http.NewRequest("POST", c.BaseURL+"/send/text", bytes.NewReader(send_bytes)) + + req.Header.Set("token", c.APIKey) + + req.Header.Set("Content-Type", "application/json") + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + body_bytes, err := io.ReadAll(resp.Body) + + if resp.StatusCode != 200 { + if err != nil { + return fmt.Errorf("non-200 status code (%d) (failed to get response body)", resp.StatusCode) + } else { + return fmt.Errorf("non-200 status code (%d), response body: \n%v\n", resp.StatusCode, string(body_bytes)) + } + } + + return nil +} diff --git a/docker-compose.yml b/docker-compose.yml index c766d69..5e37a16 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -129,6 +129,14 @@ services: MQTT_KEY: "${MQTT_KEY:-mqttpass}" OPENAI_API_KEY: "$OPENAI_API_KEY" + + UAZAPI_URL: "${UAZAPI_URL}" + UAZAPI_API_KEY: "${UAZAPI_API_KEY}" + + EMAIL_USERNAME: "${EMAIL_USERNAME}" + EMAIL_PASSWORD: "${EMAIL_PASSWORD}" + EMAIL_HOST: "${EMAIL_HOST}" + EMAIL_PORT: "${EMAIL_PORT}" labels: - "traefik.enable=true" - "traefik.http.routers.backend.rule=Host(`${SERVER_DOMAIN:-localhost}`) && PathPrefix(`/api`)" diff --git a/frontend/src/components/apps/InviteUserApp.jsx b/frontend/src/components/apps/InviteUserApp.jsx index 0768958..c2d8da5 100644 --- a/frontend/src/components/apps/InviteUserApp.jsx +++ b/frontend/src/components/apps/InviteUserApp.jsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { useSystem } from "../useSystem"; -import { usePipeClient } from "../../api/socket"; +import { StringIterator, usePipeClient } from "../../api/socket"; import "../../styles/apps/app-base.css"; import "../../styles/utilities/modal.css"; @@ -36,7 +36,30 @@ export default function InviteUserApp({ instanceData }) { const contactInfo = email ? { email } : { phone }; const token = await client.create_invite(role, contactInfo); - const link = `https://cogniflight.exequtech.com?token=${token}`; + const link = window.location.origin + "?token=" + token; + + if(phone) { + try { + const cmd = await client.run_command(`send-text -p '${phone}'`, StringIterator(`Hello! You've been invited to sign up to a cogniflight instance.\n Here's your invite link: ${link}`)); + if(cmd.command_result != 0) { + throw new Error(cmd.error); + } + addNotification("sent whatsapp to " + phone, "success"); + } catch(err) { + addNotification("failed to send invite message: " + err, "error"); + } + } + if(email) { + try { + const cmd = await client.run_command(`email -s 'Invite to platform' -c 'text/html' '${email}'`, StringIterator(`Hello! You've been invited to sign up to a cogniflight instance.
Click here to sign up.`)); + if(cmd.command_result != 0) { + throw new Error(cmd.error); + } + addNotification("sent email to " + email, "success"); + } catch(err) { + addNotification("failed to send email: " + err, "error"); + } + } setInviteLink(link); setShowPopup(true); addNotification("Invitation created successfully!", "success");