diff --git a/cmd/agent_context.go b/cmd/agent_context.go index 4793d87..668c25e 100644 --- a/cmd/agent_context.go +++ b/cmd/agent_context.go @@ -4,16 +4,53 @@ import ( "encoding/json" "os" + "github.com/memcode-ai/memcode/internal/agent/input" "github.com/memcode-ai/memcode/internal/agent/runtime" + "github.com/memcode-ai/memcode/internal/channels" gwconfig "github.com/memcode-ai/memcode/internal/gateway/config" ) // jobContext mirrors the envelope the gateway persists at gwconfig.ContextPath — -// the persona's supplemental context plus its extra skill roots. The JSON shape -// is the contract with internal/gateway/server. +// the persona's supplemental context, its extra skill roots, and this task's +// media as spool IDs. The JSON shape is the contract with internal/gateway/server. type jobContext struct { Items []runtime.ContextItem `json:"items,omitempty"` SkillRoots []string `json:"skill_roots,omitempty"` + // Attachments are media spool IDs — bare . filenames, resolved + // STRICTLY inside the gateway media spool (see resolveJobAttachments). Never + // paths: the spool is the trust boundary, so a corrupted context file cannot + // point this job at arbitrary local files. + Attachments []string `json:"attachments,omitempty"` +} + +// resolveJobAttachments turns spool IDs into engine attachments. Each ID must +// resolve inside the media spool; anything else — separators, dot-files, a +// missing file, an unsupported kind — is skipped. Audio never reaches the +// engine (the gateway transcribes it before spawning the job). +func resolveJobAttachments(ids []string) []input.Attachment { + if len(ids) == 0 { + return nil + } + spool, err := gwconfig.MediaDir() + if err != nil { + return nil + } + var out []input.Attachment + for _, id := range ids { + path, err := channels.ResolveSpoolID(spool, id) + if err != nil { + continue + } + att, ok := input.Resolve(path, spool, "channel") + if !ok { + continue + } + switch att.Kind { + case input.KindImage, input.KindPDF, input.KindText: + out = append(out, att) + } + } + return out } // loadJobContext reads the job context the gateway persisted for this session diff --git a/cmd/gateway.go b/cmd/gateway.go index d3abebc..391ae60 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -99,7 +99,7 @@ var gatewaySetupCmd = &cobra.Command{ } else { cmd.Printf("Configured: %s\n", strings.Join(enabled, ", ")) } - choice := strings.ToLower(strings.TrimSpace(prompt(in, cmd, "Channel to add/update [telegram/discord/slack/github/whatsapp] (blank to finish): "))) + choice := strings.ToLower(strings.TrimSpace(prompt(in, cmd, "Channel to add/update [telegram/discord/slack/email/signal/matrix/mattermost/msteams/googlechat/sms/github/whatsapp] (blank to finish): "))) secrets := map[string]string{} if settings.Channels == nil { @@ -136,8 +136,46 @@ var gatewaySetupCmd = &cobra.Command{ secrets[gwconfig.EnvWhatsAppVerify] = secret(cmd, "Webhook verify token: ") secrets[gwconfig.EnvWhatsAppSecret] = secret(cmd, "App secret (verifies inbound; required to activate): ") ch.AllowFrom = allowList(in, cmd) + case "email": + cmd.Println("Use a DEDICATED mailbox (an app password for Gmail/Outlook), never your personal inbox.") + secrets[gwconfig.EnvEmailAddress] = strings.TrimSpace(prompt(in, cmd, "Email address: ")) + secrets[gwconfig.EnvEmailPassword] = secret(cmd, "App password: ") + secrets[gwconfig.EnvEmailIMAPHost] = strings.TrimSpace(prompt(in, cmd, "IMAP host (e.g. imap.gmail.com): ")) + secrets[gwconfig.EnvEmailSMTPHost] = strings.TrimSpace(prompt(in, cmd, "SMTP host (e.g. smtp.gmail.com): ")) + ch.AllowFrom = allowList(in, cmd) + case "signal": + cmd.Println("Requires a running signal-cli daemon in HTTP mode (see the docs); use a dedicated number.") + secrets[gwconfig.EnvSignalNumber] = strings.TrimSpace(prompt(in, cmd, "Your Signal number (+E.164): ")) + if u := strings.TrimSpace(prompt(in, cmd, "signal-cli daemon URL (blank = http://127.0.0.1:8080): ")); u != "" { + secrets[gwconfig.EnvSignalCLIURL] = u + } + ch.AllowFrom = allowList(in, cmd) + case "matrix": + cmd.Println("Plain rooms only for now (no end-to-end-encrypted rooms).") + secrets[gwconfig.EnvMatrixHomeserver] = strings.TrimSpace(prompt(in, cmd, "Homeserver URL (e.g. https://matrix.org): ")) + secrets[gwconfig.EnvMatrixToken] = secret(cmd, "Access token: ") + ch.AllowFrom = allowList(in, cmd) + case "mattermost": + secrets[gwconfig.EnvMattermostURL] = strings.TrimSpace(prompt(in, cmd, "Server URL (e.g. https://mm.example.com): ")) + secrets[gwconfig.EnvMattermostToken] = secret(cmd, "Bot access token: ") + ch.AllowFrom = allowList(in, cmd) + case "msteams": + secrets[gwconfig.EnvTeamsAppID] = strings.TrimSpace(prompt(in, cmd, "Azure app (bot) ID: ")) + secrets[gwconfig.EnvTeamsAppPassword] = secret(cmd, "Client secret: ") + secrets[gwconfig.EnvTeamsTenantID] = strings.TrimSpace(prompt(in, cmd, "Tenant ID: ")) + ch.AllowFrom = allowList(in, cmd) + case "googlechat": + secrets[gwconfig.EnvGoogleChatSAKey] = strings.TrimSpace(prompt(in, cmd, "Path to the service-account JSON key: ")) + ch.Audience = strings.TrimSpace(prompt(in, cmd, "Project number (JWT audience): ")) + ch.AllowFrom = allowList(in, cmd) + case "sms": + secrets[gwconfig.EnvTwilioAccountSID] = strings.TrimSpace(prompt(in, cmd, "Twilio Account SID: ")) + secrets[gwconfig.EnvTwilioAuthToken] = secret(cmd, "Auth token: ") + secrets[gwconfig.EnvTwilioFromNumber] = strings.TrimSpace(prompt(in, cmd, "Your Twilio number (+E.164): ")) + ch.WebhookURL = strings.TrimSpace(prompt(in, cmd, "Exact public webhook URL (e.g. https://gw.example.com/webhook/sms): ")) + ch.AllowFrom = allowList(in, cmd) default: - cmd.Println("Unknown channel; pick one of telegram/discord/slack/github/whatsapp.") + cmd.Println("Unknown channel; pick one of telegram/discord/slack/email/signal/matrix/mattermost/msteams/googlechat/sms/github/whatsapp.") continue } settings.Channels[choice] = ch diff --git a/cmd/run.go b/cmd/run.go index d8d1847..937b059 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -141,9 +141,10 @@ for local gateway development. Never store keys in .memcode.`, // Uses the chat seams (which load + save the transcript) instead of Run. if sessionID, _ := cmd.Flags().GetString("session"); sessionID != "" { sess.SetSessionID(sessionID) - if jc := loadJobContext(sessionID); len(jc.Items) > 0 || len(jc.SkillRoots) > 0 { - sess.SetContext(jc.Items) // gateway-supplied persona/user context for this run - sess.SetSkillRoots(jc.SkillRoots) // persona's own skills join discovery + if jc := loadJobContext(sessionID); len(jc.Items) > 0 || len(jc.SkillRoots) > 0 || len(jc.Attachments) > 0 { + sess.SetContext(jc.Items) // gateway-supplied persona/user context for this run + sess.SetSkillRoots(jc.SkillRoots) // persona's own skills join discovery + sess.SetTaskAttachments(resolveJobAttachments(jc.Attachments)) // channel media rides this turn } if _, err := runtime.ResolveSession(cfg.Root, sessionID); err == nil { sess.SetResume(sessionID) diff --git a/docs/gateway/README.md b/docs/gateway/README.md index d2b6c3f..15bd3af 100644 --- a/docs/gateway/README.md +++ b/docs/gateway/README.md @@ -2,8 +2,9 @@ The same `memcode` binary that runs the interactive agent can run as a long-lived, self-hosted **gateway**: it listens on the surfaces people already -use (Telegram, Discord, Slack, GitHub, WhatsApp), turns each inbound message -into an agent job, and posts the result back. Coding is one use of this loop, +use (Telegram, Discord, Slack, Email, Signal, Matrix, Mattermost, Microsoft +Teams, Google Chat, SMS, GitHub, WhatsApp), turns each inbound message into an +agent job, and posts the result back. Coding is one use of this loop, not what it's built around — an inbound message is just a task. ``` @@ -30,13 +31,31 @@ It routes each answer the way memcode splits configuration: A channel is enabled when its secret is present. -| Channel | Secret(s) in `.env` | Transport | -|----------|------------------------------------------------------------|-------------------| -| Telegram | `TELEGRAM_BOT_TOKEN` | Bot API long-poll | -| Discord | `DISCORD_BOT_TOKEN` | gateway websocket | -| Slack | `SLACK_APP_TOKEN`, `SLACK_BOT_TOKEN` | Socket Mode | -| GitHub | `GITHUB_WEBHOOK_SECRET` | inbound webhook | -| WhatsApp | `WHATSAPP_ACCESS_TOKEN`, `WHATSAPP_VERIFY_TOKEN`, `WHATSAPP_APP_SECRET` | Meta Cloud API | +| Channel | Secret(s) in `.env` | Transport | +|-------------|------------------------------------------------------------|-------------------| +| Telegram | `TELEGRAM_BOT_TOKEN` | Bot API long-poll | +| Discord | `DISCORD_BOT_TOKEN` | gateway websocket | +| Slack | `SLACK_APP_TOKEN`, `SLACK_BOT_TOKEN` | Socket Mode | +| Email | `EMAIL_ADDRESS`, `EMAIL_PASSWORD`, `EMAIL_IMAP_HOST`, `EMAIL_SMTP_HOST` | IMAP poll + SMTP | +| Signal | `SIGNAL_NUMBER` (+ optional `SIGNAL_CLI_URL`) | signal-cli daemon (SSE + JSON-RPC) | +| Matrix | `MATRIX_HOMESERVER`, `MATRIX_ACCESS_TOKEN` | client-server /sync (no E2EE v1) | +| Mattermost | `MATTERMOST_URL`, `MATTERMOST_TOKEN` | websocket + REST v4 | +| MS Teams | `TEAMS_APP_ID`, `TEAMS_APP_PASSWORD`, `TEAMS_TENANT_ID` | Bot Framework webhook | +| Google Chat | `GOOGLE_CHAT_SA_KEY` (path) + `googlechat.audience` | signed webhook + Chat REST | +| SMS | `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN`, `TWILIO_FROM_NUMBER` + `sms.webhook_url` | Twilio webhook + Messages API | +| GitHub | `GITHUB_WEBHOOK_SECRET` | inbound webhook | +| WhatsApp | `WHATSAPP_ACCESS_TOKEN`, `WHATSAPP_VERIFY_TOKEN`, `WHATSAPP_APP_SECRET` | Meta Cloud API | + +Webhook-driven surfaces (Teams, Google Chat, SMS, GitHub, WhatsApp) mount on +the shared listener (`webhook.addr`, default `:8787`) at +`/webhook/{teams,googlechat,sms,github,whatsapp}` — expose it over HTTPS. +Email dedup is keyed on `//` (the provider-side ack +identity); Message-ID serves threading only. Email's sender identity is the +RFC From address — weaker than the other channels' platform-verified ids, so +its allow-list depends on your mailbox provider rejecting spoofed mail +(SPF/DKIM/DMARC); use a mainstream provider and a dedicated account. Signal requires a signal-cli +daemon in native HTTP mode; Matrix v1 is plain rooms only (E2EE is a known +follow-up). ### gateway.yaml @@ -136,6 +155,23 @@ projects, agents, channel knobs) on change, so an approval takes effect within seconds — no restart. Channel connections and schedules are wired at startup and do not hot-reload. +## Media and voice + +Inbound attachments (photos, PDFs, documents) are downloaded into a +content-addressed media spool (`~/.config/memcode/media`, pruned with the +inbox) and ride the task into the engine as native image/document blocks. +Everything downstream of the adapter addresses media by spool ID, never by +path — the spool is the trust boundary. + +Voice notes are transcribed gateway-side (OpenAI `gpt-4o-mini-transcribe` +falling back to `whisper-1`, or Gemini — picked by whichever key is present) +and the transcript becomes the task text; audio never reaches the engine. +Without either key a voice-only message gets an honest "not configured" reply. +Optionally, `channels..voice_replies: in_kind|always` (default `off`) +synthesizes an OGG/Opus voice reply (OpenAI `gpt-4o-mini-tts` — the full text +is always sent alongside, code blocks are never spoken, synthesis failures +degrade to text). + ## Import from OpenClaw Already running OpenClaw? Bring your channels over with one command: diff --git a/go.mod b/go.mod index 6ffabff..c10ce8a 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,9 @@ require ( github.com/charmbracelet/x/term v0.2.2 github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc github.com/chromedp/chromedp v0.15.1 + github.com/emersion/go-imap/v2 v2.0.0-beta.8 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/gorilla/websocket v1.5.3 github.com/mattn/go-runewidth v0.0.24 github.com/modelcontextprotocol/go-sdk v1.6.1 github.com/openai/openai-go/v3 v3.41.1 @@ -24,6 +27,7 @@ require ( go.yaml.in/yaml/v4 v4.0.0-rc.2 golang.org/x/image v0.43.0 golang.org/x/net v0.56.0 + golang.org/x/oauth2 v0.36.0 golang.org/x/sys v0.46.0 golang.org/x/term v0.44.0 google.golang.org/genai v1.63.0 @@ -45,6 +49,8 @@ require ( github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/dlclark/regexp2/v2 v2.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/emersion/go-message v0.18.2 // indirect + github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect github.com/go-logr/logr v1.4.3 // indirect @@ -58,7 +64,6 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.17 // indirect github.com/googleapis/gax-go/v2 v2.23.0 // indirect - github.com/gorilla/websocket v1.5.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/invopop/jsonschema v0.14.0 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect @@ -84,7 +89,6 @@ require ( go.opentelemetry.io/otel/metric v1.44.0 // indirect go.opentelemetry.io/otel/trace v1.44.0 // indirect golang.org/x/crypto v0.53.0 // indirect - golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.21.0 // indirect golang.org/x/text v0.38.0 // indirect google.golang.org/api v0.287.1 // indirect diff --git a/go.sum b/go.sum index af472fc..444346e 100644 --- a/go.sum +++ b/go.sum @@ -55,6 +55,12 @@ github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug= +github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48= +github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg= +github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA= +github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk= +github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao= @@ -168,6 +174,7 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= @@ -185,35 +192,60 @@ go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUS go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s= go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY= golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/api v0.287.1 h1:LiyJx32VU3cwQfLchn/513qKhc25hq0pEANYJoWNnnI= diff --git a/internal/agent/input/image.go b/internal/agent/input/image.go index a68fa79..3433051 100644 --- a/internal/agent/input/image.go +++ b/internal/agent/input/image.go @@ -19,6 +19,13 @@ import ( // bandwidth + latency — on EVERY turn the image rides in the conversation history. const maxImageEdge = 2576 +// maxDecodePixels caps the pixel count we're willing to decode. A crafted PNG +// can declare enormous dimensions in a few compressed bytes (a decompression +// bomb) that expand to gigabytes of RGBA on Decode — so we read only the header +// (DecodeConfig) first and refuse an over-large image before allocating. 50MP +// comfortably clears any real screenshot or phone photo while stopping the bomb. +const maxDecodePixels = 50_000_000 + // Downscale shrinks an image's long edge to maxImageEdge (re-encoding in the same format) when // it's larger, returning the smaller bytes. It's a best-effort optimization: any decode/encode // failure, an already-small image, or a result that isn't actually smaller returns the original @@ -30,6 +37,14 @@ func Downscale(data []byte, mime string) ([]byte, string) { default: return data, mime } + // Read only the header first: a hostile image can declare huge dimensions + // that would balloon to gigabytes of pixels on a full decode. Refuse an + // over-large image before decoding (it rides on as the original bytes; the + // downstream base64 caps still bound what actually reaches the model). + if cfg, _, err := image.DecodeConfig(bytes.NewReader(data)); err != nil || + int64(cfg.Width)*int64(cfg.Height) > maxDecodePixels { + return data, mime + } img, _, err := image.Decode(bytes.NewReader(data)) if err != nil { return data, mime diff --git a/internal/agent/input/image_test.go b/internal/agent/input/image_test.go index 904f646..6ade43c 100644 --- a/internal/agent/input/image_test.go +++ b/internal/agent/input/image_test.go @@ -1,6 +1,9 @@ package input import ( + "bytes" + "image" + "image/png" "os" "path/filepath" "strings" @@ -149,3 +152,29 @@ func TestParseLeavesNonexistentAndProseAlone(t *testing.T) { t.Errorf("prose should never attach: %+v", dec.Bundle.Attachments) } } + +// A crafted image declaring enormous dimensions is refused before decode (the +// decompression-bomb guard); a normal image still downscales. +func TestDownscaleRejectsOversizedDimensions(t *testing.T) { + // A 1x1 PNG that we then rewrite to claim a huge width/height in its IHDR + // would require hand-crafting; instead assert the guard via a real large-ish + // image stays intact and the pixel cap constant is enforced by DecodeConfig. + var buf bytes.Buffer + img := image.NewRGBA(image.Rect(0, 0, 8, 8)) + if err := png.Encode(&buf, img); err != nil { + t.Fatal(err) + } + // Corrupt the IHDR width/height to claim ~100000x100000 (well over the cap). + // PNG IHDR width is bytes 16..19, height 20..23 (after the 8-byte signature). + data := buf.Bytes() + bomb := make([]byte, len(data)) + copy(bomb, data) + // 100000 = 0x000186A0 + bomb[16], bomb[17], bomb[18], bomb[19] = 0x00, 0x01, 0x86, 0xA0 + bomb[20], bomb[21], bomb[22], bomb[23] = 0x00, 0x01, 0x86, 0xA0 + out, mime := Downscale(bomb, "image/png") + // Guard returns the original bytes unchanged rather than decoding the bomb. + if mime != "image/png" || len(out) != len(bomb) { + t.Errorf("oversized image should pass through untouched, got %d bytes", len(out)) + } +} diff --git a/internal/agent/runtime/approval.go b/internal/agent/runtime/approval.go index 2a9a6b0..a85d80c 100644 --- a/internal/agent/runtime/approval.go +++ b/internal/agent/runtime/approval.go @@ -42,7 +42,8 @@ type ApprovalDecision struct { RememberScope string // when Allow: the chosen ApprovalScope.Key ("" = none / plain yes) Command string // when Allow and non-empty: run THIS instead of the original Reason string // when !Allow: why — fed back to the model so it can adjust - Interrupt bool // stop the whole turn, not just this action + Interrupt bool // STOP the whole turn (Esc / "No, stop") — the model does not get another call + Redirect bool // when !Allow with a typed Reason: deny this action and skip its siblings, but let the turn CONTINUE so the model reads the feedback and responds (does NOT terminate) } // Allowed is a plain yes. diff --git a/internal/agent/runtime/approval_test.go b/internal/agent/runtime/approval_test.go index d06b40d..460883f 100644 --- a/internal/agent/runtime/approval_test.go +++ b/internal/agent/runtime/approval_test.go @@ -83,13 +83,32 @@ func TestGateCommandStructuredOutcomes(t *testing.T) { t.Fatalf("allow-with-edit: ok=%v cmd=%q", ok, cmd) } - // Interrupt → denied and the turn is marked interrupted. + // Interrupt → denied and the turn is marked interrupted (STOP). s = newTodoSession(t) s.approve = func(context.Context, ApprovalRequest) ApprovalDecision { return ApprovalDecision{Interrupt: true} } ok, _, _ = s.gateCommand(ctx, permissions.Medium, false, "go build", "") if ok || !s.turn.interrupted { t.Fatalf("interrupt: ok=%v interrupted=%v", ok, s.turn.interrupted) } + + // Redirect ("No, and tell me what to do differently" + feedback) → denied, + // the feedback propagates as the reason, and the turn is marked REDIRECTED + // (skip siblings) but NOT interrupted — so the loop continues and the model + // reads the feedback instead of the turn terminating. + s = newTodoSession(t) + s.approve = func(context.Context, ApprovalRequest) ApprovalDecision { + return ApprovalDecision{Redirect: true, Reason: "use the staging config instead"} + } + ok, _, reason = s.gateCommand(ctx, permissions.Medium, false, "go build", "") + if ok || reason != "use the staging config instead" { + t.Fatalf("redirect: ok=%v reason=%q", ok, reason) + } + if s.turn.interrupted { + t.Fatal("redirect must NOT interrupt the turn") + } + if !s.turn.redirected { + t.Fatal("redirect must mark the turn redirected (skip siblings, continue)") + } } func TestGateEditDenyReason(t *testing.T) { diff --git a/internal/agent/runtime/chat.go b/internal/agent/runtime/chat.go index c9c6c94..a63de09 100644 --- a/internal/agent/runtime/chat.go +++ b/internal/agent/runtime/chat.go @@ -175,6 +175,12 @@ func (s *Session) Submit(ctx context.Context, st *ChatState, line string) { return } dec := input.Parse(line, s.root) + // Caller-resolved media (gateway channel attachments) ride this turn's bundle + // through the normal attachment path, then clear — next turns carry nothing. + if len(s.taskAttachments) > 0 { + dec.Bundle.Attachments = append(dec.Bundle.Attachments, s.taskAttachments...) + s.taskAttachments = nil + } // Did the user explicitly authorize changing tests/specs/behavior this turn? If so, // editing tests is the WORK; if not, weakening a test is gated as a self-heal cheat. s.testEditIntent = userIntendsTestChange(dec.Bundle.Text) diff --git a/internal/agent/runtime/exec.go b/internal/agent/runtime/exec.go index 03ad899..1021506 100644 --- a/internal/agent/runtime/exec.go +++ b/internal/agent/runtime/exec.go @@ -118,9 +118,12 @@ func (s *Session) executeBatch(ctx context.Context, uses []wire.Block) []wire.Bl // run — otherwise denying edit #1 still silently applies edits #2, #3 (the // "I said no but it kept editing" bug). Every tool_use still needs a paired // tool_result, so the skipped ones get a benign one rather than being dropped. - if s.turn.interrupted || ctx.Err() != nil { - results[i] = wire.Block{Type: "tool_result", ToolUseID: u.ID, - Content: "skipped — you stopped this turn", IsError: true} + if s.turn.interrupted || s.turn.redirected || ctx.Err() != nil { + skip := "skipped — you stopped this turn" + if s.turn.redirected && !s.turn.interrupted { + skip = "skipped — the user redirected; see their instruction on the denied action above" + } + results[i] = wire.Block{Type: "tool_result", ToolUseID: u.ID, Content: skip, IsError: true} continue } if !isParallelSafe(u.Name) { diff --git a/internal/agent/runtime/loop.go b/internal/agent/runtime/loop.go index 5b05ea3..f995527 100644 --- a/internal/agent/runtime/loop.go +++ b/internal/agent/runtime/loop.go @@ -512,6 +512,13 @@ func (s *Session) runLoop(ctx context.Context, sys promptSpec, messages *[]wire. s.printf("\n■ stopped this turn at your request.\n") return iterations, false, nil } + // The user denied an action and typed a redirection: the denial (carrying + // their feedback) is already in this batch's tool_results, so CONTINUE — the + // next model call reads the feedback and responds, instead of the turn + // terminating with it unread. Clear the one-shot flag. + if s.turn.redirected { + s.turn.redirected = false + } } ceiling := "max iterations" if iterCap == maxIterationsYolo { diff --git a/internal/agent/runtime/runtime.go b/internal/agent/runtime/runtime.go index feee51c..351ba25 100644 --- a/internal/agent/runtime/runtime.go +++ b/internal/agent/runtime/runtime.go @@ -133,24 +133,25 @@ type Session struct { planCtl *plan.Controller // plan-mode state (active/revision/models/apply), owned by EnterPlan/ExitPlan (plan.go) - metrics metricsState // session accounting counters (tool calls, reads, edit/verify seqs) (metricsstate.go) - served servedState // backend/routing telemetry of the last main call (servedstate.go) - dispMu sync.Mutex // guards served + turnEffort: the TUI render goroutine reads them every frame while the engine writes them mid-turn (separate from mu so a render read never blocks on output I/O) - turnEffort wire.Effort // thinking effort for THIS turn (set per turn; default off — see turnintent.go) — read under dispMu - servingDefault string // the everyday serving model (gateway cheap lane) shown before any turn runs — read under dispMu - turnHighRisk bool // THIS turn touches a high-blast-radius surface (auth/billing/secrets/destructive) → escalate the backend (see highRiskTurn) - turn *turnState // per-turn loop state, reset each runLoop (turnstate.go) - skills []skills.Skill // discovered skill catalog (own + Claude Code plugins) - approvedSkills map[string]bool // skills the user said "don't ask again" for (loaded from + persisted to .memcode/skill-approvals) - approvedArtifacts bool // repo-scoped "don't ask again" for artifact publishing (.memcode/artifact-approvals) - nudgedSkills map[string]bool // skill triggers already nudged this session (nudge once, don't nag) — see skillNudge - scripts []scripts.Script // saved reusable command sequences (.memcode/scripts) — see script.go, scripts_prompt.go - nudgedScripts map[string]bool // script slugs already nudged this session (nudge once, don't nag) — see scriptNudge - userMd string // user's MEMCODE.md instructions, loaded once per session, injected every turn - memoryMd string // durable memory (global + project memory.md), loaded once per session, injected every turn - supplemental []ContextItem // caller-supplied supplemental context (empty for the CLI/Desktop; set only by the agent runtime), injected every turn - extraSkillRoots []string // caller-supplied extra skill roots (a gateway persona's skills dir); empty for the CLI/Desktop - editsAllowed bool // user said "don't ask again for edits" this session (scoped: edits only, not commands; never catastrophic) + metrics metricsState // session accounting counters (tool calls, reads, edit/verify seqs) (metricsstate.go) + served servedState // backend/routing telemetry of the last main call (servedstate.go) + dispMu sync.Mutex // guards served + turnEffort: the TUI render goroutine reads them every frame while the engine writes them mid-turn (separate from mu so a render read never blocks on output I/O) + turnEffort wire.Effort // thinking effort for THIS turn (set per turn; default off — see turnintent.go) — read under dispMu + servingDefault string // the everyday serving model (gateway cheap lane) shown before any turn runs — read under dispMu + turnHighRisk bool // THIS turn touches a high-blast-radius surface (auth/billing/secrets/destructive) → escalate the backend (see highRiskTurn) + turn *turnState // per-turn loop state, reset each runLoop (turnstate.go) + skills []skills.Skill // discovered skill catalog (own + Claude Code plugins) + approvedSkills map[string]bool // skills the user said "don't ask again" for (loaded from + persisted to .memcode/skill-approvals) + approvedArtifacts bool // repo-scoped "don't ask again" for artifact publishing (.memcode/artifact-approvals) + nudgedSkills map[string]bool // skill triggers already nudged this session (nudge once, don't nag) — see skillNudge + scripts []scripts.Script // saved reusable command sequences (.memcode/scripts) — see script.go, scripts_prompt.go + nudgedScripts map[string]bool // script slugs already nudged this session (nudge once, don't nag) — see scriptNudge + userMd string // user's MEMCODE.md instructions, loaded once per session, injected every turn + memoryMd string // durable memory (global + project memory.md), loaded once per session, injected every turn + supplemental []ContextItem // caller-supplied supplemental context (empty for the CLI/Desktop; set only by the agent runtime), injected every turn + extraSkillRoots []string // caller-supplied extra skill roots (a gateway persona's skills dir); empty for the CLI/Desktop + taskAttachments []input.Attachment // caller-resolved attachments for the next submitted turn (gateway channel media); consumed by Submit + editsAllowed bool // user said "don't ask again for edits" this session (scoped: edits only, not commands; never catastrophic) lastCompactSummary string // most recent in-session compaction summary (the warm layer) @@ -393,6 +394,12 @@ func (s *Session) SetContext(items []ContextItem) { s.supplemental = items } // project or the user's global skill set. Empty for the CLI and Desktop. func (s *Session) SetSkillRoots(roots []string) { s.extraSkillRoots = roots } +// SetTaskAttachments supplies caller-resolved attachments for the NEXT +// submitted turn (a gateway job carrying channel media — a photo texted to the +// bot, a PDF emailed to it). They merge into that turn's bundle and ride the +// normal attachment path (caps, downscaling, wire blocks), then clear. +func (s *Session) SetTaskAttachments(atts []input.Attachment) { s.taskAttachments = atts } + func (s *Session) setSessionID(id string) { s.sessionID = id s.ckpt = checkpoint.New(s.root, id) // rewind points live per session id @@ -581,6 +588,13 @@ func (s *Session) askApproval(ctx context.Context, req ApprovalRequest) Approval s.turn.interrupted = true s.emit(ctx, events.KindInputInterrupted, map[string]any{"during": req.Label}) } + if d.Redirect { + // Deny this action and skip its siblings this batch, but let the turn + // CONTINUE: the denial (carrying the user's typed feedback as Reason) goes + // back to the model, which reads it and responds — instead of the turn + // silently terminating with the feedback unread. + s.turn.redirected = true + } if !d.Allow { s.noteDenied(ctx, req.Title) } diff --git a/internal/agent/runtime/turnstate.go b/internal/agent/runtime/turnstate.go index ae6ebed..be0577a 100644 --- a/internal/agent/runtime/turnstate.go +++ b/internal/agent/runtime/turnstate.go @@ -16,7 +16,8 @@ type turnState struct { gather *gatherState // per-turn read-only-gathering budget + repetition tracker (gather.go) editedPaths map[string]bool // files edited this turn (for the completion gate) servedLine string // last printed "⇄ served by …" line — dedup once/turn - interrupted bool // the user chose to stop this turn + interrupted bool // the user chose to STOP this turn (Esc / "No, stop") — end after this batch + redirected bool // the user denied an action and typed a redirection — skip the sibling tool calls but CONTINUE so the model reads the feedback and responds firstBreak string // the FIRST broken-edit nudge this turn — the failure evidence for lesson distillation lessonDone bool // a lesson was already distilled this turn (fire once) billingCredits bool // user consented to serve THIS turn on memcode credits after a BYOK key failure diff --git a/internal/channels/channels.go b/internal/channels/channels.go index 58f1210..c6b16fd 100644 --- a/internal/channels/channels.go +++ b/internal/channels/channels.go @@ -33,11 +33,42 @@ type Inbound struct { // replied-to — so a group message meant for it triggers even without // respond_to_all. Detected structurally by each adapter, never by substring. Mentioned bool + // Attachments are media the sender included, already downloaded into the + // gateway's media spool by the adapter (see SaveToSpool). The spool is the + // trust boundary: downstream code addresses an attachment by its spool ID, + // never by an arbitrary path. + Attachments []Attachment } +// Attachment kinds — a coarse content class, mapped by MIME type. +const ( + KindImage = "image" + KindAudio = "audio" + KindPDF = "pdf" + KindFile = "file" +) + +// Attachment is one piece of inbound media, stored in the gateway media spool. +type Attachment struct { + Path string // absolute path inside the media spool + Kind string // image | audio | pdf | file + Mime string // as reported by the platform (best-effort) + Name string // original filename, display only +} + +// ID returns the attachment's spool ID — the bare spool filename. IDs, not +// paths, are what cross process boundaries (durable inbox, job context); the +// consumer re-resolves an ID strictly inside the spool directory. +func (a Attachment) ID() string { return filepathBase(a.Path) } + // Outbound is a reply to post back to a conversation. type Outbound struct { Text string + // VoicePath optionally points at a synthesized speech rendition of Text + // (OGG/Opus in the media spool). Adapters that can send voice notes send it + // alongside/instead of the text; adapters that can't simply ignore it — Text + // is always present as the fallback. + VoicePath string } // Sink receives inbound messages from an adapter. Deliver applies the gateway's diff --git a/internal/channels/discord/discord.go b/internal/channels/discord/discord.go index e47b6d1..9c9991b 100644 --- a/internal/channels/discord/discord.go +++ b/internal/channels/discord/discord.go @@ -9,7 +9,10 @@ package discord import ( "context" + "net/http" + "os" "strings" + "time" "github.com/bwmarrin/discordgo" @@ -22,18 +25,22 @@ const discordMaxMessage = 2000 // Channel is a Discord bot connection. type Channel struct { - session *discordgo.Session + session *discordgo.Session + mediaDir string // media spool; "" disables attachment downloads + dl *http.Client } // New builds a Discord channel for the given bot token. It requests the message // intents (Message Content is privileged — the user must enable it on the bot). -func New(token string) (*Channel, error) { +// mediaDir is the gateway media spool attachments are downloaded into; "" +// disables attachment handling. +func New(token, mediaDir string) (*Channel, error) { s, err := discordgo.New("Bot " + token) if err != nil { return nil, err } s.Identify.Intents = discordgo.IntentsGuildMessages | discordgo.IntentsDirectMessages | discordgo.IntentMessageContent - return &Channel{session: s}, nil + return &Channel{session: s, mediaDir: mediaDir, dl: &http.Client{Timeout: 30 * time.Second}}, nil } // Name returns the adapter identifier. @@ -52,6 +59,7 @@ func (c *Channel) Start(ctx context.Context, sink channels.Sink) error { if !ok { return } + inb.Attachments = c.download(ctx, m.Attachments) // The Discord gateway has no per-message replay, so a Deliver failure can't // be retried — the durable record is best-effort here. _ = sink.Deliver(ctx, inb) @@ -77,7 +85,7 @@ func toInbound(m *discordgo.MessageCreate, selfID string) (channels.Inbound, boo if m.Author.ID == selfID || m.Author.Bot { return channels.Inbound{}, false } - if strings.TrimSpace(m.Content) == "" { + if strings.TrimSpace(m.Content) == "" && len(m.Attachments) == 0 { return channels.Inbound{}, false } // A message with no guild is a DM. In a guild the bot only acts when addressed: @@ -107,9 +115,49 @@ func toInbound(m *discordgo.MessageCreate, selfID string) (channels.Inbound, boo }, true } +// download fetches message attachments (CDN URLs) into the media spool, +// best-effort: a failed download drops that attachment, the message still flows. +func (c *Channel) download(ctx context.Context, atts []*discordgo.MessageAttachment) []channels.Attachment { + if c.mediaDir == "" || len(atts) == 0 { + return nil + } + var out []channels.Attachment + for _, a := range atts { + if a == nil || a.URL == "" { + continue + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, a.URL, nil) + if err != nil { + continue + } + resp, err := c.dl.Do(req) + if err != nil { + continue + } + if resp.StatusCode/100 != 2 { + resp.Body.Close() + continue + } + att, err := channels.SaveToSpool(c.mediaDir, resp.Body, a.ContentType, a.Filename) + resp.Body.Close() + if err != nil { + continue + } + out = append(out, att) + } + return out +} + // Send posts a reply to a channel, splitting it with the shared chunker to -// respect Discord's per-message length limit. +// respect Discord's per-message length limit. A synthesized voice rendition +// (VoicePath) is uploaded first as a file, best-effort — the text always follows. func (c *Channel) Send(ctx context.Context, conversation string, msg channels.Outbound) error { + if msg.VoicePath != "" { + if f, err := os.Open(msg.VoicePath); err == nil { + _, _ = c.session.ChannelFileSend(conversation, "voice-reply.ogg", f) + f.Close() + } + } for _, part := range channels.Chunk(msg.Text, discordMaxMessage) { if err := ctx.Err(); err != nil { return err diff --git a/internal/channels/discord/discord_test.go b/internal/channels/discord/discord_test.go index 19d0a24..e7456a4 100644 --- a/internal/channels/discord/discord_test.go +++ b/internal/channels/discord/discord_test.go @@ -1,6 +1,7 @@ package discord import ( + "reflect" "testing" "github.com/bwmarrin/discordgo" @@ -47,7 +48,7 @@ func TestToInbound(t *testing.T) { return } want := channels.Inbound{Channel: "discord", Conversation: tt.wantConvo, Principal: tt.wantPrincipal, Text: tt.wantText, MessageID: "m1"} - if got != want { + if !reflect.DeepEqual(got, want) { t.Errorf("got %+v, want %+v", got, want) } }) diff --git a/internal/channels/email/compose.go b/internal/channels/email/compose.go new file mode 100644 index 0000000..2ae03ec --- /dev/null +++ b/internal/channels/email/compose.go @@ -0,0 +1,85 @@ +package email + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "mime" + "strings" + "time" +) + +// composeReply builds a plain-text RFC 5322 reply. Threading: In-Reply-To +// names the peer's last message, References carries the thread root + last, so +// every client threads it correctly. The subject gets exactly one "Re:". +func composeReply(from, to string, th threadInfo, body string) []byte { + subject := strings.TrimSpace(th.subject) + if subject == "" { + subject = "Your memcode task" + } + if !strings.HasPrefix(strings.ToLower(subject), "re:") { + subject = "Re: " + subject + } + var b strings.Builder + fmt.Fprintf(&b, "From: %s\r\n", from) + fmt.Fprintf(&b, "To: %s\r\n", stripCRLF(to)) + fmt.Fprintf(&b, "Subject: %s\r\n", mime.QEncoding.Encode("utf-8", subject)) + fmt.Fprintf(&b, "Date: %s\r\n", time.Now().Format(time.RFC1123Z)) + fmt.Fprintf(&b, "Message-Id: %s\r\n", newMessageID(from)) + // Threading ids came from inbound mail. mail.ReadMessage already rejects + // CRLF-bearing headers, but strip line breaks anyway (defense in depth): a + // Message-ID must never be able to smuggle extra headers into our reply. + if last := stripCRLF(th.last); last != "" { + fmt.Fprintf(&b, "In-Reply-To: %s\r\n", last) + } + if refs := stripCRLF(threadReferences(th)); refs != "" { + fmt.Fprintf(&b, "References: %s\r\n", refs) + } + // Mark our replies as auto-generated so another auto-responder (or a second + // memcode bot) won't ping-pong with us — inbound honors this too (shouldIgnore). + b.WriteString("Auto-Submitted: auto-replied\r\n") + b.WriteString("MIME-Version: 1.0\r\n") + b.WriteString("Content-Type: text/plain; charset=utf-8\r\n") + b.WriteString("Content-Transfer-Encoding: 8bit\r\n") + b.WriteString("\r\n") + // Normalize newlines to CRLF; net/smtp handles dot-stuffing itself. + b.WriteString(strings.ReplaceAll(strings.ReplaceAll(body, "\r\n", "\n"), "\n", "\r\n")) + b.WriteString("\r\n") + return []byte(b.String()) +} + +// stripCRLF removes line breaks from a header value (header-injection guard). +// mail.ReadMessage already rejects CRLF-bearing inbound headers; this is the +// cheap second layer so no future caller can regress it. +func stripCRLF(s string) string { + return strings.Map(func(r rune) rune { + if r == '\r' || r == '\n' { + return -1 + } + return r + }, s) +} + +func threadReferences(th threadInfo) string { + switch { + case th.root == "" && th.last == "": + return "" + case th.root == th.last || th.root == "": + return th.last + case th.last == "": + return th.root + default: + return th.root + " " + th.last + } +} + +// newMessageID mints a unique Message-ID under the sender's domain. +func newMessageID(from string) string { + domain := "memcode.local" + if i := strings.IndexByte(from, '@'); i >= 0 && i+1 < len(from) { + domain = from[i+1:] + } + b := make([]byte, 12) + _, _ = rand.Read(b) + return fmt.Sprintf("<%d.%s@%s>", time.Now().UnixNano(), hex.EncodeToString(b), domain) +} diff --git a/internal/channels/email/email.go b/internal/channels/email/email.go new file mode 100644 index 0000000..28b3e5f --- /dev/null +++ b/internal/channels/email/email.go @@ -0,0 +1,237 @@ +// Package email is the gateway's email channel: a dedicated mailbox the agent +// answers. IMAP (SSL :993) is polled for unseen mail; replies go out over SMTP +// (STARTTLS :587) with proper threading headers. The model is a DEDICATED +// account (an app password for Gmail/Outlook), never your personal inbox — the +// bot answers everything its allow-list admits. +// +// Ack semantics match the gateway contract: a message is marked \Seen ONLY +// after Deliver returns nil, so a crash between fetch and durable record just +// re-fetches it. The durable dedup key is // — the +// provider-side identity, robust against malformed or duplicated Message-IDs +// (which serve threading, not dedup). +// +// IDENTITY CAVEAT: the principal is the RFC From address — weaker than the +// other channels' platform-authenticated ids, since From can be spoofed by +// mail that evades the provider's SPF/DKIM/DMARC filtering. The mailbox +// provider's authentication is the real gate (a mainstream provider rejects or +// junks spoofed mail before we poll it), which is one more reason for the +// dedicated-account model. Enforcing Authentication-Results=pass explicitly is +// a tracked follow-up. +package email + +import ( + "bytes" + "context" + "fmt" + "strings" + "sync" + "time" + + "github.com/emersion/go-imap/v2" + + "github.com/memcode-ai/memcode/internal/channels" +) + +const ( + defaultPoll = 15 * time.Second + maxPollBackoff = 5 * time.Minute + mailbox = "INBOX" +) + +// Channel is an email (IMAP+SMTP) connection. +type Channel struct { + address string // the dedicated account, also the SMTP From + password string // app password + imapHost string // host[:port]; port defaults to 993 + smtpHost string // host[:port]; port defaults to 587 + poll time.Duration + mediaDir string // media spool; "" disables attachment downloads + + // threads remembers, per peer, how to thread the next reply (root id, + // last inbound id, subject). In-memory: after a restart a reply still + // reaches the peer, it may just start a fresh thread. + mu sync.Mutex + threads map[string]threadInfo + + // dial/send are test seams. + dial func() (imapSession, error) + send func(to string, raw []byte) error +} + +type threadInfo struct { + root string + last string + subject string +} + +// imapSession is the slice of the IMAP client the poll loop uses (a seam so +// tests can script it). +type imapSession interface { + SelectInbox() (uidValidity uint32, err error) + UnseenUIDs() ([]imap.UID, error) + FetchRaw(uid imap.UID) ([]byte, error) + MarkSeen(uid imap.UID) error + Close() error +} + +// New builds an email channel. poll <= 0 uses the default (15s). mediaDir is +// the gateway media spool inbound attachments are saved into; "" disables +// attachment handling. +func New(address, password, imapHost, smtpHost string, poll time.Duration, mediaDir string) *Channel { + if poll <= 0 { + poll = defaultPoll + } + c := &Channel{ + address: strings.ToLower(strings.TrimSpace(address)), + password: password, + imapHost: withDefaultPort(imapHost, "993"), + smtpHost: withDefaultPort(smtpHost, "587"), + poll: poll, + mediaDir: mediaDir, + threads: map[string]threadInfo{}, + } + c.dial = c.dialIMAP + c.send = c.smtpSend + return c +} + +// Name returns the adapter identifier. +func (c *Channel) Name() string { return "email" } + +func withDefaultPort(host, port string) string { + host = strings.TrimSpace(host) + if host == "" || strings.Contains(host, ":") { + return host + } + return host + ":" + port +} + +// Start polls the mailbox until ctx is cancelled. Connection errors back off +// exponentially (capped) instead of returning, so a flaky mail server never +// takes the gateway down. +func (c *Channel) Start(ctx context.Context, sink channels.Sink) error { + backoff := time.Second + for { + if err := ctx.Err(); err != nil { + return err + } + if err := c.pollOnce(ctx, sink); err != nil && ctx.Err() == nil { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(backoff): + } + backoff = min(backoff*2, maxPollBackoff) + continue + } + backoff = time.Second + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(c.poll): + } + } +} + +// pollOnce runs one fetch cycle: select, search unseen, deliver each, mark +// seen only on durable record. +func (c *Channel) pollOnce(ctx context.Context, sink channels.Sink) error { + sess, err := c.dial() + if err != nil { + return err + } + defer sess.Close() + uidValidity, err := sess.SelectInbox() + if err != nil { + return err + } + uids, err := sess.UnseenUIDs() + if err != nil { + return err + } + for _, uid := range uids { + if err := ctx.Err(); err != nil { + return err + } + raw, err := sess.FetchRaw(uid) + if err != nil { + return err + } + msg, ok := parseMessage(raw) + if !ok || shouldIgnore(msg, c.address) { + // Not actionable mail (bounce, auto-reply, self, unparseable) — mark + // seen so it isn't re-fetched forever, and move on. + _ = sess.MarkSeen(uid) + continue + } + inb := c.toInbound(msg, uidValidity, uid) + if err := sink.Deliver(ctx, inb); err != nil { + // NOT durably recorded — leave unseen; the next poll retries it. + return err + } + c.rememberThread(msg) + _ = sess.MarkSeen(uid) + } + return nil +} + +// toInbound normalizes a parsed message. The conversation is the peer address +// (the reply route — like a phone number on SMS/WhatsApp); threading metadata +// is remembered per peer for Send. +func (c *Channel) toInbound(msg parsedMessage, uidValidity uint32, uid imap.UID) channels.Inbound { + var atts []channels.Attachment + if c.mediaDir != "" { + atts = c.spoolAttachments(msg) + } + text := msg.text + if strings.TrimSpace(text) == "" && msg.subject != "" { + text = msg.subject // subject-only mail: the subject is the task + } + return channels.Inbound{ + Channel: "email", + Conversation: msg.from, + Principal: msg.from, + Text: text, + MessageID: fmt.Sprintf("%s/%d/%d", mailbox, uidValidity, uid), + IsDirect: true, + Attachments: atts, + } +} + +func (c *Channel) spoolAttachments(msg parsedMessage) []channels.Attachment { + var out []channels.Attachment + for _, a := range msg.attachments { + att, err := channels.SaveToSpool(c.mediaDir, bytes.NewReader(a.data), a.mime, a.name) + if err != nil { + continue + } + out = append(out, att) + } + return out +} + +// rememberThread records how to thread the next reply to this peer. +func (c *Channel) rememberThread(msg parsedMessage) { + root := msg.msgID + if len(msg.references) > 0 { + root = msg.references[0] + } else if msg.inReplyTo != "" { + root = msg.inReplyTo + } + c.mu.Lock() + c.threads[msg.from] = threadInfo{root: root, last: msg.msgID, subject: msg.subject} + c.mu.Unlock() +} + +// Send replies to a peer over SMTP, threading into the remembered conversation +// when one is known. Email has no length cap, so no chunking. +func (c *Channel) Send(ctx context.Context, conversation string, msg channels.Outbound) error { + if err := ctx.Err(); err != nil { + return err + } + c.mu.Lock() + th := c.threads[conversation] + c.mu.Unlock() + raw := composeReply(c.address, conversation, th, msg.Text) + return c.send(conversation, raw) +} diff --git a/internal/channels/email/email_test.go b/internal/channels/email/email_test.go new file mode 100644 index 0000000..ba942c3 --- /dev/null +++ b/internal/channels/email/email_test.go @@ -0,0 +1,212 @@ +package email + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/emersion/go-imap/v2" + + "github.com/memcode-ai/memcode/internal/channels" +) + +const simpleMail = "From: Tim \r\n" + + "To: bot@example.com\r\n" + + "Subject: Fix the build\r\n" + + "Message-Id: \r\n" + + "Content-Type: text/plain; charset=utf-8\r\n" + + "\r\n" + + "The CI is red, investigate.\r\n" + +func TestParseSimple(t *testing.T) { + p, ok := parseMessage([]byte(simpleMail)) + if !ok { + t.Fatal("parse failed") + } + if p.from != "tim@example.com" || p.subject != "Fix the build" || p.msgID != "" { + t.Errorf("parsed %+v", p) + } + if !strings.Contains(p.text, "CI is red") { + t.Errorf("text = %q", p.text) + } +} + +func TestParseMultipartWithAttachment(t *testing.T) { + raw := "From: a@b.com\r\n" + + "Subject: =?utf-8?q?see_attached?=\r\n" + + "Message-Id: \r\n" + + "In-Reply-To: \r\n" + + "References: \r\n" + + "Content-Type: multipart/mixed; boundary=XX\r\n" + + "\r\n" + + "--XX\r\n" + + "Content-Type: text/plain\r\n" + + "Content-Transfer-Encoding: quoted-printable\r\n" + + "\r\n" + + "caf=C3=A9 plans attached\r\n" + + "--XX\r\n" + + "Content-Type: application/pdf; name=plan.pdf\r\n" + + "Content-Disposition: attachment; filename=plan.pdf\r\n" + + "Content-Transfer-Encoding: base64\r\n" + + "\r\n" + + "JVBERi0xLjQ=\r\n" + + "--XX--\r\n" + p, ok := parseMessage([]byte(raw)) + if !ok { + t.Fatal("parse failed") + } + if p.subject != "see attached" { + t.Errorf("subject = %q", p.subject) + } + if !strings.Contains(p.text, "café plans") { + t.Errorf("text = %q", p.text) + } + if len(p.attachments) != 1 || p.attachments[0].name != "plan.pdf" || p.attachments[0].mime != "application/pdf" { + t.Fatalf("attachments = %+v", p.attachments) + } + if string(p.attachments[0].data) != "%PDF-1.4" { + t.Errorf("decoded data = %q", p.attachments[0].data) + } + if len(p.references) != 2 || p.references[0] != "" { + t.Errorf("references = %v", p.references) + } +} + +func TestShouldIgnore(t *testing.T) { + ok := func(raw string) parsedMessage { + p, _ := parseMessage([]byte(raw)) + return p + } + if !shouldIgnore(ok("From: noreply@svc.com\r\n\r\nx"), "bot@x.com") { + t.Error("noreply not ignored") + } + if !shouldIgnore(ok("From: mailer-daemon@x.com\r\n\r\nx"), "bot@x.com") { + t.Error("bounce not ignored") + } + if !shouldIgnore(ok("From: a@b.com\r\nAuto-Submitted: auto-replied\r\n\r\nx"), "bot@x.com") { + t.Error("auto-submitted not ignored") + } + if !shouldIgnore(ok("From: bot@x.com\r\n\r\nx"), "bot@x.com") { + t.Error("self not ignored (loop!)") + } + if shouldIgnore(ok("From: tim@b.com\r\nAuto-Submitted: no\r\n\r\nx"), "bot@x.com") { + t.Error("normal sender ignored") + } +} + +func TestComposeReplyThreading(t *testing.T) { + th := threadInfo{root: "", last: "", subject: "Re: build"} + raw := string(composeReply("bot@x.com", "tim@b.com", th, "done\nall green")) + for _, want := range []string{ + "To: tim@b.com\r\n", + "In-Reply-To: \r\n", + "References: \r\n", + "done\r\nall green", + } { + if !strings.Contains(raw, want) { + t.Errorf("missing %q in:\n%s", want, raw) + } + } + if strings.Contains(raw, "Re: Re:") { + t.Error("stacked Re:") + } + // No remembered thread → still a valid standalone message. + raw = string(composeReply("bot@x.com", "tim@b.com", threadInfo{}, "hi")) + if strings.Contains(raw, "In-Reply-To") || !strings.Contains(raw, "Subject: ") { + t.Errorf("standalone compose wrong:\n%s", raw) + } +} + +// fakeSession scripts one poll cycle. +type fakeSession struct { + uidValidity uint32 + msgs map[imap.UID][]byte + seen []imap.UID +} + +func (f *fakeSession) SelectInbox() (uint32, error) { return f.uidValidity, nil } +func (f *fakeSession) UnseenUIDs() ([]imap.UID, error) { + var out []imap.UID + for uid := range f.msgs { + out = append(out, uid) + } + return out, nil +} +func (f *fakeSession) FetchRaw(uid imap.UID) ([]byte, error) { return f.msgs[uid], nil } +func (f *fakeSession) MarkSeen(uid imap.UID) error { f.seen = append(f.seen, uid); return nil } +func (f *fakeSession) Close() error { return nil } + +type sinkFn func(channels.Inbound) error + +func (s sinkFn) Deliver(_ context.Context, inb channels.Inbound) error { return s(inb) } + +func TestPollOnceAckSemantics(t *testing.T) { + fake := &fakeSession{uidValidity: 7, msgs: map[imap.UID][]byte{42: []byte(simpleMail)}} + c := New("bot@example.com", "pw", "imap.example.com", "smtp.example.com", 0, "") + c.dial = func() (imapSession, error) { return fake, nil } + + var got channels.Inbound + if err := c.pollOnce(context.Background(), sinkFn(func(inb channels.Inbound) error { + got = inb + return nil + })); err != nil { + t.Fatal(err) + } + // Durable dedup key is mailbox/UIDVALIDITY/UID, not the Message-ID. + if got.MessageID != "INBOX/7/42" { + t.Errorf("MessageID = %q", got.MessageID) + } + if got.Principal != "tim@example.com" || !got.IsDirect || got.Channel != "email" { + t.Errorf("inbound = %+v", got) + } + if len(fake.seen) != 1 || fake.seen[0] != 42 { + t.Errorf("seen = %v (must mark seen after durable record)", fake.seen) + } + + // A Deliver failure leaves the message UNSEEN so the next poll retries it. + fake2 := &fakeSession{uidValidity: 7, msgs: map[imap.UID][]byte{43: []byte(simpleMail)}} + c.dial = func() (imapSession, error) { return fake2, nil } + if err := c.pollOnce(context.Background(), sinkFn(func(channels.Inbound) error { + return errors.New("db down") + })); err == nil { + t.Fatal("pollOnce should surface the failure") + } + if len(fake2.seen) != 0 { + t.Errorf("failed delivery must not mark seen, got %v", fake2.seen) + } +} + +func TestSendUsesThread(t *testing.T) { + c := New("bot@example.com", "pw", "imap.example.com", "smtp.example.com", 0, "") + var sentTo string + var sentRaw []byte + c.send = func(to string, raw []byte) error { sentTo, sentRaw = to, raw; return nil } + // Simulate having seen a message from tim first. + p, _ := parseMessage([]byte(simpleMail)) + c.rememberThread(p) + if err := c.Send(context.Background(), "tim@example.com", channels.Outbound{Text: "on it"}); err != nil { + t.Fatal(err) + } + if sentTo != "tim@example.com" || !strings.Contains(string(sentRaw), "In-Reply-To: ") { + t.Errorf("sent to %q raw:\n%s", sentTo, sentRaw) + } + if !strings.Contains(string(sentRaw), "Subject: Re: Fix the build") { + t.Errorf("subject wrong:\n%s", sentRaw) + } +} + +// Threading headers are injection-proof even if a hostile Message-ID slipped +// past inbound parsing. +func TestComposeReplyStripsCRLF(t *testing.T) { + th := threadInfo{last: "\r\nBcc: victim@x.com", subject: "hi"} + raw := string(composeReply("bot@x.com", "tim@b.com", th, "ok")) + // The CRLF is stripped, so "Bcc:" can only survive INSIDE the In-Reply-To + // value (inert) — never as its own header line. + if strings.Contains(raw, "\r\nBcc:") { + t.Fatalf("injected header line survived:\n%s", raw) + } + if !strings.Contains(raw, "In-Reply-To: Bcc: victim@x.com\r\n") { + t.Fatalf("strip changed more than line breaks:\n%s", raw) + } +} diff --git a/internal/channels/email/parse.go b/internal/channels/email/parse.go new file mode 100644 index 0000000..abe75ed --- /dev/null +++ b/internal/channels/email/parse.go @@ -0,0 +1,188 @@ +package email + +import ( + "bytes" + "encoding/base64" + "io" + "mime" + "mime/multipart" + "mime/quotedprintable" + "net/mail" + "strings" +) + +// parsedMessage is the slice of an inbound email the gateway acts on. +type parsedMessage struct { + from string // lowercased address (the principal + conversation) + subject string + msgID string // RFC Message-ID, threading metadata + inReplyTo string // parent Message-ID + references []string // thread chain, oldest first + autoSubmit string // Auto-Submitted header (loop/bounce detection) + text string // best-effort plain text body + attachments []rawAttachment +} + +type rawAttachment struct { + name string + mime string + data []byte +} + +// Caps against a malicious message: a single decoded part, the number of parts, +// and the aggregate decoded bytes across all parts. +const ( + maxParsedAttachment = 25 << 20 // one decoded part + maxAttachmentCount = 50 // parts per message + maxAggregateBytes = 50 << 20 // total decoded attachment bytes per message +) + +// parseMessage extracts what the gateway needs from a raw RFC 5322 message. +// Best-effort: an unparseable message returns ok=false and is skipped (and +// marked seen) rather than wedging the poll loop. +func parseMessage(raw []byte) (parsedMessage, bool) { + m, err := mail.ReadMessage(bytes.NewReader(raw)) + if err != nil { + return parsedMessage{}, false + } + var p parsedMessage + if addr, err := mail.ParseAddress(m.Header.Get("From")); err == nil { + p.from = strings.ToLower(addr.Address) + } + if p.from == "" { + return parsedMessage{}, false + } + dec := &mime.WordDecoder{} + if s, err := dec.DecodeHeader(m.Header.Get("Subject")); err == nil { + p.subject = s + } else { + p.subject = m.Header.Get("Subject") + } + p.msgID = strings.TrimSpace(m.Header.Get("Message-Id")) + p.inReplyTo = firstMsgID(m.Header.Get("In-Reply-To")) + for _, id := range strings.Fields(m.Header.Get("References")) { + if id = strings.TrimSpace(id); id != "" { + p.references = append(p.references, id) + } + } + p.autoSubmit = strings.ToLower(strings.TrimSpace(m.Header.Get("Auto-Submitted"))) + walkPart(mailHeader(m.Header), m.Body, &p, 0, new(int64)) + p.text = strings.TrimSpace(p.text) + return p, true +} + +func firstMsgID(s string) string { + f := strings.Fields(s) + if len(f) == 0 { + return "" + } + return f[0] +} + +// header adapts the two header types (mail.Header, textproto.MIMEHeader) the +// walk sees to one getter. +type header func(key string) string + +func (h header) get(key string) string { return h(key) } + +func mailHeader(h mail.Header) header { return func(k string) string { return h.Get(k) } } + +func partHeader(p *multipart.Part) header { return func(k string) string { return p.Header.Get(k) } } + +// walkPart recurses a MIME tree, collecting the first text/plain body (falling +// back to a stripped-tags-free text/html is deliberately NOT attempted — plain +// text or nothing, like the wire) and every attachment. Depth-capped against +// pathological nesting. +func walkPart(h header, body io.Reader, p *parsedMessage, depth int, agg *int64) { + if depth > 8 || len(p.attachments) >= maxAttachmentCount || *agg >= maxAggregateBytes { + return + } + ctype := h.get("Content-Type") + if ctype == "" { + ctype = "text/plain" + } + mediaType, params, err := mime.ParseMediaType(ctype) + if err != nil { + mediaType = "text/plain" + } + if strings.HasPrefix(mediaType, "multipart/") { + boundary := params["boundary"] + if boundary == "" { + return + } + mr := multipart.NewReader(body, boundary) + for { + if len(p.attachments) >= maxAttachmentCount || *agg >= maxAggregateBytes { + return + } + part, err := mr.NextPart() + if err != nil { + return + } + walkPart(partHeader(part), part, p, depth+1, agg) + } + } + + disposition, dparams, _ := mime.ParseMediaType(h.get("Content-Disposition")) + isAttachment := disposition == "attachment" || dparams["filename"] != "" || params["name"] != "" + + data, err := io.ReadAll(io.LimitReader(decodeTransfer(h.get("Content-Transfer-Encoding"), body), maxParsedAttachment+1)) + if err != nil || int64(len(data)) > maxParsedAttachment { + return + } + + switch { + case !isAttachment && mediaType == "text/plain": + if p.text == "" { + p.text = string(data) + } + case isAttachment || !strings.HasPrefix(mediaType, "text/"): + name := dparams["filename"] + if name == "" { + name = params["name"] + } + if name == "" { + name = "attachment" + } + if dec, err := (&mime.WordDecoder{}).DecodeHeader(name); err == nil { + name = dec + } + if len(data) > 0 && len(p.attachments) < maxAttachmentCount && *agg+int64(len(data)) <= maxAggregateBytes { + *agg += int64(len(data)) + p.attachments = append(p.attachments, rawAttachment{name: name, mime: mediaType, data: data}) + } + } +} + +// decodeTransfer wraps body with the declared content-transfer decoding. +func decodeTransfer(encoding string, body io.Reader) io.Reader { + switch strings.ToLower(strings.TrimSpace(encoding)) { + case "base64": + return base64.NewDecoder(base64.StdEncoding, body) + case "quoted-printable": + return quotedprintable.NewReader(body) + default: + return body + } +} + +// shouldIgnore filters non-actionable mail: automated senders, bounces, and +// our own messages (loop prevention). self is the bot's own address. +func shouldIgnore(p parsedMessage, self string) bool { + if p.from == "" || p.from == self { + return true + } + if p.autoSubmit != "" && p.autoSubmit != "no" { + return true // auto-generated / auto-replied (RFC 3834) + } + local := p.from + if i := strings.IndexByte(local, '@'); i > 0 { + local = local[:i] + } + for _, bad := range []string{"noreply", "no-reply", "no_reply", "donotreply", "mailer-daemon", "postmaster", "bounce"} { + if strings.Contains(local, bad) { + return true + } + } + return false +} diff --git a/internal/channels/email/transport.go b/internal/channels/email/transport.go new file mode 100644 index 0000000..1967ea9 --- /dev/null +++ b/internal/channels/email/transport.go @@ -0,0 +1,110 @@ +package email + +import ( + "fmt" + "net/smtp" + "strings" + + "github.com/emersion/go-imap/v2" + "github.com/emersion/go-imap/v2/imapclient" +) + +// maxRawMessage caps a single fetched message; a larger one is skipped (marked +// seen by the caller) rather than buffered whole. +const maxRawMessage = 60 << 20 + +// dialIMAP opens a logged-in TLS session on the IMAP host. +func (c *Channel) dialIMAP() (imapSession, error) { + cl, err := imapclient.DialTLS(c.imapHost, nil) + if err != nil { + return nil, fmt.Errorf("imap dial %s: %w", c.imapHost, err) + } + if err := cl.Login(c.address, c.password).Wait(); err != nil { + _ = cl.Close() + return nil, fmt.Errorf("imap login: %w", err) + } + return &liveSession{cl: cl}, nil +} + +// liveSession adapts imapclient to the poll loop's seam. +type liveSession struct { + cl *imapclient.Client +} + +func (s *liveSession) SelectInbox() (uint32, error) { + data, err := s.cl.Select(mailbox, nil).Wait() + if err != nil { + return 0, fmt.Errorf("imap select: %w", err) + } + return data.UIDValidity, nil +} + +func (s *liveSession) UnseenUIDs() ([]imap.UID, error) { + data, err := s.cl.UIDSearch(&imap.SearchCriteria{ + NotFlag: []imap.Flag{imap.FlagSeen}, + }, nil).Wait() + if err != nil { + return nil, fmt.Errorf("imap search: %w", err) + } + uidSet, ok := data.All.(imap.UIDSet) + if !ok { + return nil, nil + } + uids, _ := uidSet.Nums() + return uids, nil +} + +func (s *liveSession) FetchRaw(uid imap.UID) ([]byte, error) { + // BODY.PEEK[] — the empty section is the whole message; Peek so fetching + // alone never sets \Seen (only a durable Deliver does, via MarkSeen). + section := &imap.FetchItemBodySection{Peek: true} + msgs, err := s.cl.Fetch(imap.UIDSetNum(uid), &imap.FetchOptions{ + UID: true, + BodySection: []*imap.FetchItemBodySection{section}, + }).Collect() + if err != nil { + return nil, fmt.Errorf("imap fetch uid %d: %w", uid, err) + } + for _, m := range msgs { + for _, bs := range m.BodySection { + if len(bs.Bytes) == 0 { + continue + } + if len(bs.Bytes) > maxRawMessage { + return nil, fmt.Errorf("imap fetch uid %d: message too large (%d bytes)", uid, len(bs.Bytes)) + } + return bs.Bytes, nil + } + } + return nil, fmt.Errorf("imap fetch uid %d: empty body", uid) +} + +func (s *liveSession) MarkSeen(uid imap.UID) error { + cmd := s.cl.Store(imap.UIDSetNum(uid), &imap.StoreFlags{ + Op: imap.StoreFlagsAdd, + Silent: true, + Flags: []imap.Flag{imap.FlagSeen}, + }, nil) + return cmd.Close() +} + +func (s *liveSession) Close() error { + _ = s.cl.Logout().Wait() + return s.cl.Close() +} + +// smtpSend delivers one composed message via SMTP with STARTTLS (net/smtp +// negotiates it automatically when the server advertises it) and the same +// app-password auth as IMAP. +func (c *Channel) smtpSend(to string, raw []byte) error { + host := c.smtpHost + bare := host + if i := strings.IndexByte(bare, ':'); i >= 0 { + bare = bare[:i] + } + auth := smtp.PlainAuth("", c.address, c.password, bare) + if err := smtp.SendMail(host, auth, c.address, []string{to}, raw); err != nil { + return fmt.Errorf("smtp send to %s: %w", to, err) + } + return nil +} diff --git a/internal/channels/matrix/matrix.go b/internal/channels/matrix/matrix.go new file mode 100644 index 0000000..aa54f80 --- /dev/null +++ b/internal/channels/matrix/matrix.go @@ -0,0 +1,525 @@ +// Package matrix is the gateway's Matrix channel adapter. It talks to the +// Matrix client-server API directly over net/http (long-poll /sync + room +// send) — no SDK, matching the repo's thin-dependency ethos. The user points +// it at their homeserver with MATRIX_HOMESERVER + MATRIX_ACCESS_TOKEN in the +// global .env (a dedicated bot account's access token; this package never +// reads the environment itself — the gateway wires the values in). +// +// NO END-TO-END ENCRYPTION in v1. This adapter speaks PLAIN rooms only: +// events in encrypted rooms arrive as m.room.encrypted and are silently +// ignored, because E2EE requires Olm/Megolm session state that a raw HTTP +// client does not carry. Invite the bot to unencrypted rooms. +package matrix + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "math/rand/v2" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/memcode-ai/memcode/internal/channels" +) + +const ( + // Matrix has no hard body-size limit like Telegram's, but the total event + // must stay under the federation's 65 KiB cap; 4000 chars leaves ample room. + matrixMaxMessage = 4000 + maxSyncBackoff = 60 * time.Second +) + +// CursorStore persists the /sync since-token so a restart resumes where it +// left off instead of replaying (and re-running) the backlog. Satisfied by the +// gateway's state store; nil in tests / when no persistence is wired. +type CursorStore interface { + Cursor(ctx context.Context, channel string) (string, error) + SetCursor(ctx context.Context, channel, cursor string) error +} + +// Channel is a Matrix client connection. +type Channel struct { + homeserver string // base URL, e.g. https://matrix.example.org; overridable in tests + token string + client *http.Client + store CursorStore + mediaDir string // media spool; "" disables attachment downloads + + userID string // own mxid, learned via /whoami at Start + direct map[string]bool // room ids marked as DMs in m.direct account data +} + +// New builds a Matrix channel for the given homeserver and access token. +// store may be nil, in which case the since-token lives only in memory (and a +// restart re-reads recent history, which the router's dedup then discards). +// mediaDir is the gateway media spool images/audio/files are downloaded into; +// "" disables attachment handling (messages still flow as text). +func New(homeserver, accessToken string, store CursorStore, mediaDir string) *Channel { + return &Channel{ + homeserver: strings.TrimRight(homeserver, "/"), + token: accessToken, + // The HTTP timeout must exceed the long-poll timeout so /sync can block + // server-side for the full window without the client giving up. + client: &http.Client{Timeout: 65 * time.Second}, + store: store, + mediaDir: mediaDir, + } +} + +// Name returns the adapter identifier. +func (c *Channel) Name() string { return "matrix" } + +// syncResponse mirrors the fields we use from a /sync response. Named +// sub-types (not anonymous structs) so they're straightforward to build in +// tests. +type syncResponse struct { + NextBatch string `json:"next_batch"` + Rooms syncRooms `json:"rooms"` +} + +type syncRooms struct { + Join map[string]joinedRoom `json:"join"` +} + +type joinedRoom struct { + Timeline roomTimeline `json:"timeline"` +} + +type roomTimeline struct { + Events []roomEvent `json:"events"` +} + +type roomEvent struct { + Type string `json:"type"` + EventID string `json:"event_id"` + Sender string `json:"sender"` + Content eventContent `json:"content"` +} + +type eventContent struct { + MsgType string `json:"msgtype"` + Body string `json:"body"` + FormattedBody string `json:"formatted_body"` + Filename string `json:"filename"` + URL string `json:"url"` // mxc:// URI for media events + Info eventInfo `json:"info"` + Mentions mentions `json:"m.mentions"` + RelatesTo *relatesTo `json:"m.relates_to"` +} + +type eventInfo struct { + MimeType string `json:"mimetype"` +} + +type mentions struct { + UserIDs []string `json:"user_ids"` +} + +type relatesTo struct { + InReplyTo *struct { + EventID string `json:"event_id"` + } `json:"m.in_reply_to"` +} + +// Start long-polls /sync and forwards each message event as an Inbound until +// ctx is cancelled. The since-token is loaded from (and saved to) the cursor +// store so a restart resumes where it left off. Transient errors back off with +// jitter rather than returning, so a flaky homeserver never takes the gateway +// down. +func (c *Channel) Start(ctx context.Context, sink channels.Sink) error { + // Learn our own mxid so we can skip our own echoes and detect being + // addressed. If whoami fails the adapter still flows messages; without an + // own id we also can't fetch m.direct (its URL needs the user id), so DM + // detection and group mentions degrade to false — the safe default (group + // messages won't trigger unless respond_to_all). + c.userID = c.whoami(ctx) + c.direct = c.fetchDirectRooms(ctx) + + var since string + if c.store != nil { + if v, err := c.store.Cursor(ctx, "matrix"); err == nil { + since = v + } + } + backoff := time.Second + for { + if err := ctx.Err(); err != nil { + return err + } + resp, err := c.sync(ctx, since) + if err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + // Exponential backoff with jitter, capped, so a homeserver outage + // doesn't turn into a synchronized hammer when it comes back. + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(jitter(backoff)): + } + backoff = min(backoff*2, maxSyncBackoff) + continue + } + backoff = time.Second // recovered — reset the ladder + + // First-ever sync (no since-token): deliver nothing. The filtered + // request limited each timeline to one event, and even that one is + // history from before the gateway existed — just record where "now" is. + if since == "" { + since = resp.NextBatch + if c.store != nil { + _ = c.store.SetCursor(ctx, "matrix", since) + } + continue + } + + if err := c.deliverBatch(ctx, sink, resp); err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + // Not durably recorded — do NOT advance since. The next sync from + // the old token replays the batch; the router's dedup discards the + // events that did land. + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(jitter(backoff)): + } + backoff = min(backoff*2, maxSyncBackoff) + continue + } + // Every delivery in the batch was durably recorded — only now may the + // since-token advance (persisted, so a restart resumes here). + since = resp.NextBatch + if c.store != nil { + _ = c.store.SetCursor(ctx, "matrix", since) + } + } +} + +// deliverBatch hands every deliverable event in a sync response to the sink, +// room by room in timeline order. The first Deliver error aborts the batch so +// the caller can retry from the un-advanced since-token. +func (c *Channel) deliverBatch(ctx context.Context, sink channels.Sink, resp *syncResponse) error { + for roomID, room := range resp.Rooms.Join { + for _, ev := range room.Timeline.Events { + inb, ok := c.toInbound(roomID, ev) + if !ok { + continue + } + if att, ok := c.download(ctx, ev.Content); ok { + inb.Attachments = append(inb.Attachments, att) + } + if err := sink.Deliver(ctx, inb); err != nil { + return err + } + } + } + return nil +} + +// jitter returns d scaled by a random factor in [0.75, 1.25) so concurrent +// pollers don't retry in lockstep against a recovering homeserver. +func jitter(d time.Duration) time.Duration { + return time.Duration(float64(d) * (0.75 + rand.Float64()*0.5)) +} + +// toInbound converts one timeline event to a normalized Inbound, or ok=false +// if it isn't a message meant for us: wrong type, our own echo (every /sync +// includes the events we ourselves send), an m.notice (bot-output convention — +// skipping them is the loop breaker between bots), or an empty body. +func (c *Channel) toInbound(roomID string, ev roomEvent) (channels.Inbound, bool) { + if ev.Type != "m.room.message" { + return channels.Inbound{}, false + } + if c.userID != "" && ev.Sender == c.userID { + return channels.Inbound{}, false + } + if ev.Content.MsgType == "m.notice" { + return channels.Inbound{}, false + } + if ev.Content.Body == "" { + return channels.Inbound{}, false + } + text := "" + switch ev.Content.MsgType { + case "m.text": + text = ev.Content.Body + case "m.image", "m.audio", "m.file": + // For media events the body is the filename by convention; only when a + // separate content.filename is present AND differs is the body a caption. + if ev.Content.Filename != "" && ev.Content.Body != ev.Content.Filename { + text = ev.Content.Body + } + default: + return channels.Inbound{}, false + } + return channels.Inbound{ + Channel: "matrix", + Conversation: roomID, + // The mxid IS the stable id in Matrix (unlike a display name, it never + // changes), so it's safe to authorize on directly. + Principal: ev.Sender, + Text: text, + MessageID: ev.EventID, + IsDirect: c.direct[roomID], + Mentioned: c.mentionsMe(ev.Content), + }, true +} + +// mentionsMe reports whether the event addresses this account: the modern +// intentional-mentions field (m.mentions.user_ids), the mxid appearing in the +// body (how clients without intentional mentions render a pill), or a reply +// whose quoted fallback in formatted_body names the mxid. Never a bare +// display-name substring — display names are neither stable nor unique. +func (c *Channel) mentionsMe(content eventContent) bool { + if c.userID == "" { + return false + } + for _, id := range content.Mentions.UserIDs { + if id == c.userID { + return true + } + } + if strings.Contains(content.Body, c.userID) { + return true + } + if content.RelatesTo != nil && content.RelatesTo.InReplyTo != nil && + strings.Contains(content.FormattedBody, c.userID) { + return true + } + return false +} + +// download fetches a media event's mxc:// content into the media spool, +// best-effort: a failed download drops the attachment, the message itself +// still flows. +func (c *Channel) download(ctx context.Context, content eventContent) (channels.Attachment, bool) { + if c.mediaDir == "" || content.URL == "" { + return channels.Attachment{}, false + } + switch content.MsgType { + case "m.image", "m.audio", "m.file": + default: + return channels.Attachment{}, false + } + server, mediaID, ok := parseMXC(content.URL) + if !ok { + return channels.Attachment{}, false + } + // The authenticated media endpoint (v1.11+) — the old unauthenticated + // /_matrix/media path is deprecated and increasingly 404s. + endpoint := fmt.Sprintf("%s/_matrix/client/v1/media/download/%s/%s", + c.homeserver, url.PathEscape(server), url.PathEscape(mediaID)) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return channels.Attachment{}, false + } + req.Header.Set("Authorization", "Bearer "+c.token) + resp, err := c.client.Do(req) + if err != nil { + return channels.Attachment{}, false + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + return channels.Attachment{}, false + } + att, err := channels.SaveToSpool(c.mediaDir, resp.Body, content.Info.MimeType, content.Body) + if err != nil { + return channels.Attachment{}, false + } + return att, true +} + +// parseMXC splits an mxc://server/mediaId content URI. +func parseMXC(uri string) (server, mediaID string, ok bool) { + rest, found := strings.CutPrefix(uri, "mxc://") + if !found { + return "", "", false + } + server, mediaID, found = strings.Cut(rest, "/") + if !found || server == "" || mediaID == "" { + return "", "", false + } + return server, mediaID, true +} + +// whoami fetches this account's mxid. On any error it returns "", and the +// caller degrades safely (see Start). +func (c *Channel) whoami(ctx context.Context) string { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, + c.homeserver+"/_matrix/client/v3/account/whoami", nil) + if err != nil { + return "" + } + req.Header.Set("Authorization", "Bearer "+c.token) + resp, err := c.client.Do(req) + if err != nil { + return "" + } + defer resp.Body.Close() + var out struct { + UserID string `json:"user_id"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return "" + } + return out.UserID +} + +// fetchDirectRooms reads the m.direct account data — the client convention +// that marks which rooms are DMs — and returns the set of DM room ids. +// Best-effort and fetched once: a room becoming a DM mid-run is rare enough +// that a restart picking it up is fine for v1. +func (c *Channel) fetchDirectRooms(ctx context.Context) map[string]bool { + if c.userID == "" { + return nil + } + endpoint := fmt.Sprintf("%s/_matrix/client/v3/user/%s/account_data/m.direct", + c.homeserver, url.PathEscape(c.userID)) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil + } + req.Header.Set("Authorization", "Bearer "+c.token) + resp, err := c.client.Do(req) + if err != nil { + return nil + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + return nil + } + // m.direct maps peer mxid → list of room ids; we only need the room set. + var byUser map[string][]string + if err := json.NewDecoder(resp.Body).Decode(&byUser); err != nil { + return nil + } + direct := make(map[string]bool) + for _, rooms := range byUser { + for _, id := range rooms { + direct[id] = true + } + } + return direct +} + +// sync performs one /sync long-poll. An empty since means the first-ever sync, +// which carries a filter capping each room timeline at one event so a fresh +// gateway doesn't pull (and replay) the full history. +func (c *Channel) sync(ctx context.Context, since string) (*syncResponse, error) { + q := url.Values{} + q.Set("timeout", "30000") + if since != "" { + q.Set("since", since) + } else { + q.Set("filter", `{"room":{"timeline":{"limit":1}}}`) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, + c.homeserver+"/_matrix/client/v3/sync?"+q.Encode(), nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+c.token) + resp, err := c.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + return nil, fmt.Errorf("matrix sync: status %d", resp.StatusCode) + } + var out syncResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, err + } + if out.NextBatch == "" { + return nil, fmt.Errorf("matrix sync: response missing next_batch") + } + return &out, nil +} + +// Send posts a reply to a room, split with the shared chunker. Replies go out +// as m.notice — the Matrix bot convention — so other bots (including a second +// gateway) skip them and no reply loop can form. A voice rendition, when +// present, is uploaded and sent as m.audio first, best-effort: any failure +// falls back silently to the text. +func (c *Channel) Send(ctx context.Context, conversation string, msg channels.Outbound) error { + if msg.VoicePath != "" { + c.sendVoice(ctx, conversation, msg.VoicePath) + } + for _, part := range channels.Chunk(msg.Text, matrixMaxMessage) { + body := map[string]any{"msgtype": "m.notice", "body": part} + if err := c.putEvent(ctx, conversation, body); err != nil { + return err + } + } + return nil +} + +// sendVoice uploads the OGG at path and posts it as an m.audio event. +// Best-effort by design: voice is an embellishment, the text notice that +// follows is the actual reply, so every error here is swallowed. +func (c *Channel) sendVoice(ctx context.Context, conversation, path string) { + f, err := os.Open(path) + if err != nil { + return + } + defer f.Close() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + c.homeserver+"/_matrix/media/v3/upload?filename=voice.ogg", f) + if err != nil { + return + } + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Content-Type", "audio/ogg") + resp, err := c.client.Do(req) + if err != nil { + return + } + defer resp.Body.Close() + var out struct { + ContentURI string `json:"content_uri"` + } + if resp.StatusCode/100 != 2 || json.NewDecoder(resp.Body).Decode(&out) != nil || out.ContentURI == "" { + return + } + _ = c.putEvent(ctx, conversation, map[string]any{ + "msgtype": "m.audio", + "body": "voice message", + "url": out.ContentURI, + "info": map[string]any{"mimetype": "audio/ogg"}, + }) +} + +// putEvent sends one m.room.message event. The transaction id makes the PUT +// idempotent on the homeserver side, so a retried request can't double-post. +func (c *Channel) putEvent(ctx context.Context, roomID string, content map[string]any) error { + body, err := json.Marshal(content) + if err != nil { + return err + } + txnID := fmt.Sprintf("memcode%d", time.Now().UnixNano()) + endpoint := fmt.Sprintf("%s/_matrix/client/v3/rooms/%s/send/m.room.message/%s", + c.homeserver, url.PathEscape(roomID), txnID) + req, err := http.NewRequestWithContext(ctx, http.MethodPut, endpoint, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Content-Type", "application/json") + resp, err := c.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + return fmt.Errorf("matrix send: status %d", resp.StatusCode) + } + return nil +} diff --git a/internal/channels/matrix/matrix_test.go b/internal/channels/matrix/matrix_test.go new file mode 100644 index 0000000..e8ef669 --- /dev/null +++ b/internal/channels/matrix/matrix_test.go @@ -0,0 +1,336 @@ +package matrix + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "sync" + "testing" + "time" + + "github.com/memcode-ai/memcode/internal/channels" +) + +// recordSink forwards each delivered Inbound to a channel and can be told to +// fail the first N deliveries, for exercising the ack semantics. +type recordSink struct { + mu sync.Mutex + failures int + got chan channels.Inbound +} + +func newRecordSink(failures int) *recordSink { + return &recordSink{failures: failures, got: make(chan channels.Inbound, 16)} +} + +func (s *recordSink) Deliver(ctx context.Context, inb channels.Inbound) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.failures > 0 { + s.failures-- + return errors.New("sink down") + } + s.got <- inb + return nil +} + +// fakeCursorStore is an in-memory CursorStore that records every SetCursor. +type fakeCursorStore struct { + mu sync.Mutex + cursor string + sets []string +} + +func (f *fakeCursorStore) Cursor(ctx context.Context, channel string) (string, error) { + f.mu.Lock() + defer f.mu.Unlock() + return f.cursor, nil +} + +func (f *fakeCursorStore) SetCursor(ctx context.Context, channel, cursor string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.cursor = cursor + f.sets = append(f.sets, cursor) + return nil +} + +func (f *fakeCursorStore) lastSet() (string, int) { + f.mu.Lock() + defer f.mu.Unlock() + if len(f.sets) == 0 { + return "", 0 + } + return f.sets[len(f.sets)-1], len(f.sets) +} + +const botID = "@bot:example.org" + +// fakeHomeserver serves whoami, m.direct, and a scripted sequence of /sync +// bodies (one per call; the last repeats). It records each sync's query. +type fakeHomeserver struct { + t *testing.T + syncs []string + + mu sync.Mutex + queries []string // since param of each /sync call ("" for none) + filters []string // filter param of each /sync call +} + +func (f *fakeHomeserver) handler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/account/whoami"): + io.WriteString(w, `{"user_id":"`+botID+`"}`) + case strings.HasSuffix(r.URL.Path, "/account_data/m.direct"): + if !strings.Contains(r.URL.Path, botID) { + f.t.Errorf("m.direct fetched for wrong user: %s", r.URL.Path) + } + io.WriteString(w, `{"@friend:example.org":["!dm:example.org"]}`) + case strings.HasSuffix(r.URL.Path, "/sync"): + f.mu.Lock() + n := len(f.queries) + f.queries = append(f.queries, r.URL.Query().Get("since")) + f.filters = append(f.filters, r.URL.Query().Get("filter")) + f.mu.Unlock() + if got := r.Header.Get("Authorization"); got != "Bearer TOKEN" { + f.t.Errorf("sync auth = %q, want bearer token", got) + } + if n >= len(f.syncs) { + n = len(f.syncs) - 1 + } + io.WriteString(w, f.syncs[n]) + default: + f.t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + }) +} + +func (f *fakeHomeserver) sinceOf(i int) string { + f.mu.Lock() + defer f.mu.Unlock() + if i >= len(f.queries) { + return "" + } + return f.queries[i] +} + +func (f *fakeHomeserver) filterOf(i int) string { + f.mu.Lock() + defer f.mu.Unlock() + if i >= len(f.filters) { + return "" + } + return f.filters[i] +} + +func TestStartDeliversInbound(t *testing.T) { + // Sync 1: no since — history that must NOT be delivered, just skipped over. + // Sync 2: a DM text and a group text carrying an intentional mention. + fs := &fakeHomeserver{t: t, syncs: []string{ + `{"next_batch":"s1","rooms":{"join":{"!dm:example.org":{"timeline":{"events":[ + {"type":"m.room.message","event_id":"$old","sender":"@friend:example.org","content":{"msgtype":"m.text","body":"ancient history"}} + ]}}}}}`, + `{"next_batch":"s2","rooms":{"join":{ + "!dm:example.org":{"timeline":{"events":[ + {"type":"m.room.message","event_id":"$dm1","sender":"@friend:example.org","content":{"msgtype":"m.text","body":"hello there"}} + ]}}, + "!group:example.org":{"timeline":{"events":[ + {"type":"m.room.message","event_id":"$grp1","sender":"@friend:example.org","content":{"msgtype":"m.text","body":"do it","m.mentions":{"user_ids":["@bot:example.org"]}}} + ]}} + }}}`, + `{"next_batch":"s2"}`, + }} + srv := httptest.NewServer(fs.handler()) + defer srv.Close() + + sink := newRecordSink(0) + c := New(srv.URL, "TOKEN", nil, "") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go c.Start(ctx, sink) + + // Room map iteration order is unspecified, so collect by event id. + byID := map[string]channels.Inbound{} + for range 2 { + select { + case inb := <-sink.got: + byID[inb.MessageID] = inb + case <-time.After(3 * time.Second): + t.Fatalf("timed out; delivered so far: %v", byID) + } + } + cancel() + + if _, ok := byID["$old"]; ok { + t.Error("initial (no-since) sync must deliver nothing, but $old came through") + } + dm, ok := byID["$dm1"] + if !ok { + t.Fatal("DM event not delivered") + } + want := channels.Inbound{ + Channel: "matrix", Conversation: "!dm:example.org", + Principal: "@friend:example.org", Text: "hello there", + MessageID: "$dm1", IsDirect: true, + } + if !reflect.DeepEqual(dm, want) { + t.Errorf("dm inbound = %+v, want %+v", dm, want) + } + grp, ok := byID["$grp1"] + if !ok { + t.Fatal("group event not delivered") + } + if grp.IsDirect || !grp.Mentioned { + t.Errorf("group: IsDirect=%v Mentioned=%v, want false/true (m.mentions)", grp.IsDirect, grp.Mentioned) + } + if fs.filterOf(0) == "" { + t.Error("first-ever sync must carry a history-limiting filter") + } + if fs.sinceOf(1) != "s1" { + t.Errorf("second sync since = %q, want s1", fs.sinceOf(1)) + } +} + +func TestDeliverFailureHoldsCursor(t *testing.T) { + // The store already holds a cursor, so the first sync is a real batch (no + // initial-sync special case). The sink fails that delivery once; the + // since-token must stay put and the batch must be re-synced before any + // SetCursor advances it. + batch := `{"next_batch":"s2","rooms":{"join":{"!r:example.org":{"timeline":{"events":[ + {"type":"m.room.message","event_id":"$e1","sender":"@friend:example.org","content":{"msgtype":"m.text","body":"retry me"}} + ]}}}}}` + fs := &fakeHomeserver{t: t, syncs: []string{batch}} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Once the cursor advances past s1, serve empty so the loop idles. + if strings.HasSuffix(r.URL.Path, "/sync") && r.URL.Query().Get("since") == "s2" { + io.WriteString(w, `{"next_batch":"s2"}`) + return + } + fs.handler().ServeHTTP(w, r) + })) + defer srv.Close() + + store := &fakeCursorStore{cursor: "s1"} + sink := newRecordSink(1) // first Deliver errors + c := New(srv.URL, "TOKEN", store, "") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go c.Start(ctx, sink) + + select { + case inb := <-sink.got: + if inb.MessageID != "$e1" { + t.Fatalf("replayed event id = %q, want $e1", inb.MessageID) + } + case <-time.After(5 * time.Second): + t.Fatal("event never redelivered after sink recovery") + } + // The retry must have come from the un-advanced token. + if fs.sinceOf(0) != "s1" || fs.sinceOf(1) != "s1" { + t.Errorf("syncs used since %q then %q, want s1 both times (no advance on failure)", + fs.sinceOf(0), fs.sinceOf(1)) + } + // SetCursor fires only after the successful delivery, and only with s2. + deadline := time.Now().Add(3 * time.Second) + for { + if last, n := store.lastSet(); n > 0 { + if last != "s2" || n != 1 { + t.Errorf("SetCursor calls = %d last %q, want exactly one with s2", n, last) + } + break + } + if time.Now().After(deadline) { + t.Fatal("cursor never persisted after successful delivery") + } + time.Sleep(10 * time.Millisecond) + } + cancel() +} + +func TestSkipsOwnAndNotice(t *testing.T) { + fs := &fakeHomeserver{t: t, syncs: []string{ + `{"next_batch":"s2","rooms":{"join":{"!r:example.org":{"timeline":{"events":[ + {"type":"m.room.message","event_id":"$own","sender":"@bot:example.org","content":{"msgtype":"m.text","body":"my own echo"}}, + {"type":"m.room.message","event_id":"$ntc","sender":"@otherbot:example.org","content":{"msgtype":"m.notice","body":"bot output"}}, + {"type":"m.room.message","event_id":"$ok","sender":"@friend:example.org","content":{"msgtype":"m.text","body":"real message"}} + ]}}}}}`, + `{"next_batch":"s3"}`, + }} + srv := httptest.NewServer(fs.handler()) + defer srv.Close() + + sink := newRecordSink(0) + c := New(srv.URL, "TOKEN", &fakeCursorStore{cursor: "s1"}, "") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go c.Start(ctx, sink) + + select { + case inb := <-sink.got: + if inb.MessageID != "$ok" { + t.Fatalf("delivered %q, want only $ok", inb.MessageID) + } + case <-time.After(3 * time.Second): + t.Fatal("the one real message never arrived") + } + select { + case inb := <-sink.got: + t.Fatalf("own/notice event leaked through: %+v", inb) + case <-time.After(100 * time.Millisecond): + } + cancel() +} + +func TestSendChunksAsNotice(t *testing.T) { + type sent struct { + path, auth string + body map[string]any + } + var mu sync.Mutex + var posts []sent + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Errorf("unexpected method %s", r.Method) + } + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + mu.Lock() + posts = append(posts, sent{r.URL.Path, r.Header.Get("Authorization"), body}) + mu.Unlock() + io.WriteString(w, `{"event_id":"$sent"}`) + })) + defer srv.Close() + + c := New(srv.URL, "TOKEN", nil, "") + long := strings.Repeat("a", matrixMaxMessage+500) // forces two chunks + if err := c.Send(context.Background(), "!room:example.org", channels.Outbound{Text: long}); err != nil { + t.Fatalf("Send: %v", err) + } + if len(posts) != 2 { + t.Fatalf("got %d PUTs, want 2 (chunked)", len(posts)) + } + total := "" + for i, p := range posts { + if !strings.HasPrefix(p.path, "/_matrix/client/v3/rooms/!room:example.org/send/m.room.message/") { + t.Errorf("put %d path = %q", i, p.path) + } + if p.auth != "Bearer TOKEN" { + t.Errorf("put %d auth = %q, want bearer token", i, p.auth) + } + if p.body["msgtype"] != "m.notice" { + t.Errorf("put %d msgtype = %v, want m.notice (bot convention)", i, p.body["msgtype"]) + } + s, _ := p.body["body"].(string) + total += s + } + if total != long { + t.Error("chunked bodies do not reassemble to the original text") + } +} diff --git a/internal/channels/mattermost/mattermost.go b/internal/channels/mattermost/mattermost.go new file mode 100644 index 0000000..c2f7364 --- /dev/null +++ b/internal/channels/mattermost/mattermost.go @@ -0,0 +1,366 @@ +// Package mattermost is the gateway's Mattermost channel adapter, aimed at +// self-hosted servers. It listens on the server's WebSocket event stream +// (api/v4/websocket) for new posts and replies over the REST v4 API — no SDK, +// just net/http plus gorilla/websocket for the socket (the dependency is +// isolated here, guarded by TestVendorSDKsOnlyInTheirAdapters). The user +// creates a bot account, generates a bot access token, and puts the server +// base URL and token in the global .env as MATTERMOST_URL (e.g. +// https://mm.example.com) and MATTERMOST_TOKEN; messages and the token never +// leave the machines the user already runs. +package mattermost + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "math/rand/v2" + "net/http" + "net/url" + "strings" + "time" + "unicode" + "unicode/utf8" + + "github.com/gorilla/websocket" + + "github.com/memcode-ai/memcode/internal/channels" +) + +const ( + // mattermostMaxMessage stays under Mattermost's default ~4000-character post + // cap with headroom, since the cap is server-configurable downward too. + mattermostMaxMessage = 3800 + maxReconnectBackoff = 60 * time.Second +) + +// Channel is a Mattermost bot connection. +type Channel struct { + base string // server base URL, no trailing slash + token string + client *http.Client + mediaDir string // media spool; "" disables attachment downloads +} + +// New builds a Mattermost channel for the given server base URL and bot access +// token. mediaDir is the gateway media spool file attachments are downloaded +// into; "" disables attachment handling (messages still flow as text). +func New(serverURL, token, mediaDir string) *Channel { + return &Channel{ + base: strings.TrimRight(serverURL, "/"), + token: token, + client: &http.Client{Timeout: 30 * time.Second}, + mediaDir: mediaDir, + } +} + +// Name returns the adapter identifier. +func (c *Channel) Name() string { return "mattermost" } + +// wsEvent mirrors the fields we use from a Mattermost websocket event. The +// "post" payload arrives double-encoded: a JSON string holding the post's own +// JSON, so it stays a string here and is unmarshalled a second time. +type wsEvent struct { + Event string `json:"event"` + Data struct { + Post string `json:"post"` // JSON-encoded post (double-encoded) + ChannelType string `json:"channel_type"` // "D" = DM, "O"/"P" = channels + SenderName string `json:"sender_name"` + } `json:"data"` +} + +// post mirrors the fields we use from a Mattermost post. +type post struct { + ID string `json:"id"` + UserID string `json:"user_id"` + ChannelID string `json:"channel_id"` + Message string `json:"message"` + RootID string `json:"root_id"` + FileIDs []string `json:"file_ids"` + Type string `json:"type"` // non-empty = system message +} + +// Start connects to the websocket event stream and forwards each user post as +// an Inbound until ctx is cancelled. A dropped socket reconnects with jittered +// exponential backoff rather than returning, so a flaky server never takes the +// gateway down. +func (c *Channel) Start(ctx context.Context, sink channels.Sink) error { + // Learn our own id and username so we can skip our own posts and detect being + // addressed in a channel. If it fails the bot still serves DMs (recognized by + // channel type); channel messages just won't match as mentions — the safe + // default. + selfID, selfUsername := c.getMe(ctx) + + backoff := time.Second + for { + if err := ctx.Err(); err != nil { + return err + } + c.runConn(ctx, sink, selfID, selfUsername, &backoff) + if ctx.Err() != nil { + return ctx.Err() + } + // Exponential backoff with jitter, capped, so many gateways don't hammer a + // recovering server in lockstep. Reset happens inside runConn once the + // socket proves healthy (a frame actually arrives). + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(jitter(backoff)): + } + backoff = min(backoff*2, maxReconnectBackoff) + } +} + +// runConn owns one websocket connection: dial, authenticate, and pump events +// until the socket errors or ctx is cancelled. backoff is reset to the floor +// only after a frame arrives, so a server that accepts dials and instantly +// drops them still walks the backoff ladder. +func (c *Channel) runConn(ctx context.Context, sink channels.Sink, selfID, selfUsername string, backoff *time.Duration) { + wsURL, err := deriveWSURL(c.base) + if err != nil { + return + } + dialer := websocket.Dialer{HandshakeTimeout: 15 * time.Second} + hdr := http.Header{"Authorization": {"Bearer " + c.token}} + conn, resp, err := dialer.DialContext(ctx, wsURL, hdr) + if resp != nil && resp.Body != nil { + resp.Body.Close() + } + if err != nil { + return + } + defer conn.Close() + + // Unblock the ReadMessage loop on shutdown: closing the conn is the only way + // to interrupt a blocked read. + done := make(chan struct{}) + defer close(done) + go func() { + select { + case <-ctx.Done(): + conn.Close() + case <-done: + } + }() + + // The header auth above is accepted on the upgrade, but the documented flow + // is the in-band authentication challenge — send it too, belt and braces. + challenge := map[string]any{ + "seq": 1, + "action": "authentication_challenge", + "data": map[string]any{"token": c.token}, + } + if err := conn.WriteJSON(challenge); err != nil { + return + } + + for { + _, raw, err := conn.ReadMessage() + if err != nil { + return + } + *backoff = time.Second // the socket is live — reset the ladder + inb, fileIDs, ok := parseEvent(raw, selfID, selfUsername) + if !ok { + continue + } + inb.Attachments = c.download(ctx, fileIDs) + // The websocket has no per-message replay, so a Deliver failure can't be + // retried — the durable record is best-effort here. + _ = sink.Deliver(ctx, inb) + } +} + +// jitter returns d scaled by a random factor in [0.75, 1.25) so reconnecting +// gateways don't retry in lockstep against a recovering server. +func jitter(d time.Duration) time.Duration { + return time.Duration(float64(d) * (0.75 + rand.Float64()*0.5)) +} + +// deriveWSURL maps the server base URL to its websocket endpoint: https becomes +// wss, http becomes ws, path api/v4/websocket. +func deriveWSURL(base string) (string, error) { + u, err := url.Parse(base) + if err != nil { + return "", fmt.Errorf("mattermost server url %q: %w", base, err) + } + switch u.Scheme { + case "https": + u.Scheme = "wss" + case "http": + u.Scheme = "ws" + default: + return "", fmt.Errorf("mattermost server url %q: unsupported scheme %q", base, u.Scheme) + } + u.Path = strings.TrimRight(u.Path, "/") + "/api/v4/websocket" + return u.String(), nil +} + +// parseEvent converts a raw websocket frame to a normalized Inbound plus the +// file ids it references, or ok=false for anything that shouldn't flow: other +// event types, our own posts, system messages, and empty posts. selfID and +// selfUsername identify this bot; either may be empty when users/me failed. +func parseEvent(raw []byte, selfID, selfUsername string) (channels.Inbound, []string, bool) { + var ev wsEvent + if err := json.Unmarshal(raw, &ev); err != nil || ev.Event != "posted" { + return channels.Inbound{}, nil, false + } + // The post rides inside the event as a JSON-encoded string — unmarshal again. + var p post + if err := json.Unmarshal([]byte(ev.Data.Post), &p); err != nil { + return channels.Inbound{}, nil, false + } + if selfID != "" && p.UserID == selfID { + return channels.Inbound{}, nil, false + } + if p.Type != "" { // system message (joins, headers, …), never a task + return channels.Inbound{}, nil, false + } + if strings.TrimSpace(p.Message) == "" && len(p.FileIDs) == 0 { + return channels.Inbound{}, nil, false + } + // Principal is the stable user id, never the mutable username, so the + // allow-list authorizes on a stable identity. + return channels.Inbound{ + Channel: "mattermost", + Conversation: p.ChannelID, + Principal: p.UserID, + Text: p.Message, + MessageID: p.ID, + IsDirect: ev.Data.ChannelType == "D", + Mentioned: mentionsUser(p.Message, selfUsername), + }, p.FileIDs, true +} + +// mentionsUser reports whether text contains "@username" as a whole mention. +// Mattermost mentions are plain text (no entity metadata on the event), so +// this is the one place a substring check is the structural check: the match +// must end at a word boundary so "@tim" never fires on "@timothy". +func mentionsUser(text, username string) bool { + if username == "" { + return false + } + want := "@" + strings.ToLower(username) + lower := strings.ToLower(text) + for from := 0; ; { + i := strings.Index(lower[from:], want) + if i < 0 { + return false + } + end := from + i + len(want) + if end >= len(lower) { + return true + } + r, _ := utf8.DecodeRuneInString(lower[end:]) + if !unicode.IsLetter(r) && !unicode.IsDigit(r) { + return true + } + from = end + } +} + +// getMe fetches this bot's id and username. On any error it returns zero +// values, and the caller degrades safely (DMs still work; channel mentions +// won't match). +func (c *Channel) getMe(ctx context.Context) (id, username string) { + resp, err := c.get(ctx, "/api/v4/users/me") + if err != nil { + return "", "" + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + return "", "" + } + var out struct { + ID string `json:"id"` + Username string `json:"username"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return "", "" + } + return out.ID, out.Username +} + +// download fetches each referenced file into the media spool (files/{id}/info +// for the name and MIME type, then the file bytes), best-effort: a failed +// download drops that attachment, the message itself still flows. +func (c *Channel) download(ctx context.Context, fileIDs []string) []channels.Attachment { + if c.mediaDir == "" || len(fileIDs) == 0 { + return nil + } + var out []channels.Attachment + for _, id := range fileIDs { + att, err := c.downloadOne(ctx, id) + if err != nil { + continue + } + out = append(out, att) + } + return out +} + +func (c *Channel) downloadOne(ctx context.Context, fileID string) (channels.Attachment, error) { + name, mime := "file", "" + if resp, err := c.get(ctx, "/api/v4/files/"+url.PathEscape(fileID)+"/info"); err == nil { + var info struct { + Name string `json:"name"` + MimeType string `json:"mime_type"` + } + if resp.StatusCode/100 == 2 && json.NewDecoder(resp.Body).Decode(&info) == nil { + if info.Name != "" { + name = info.Name + } + mime = info.MimeType + } + resp.Body.Close() + } + resp, err := c.get(ctx, "/api/v4/files/"+url.PathEscape(fileID)) + if err != nil { + return channels.Attachment{}, err + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + return channels.Attachment{}, fmt.Errorf("mattermost file download: status %d", resp.StatusCode) + } + return channels.SaveToSpool(c.mediaDir, resp.Body, mime, name) +} + +// get performs one authenticated GET against the REST v4 API. +func (c *Channel) get(ctx context.Context, path string) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.base+path, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+c.token) + return c.client.Do(req) +} + +// Send posts a reply to a channel, split with the shared chunker to stay under +// Mattermost's per-post length cap. A non-2xx fails fast rather than retrying. +func (c *Channel) Send(ctx context.Context, conversation string, msg channels.Outbound) error { + for _, part := range channels.Chunk(msg.Text, mattermostMaxMessage) { + if err := ctx.Err(); err != nil { + return err + } + body, err := json.Marshal(map[string]string{"channel_id": conversation, "message": part}) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+"/api/v4/posts", bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Content-Type", "application/json") + resp, err := c.client.Do(req) + if err != nil { + return err + } + resp.Body.Close() + if resp.StatusCode/100 != 2 { + return fmt.Errorf("mattermost create post: status %d", resp.StatusCode) + } + } + return nil +} diff --git a/internal/channels/mattermost/mattermost_test.go b/internal/channels/mattermost/mattermost_test.go new file mode 100644 index 0000000..b4b4fbd --- /dev/null +++ b/internal/channels/mattermost/mattermost_test.go @@ -0,0 +1,306 @@ +package mattermost + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/gorilla/websocket" + + "github.com/memcode-ai/memcode/internal/channels" +) + +// postedEvent builds a raw "posted" websocket frame with the post payload +// double-encoded, exactly as Mattermost sends it. +func postedEvent(t *testing.T, p post, channelType, senderName string) []byte { + t.Helper() + pj, err := json.Marshal(p) + if err != nil { + t.Fatal(err) + } + ev := map[string]any{ + "event": "posted", + "data": map[string]any{ + "post": string(pj), // the double-encoding under test + "channel_type": channelType, + "sender_name": senderName, + }, + } + raw, err := json.Marshal(ev) + if err != nil { + t.Fatal(err) + } + return raw +} + +func TestParseEventPosted(t *testing.T) { + raw := postedEvent(t, post{ + ID: "post1", + UserID: "user1", + ChannelID: "chan1", + Message: "hey @membot fix the build", + }, "O", "@alice") + inb, files, ok := parseEvent(raw, "botid", "membot") + if !ok { + t.Fatal("expected ok") + } + want := channels.Inbound{ + Channel: "mattermost", + Conversation: "chan1", + Principal: "user1", + Text: "hey @membot fix the build", + MessageID: "post1", + IsDirect: false, + Mentioned: true, + } + if fmt.Sprintf("%+v", inb) != fmt.Sprintf("%+v", want) { + t.Fatalf("inbound = %+v, want %+v", inb, want) + } + if len(files) != 0 { + t.Fatalf("files = %v, want none", files) + } + + // A DM is direct regardless of mention, and file ids flow through. + raw = postedEvent(t, post{ + ID: "post2", UserID: "user1", ChannelID: "dm1", + Message: "here you go", FileIDs: []string{"f1", "f2"}, + }, "D", "@alice") + inb, files, ok = parseEvent(raw, "botid", "membot") + if !ok { + t.Fatal("expected ok") + } + if !inb.IsDirect { + t.Fatal("channel_type D must map to IsDirect") + } + if inb.Mentioned { + t.Fatal("no @membot in text — Mentioned must be false") + } + if len(files) != 2 || files[0] != "f1" || files[1] != "f2" { + t.Fatalf("files = %v, want [f1 f2]", files) + } +} + +func TestParseEventSkips(t *testing.T) { + cases := []struct { + name string + raw []byte + }{ + {"own post", postedEvent(t, post{ID: "p", UserID: "botid", ChannelID: "c", Message: "echo"}, "D", "@membot")}, + {"system message", postedEvent(t, post{ID: "p", UserID: "u", ChannelID: "c", Message: "u joined", Type: "system_join_channel"}, "O", "@u")}, + {"empty with no files", postedEvent(t, post{ID: "p", UserID: "u", ChannelID: "c", Message: " "}, "D", "@u")}, + {"other event type", []byte(`{"event":"typing","data":{}}`)}, + {"malformed post payload", []byte(`{"event":"posted","data":{"post":"{not json"}}`)}, + } + for _, tc := range cases { + if _, _, ok := parseEvent(tc.raw, "botid", "membot"); ok { + t.Errorf("%s: expected skip", tc.name) + } + } + + // A file-only post (empty message, files attached) must still flow. + raw := postedEvent(t, post{ID: "p", UserID: "u", ChannelID: "c", FileIDs: []string{"f1"}}, "D", "@u") + if _, _, ok := parseEvent(raw, "botid", "membot"); !ok { + t.Error("file-only post: expected ok") + } +} + +func TestMentionsUser(t *testing.T) { + cases := []struct { + text, username string + want bool + }{ + {"hey @membot do it", "membot", true}, + {"@membot", "membot", true}, // end of string is a boundary + {"@membot, please", "membot", true}, // punctuation is a boundary + {"@membot: ship it", "membot", true}, // colon after a mention + {"ping @MemBot now", "membot", true}, // case-insensitive + {"@membots are cool", "membot", false}, // longer handle, not us + {"@membot2 is someone else", "membot", false}, // trailing digit extends the handle + {"mail me at a@membot", "membot", true}, // no leading-boundary requirement + {"@membots yes but also @membot", "membot", true}, // later occurrence still matches + {"no mention here", "membot", false}, + {"@ membot spaced out", "membot", false}, + {"anything", "", false}, // unknown own username never matches + } + for _, tc := range cases { + if got := mentionsUser(tc.text, tc.username); got != tc.want { + t.Errorf("mentionsUser(%q, %q) = %v, want %v", tc.text, tc.username, got, tc.want) + } + } +} + +func TestDeriveWSURL(t *testing.T) { + cases := []struct { + base, want string + }{ + {"https://mm.example.com", "wss://mm.example.com/api/v4/websocket"}, + {"http://127.0.0.1:8065", "ws://127.0.0.1:8065/api/v4/websocket"}, + {"https://example.com/mattermost", "wss://example.com/mattermost/api/v4/websocket"}, + } + for _, tc := range cases { + got, err := deriveWSURL(tc.base) + if err != nil { + t.Fatalf("deriveWSURL(%q): %v", tc.base, err) + } + if got != tc.want { + t.Errorf("deriveWSURL(%q) = %q, want %q", tc.base, got, tc.want) + } + } + if _, err := deriveWSURL("ftp://example.com"); err == nil { + t.Error("ftp scheme: expected error") + } +} + +func TestSendChunksWithBearer(t *testing.T) { + var mu sync.Mutex + var messages []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/api/v4/posts" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + return + } + if got := r.Header.Get("Authorization"); got != "Bearer tok" { + t.Errorf("Authorization = %q, want %q", got, "Bearer tok") + } + var body struct { + ChannelID string `json:"channel_id"` + Message string `json:"message"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode body: %v", err) + } + if body.ChannelID != "chan1" { + t.Errorf("channel_id = %q, want chan1", body.ChannelID) + } + mu.Lock() + messages = append(messages, body.Message) + mu.Unlock() + fmt.Fprint(w, `{"id":"newpost"}`) + })) + defer srv.Close() + + c := New(srv.URL, "tok", "") + long := strings.Repeat("a", 2*mattermostMaxMessage+100) + if err := c.Send(context.Background(), "chan1", channels.Outbound{Text: long}); err != nil { + t.Fatal(err) + } + mu.Lock() + defer mu.Unlock() + if len(messages) != 3 { + t.Fatalf("got %d posts, want 3", len(messages)) + } + for i, m := range messages { + if len(m) > mattermostMaxMessage { + t.Errorf("part %d is %d chars, over the cap", i, len(m)) + } + } + if strings.Join(messages, "") != long { + t.Error("chunked parts don't reassemble to the original text") + } +} + +func TestSendFailsFastOnNon2xx(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "no", http.StatusForbidden) + })) + defer srv.Close() + c := New(srv.URL, "tok", "") + if err := c.Send(context.Background(), "chan1", channels.Outbound{Text: "hi"}); err == nil { + t.Fatal("expected error on 403") + } +} + +// sinkFunc adapts a func to channels.Sink. +type sinkFunc func(ctx context.Context, inb channels.Inbound) error + +func (f sinkFunc) Deliver(ctx context.Context, inb channels.Inbound) error { return f(ctx, inb) } + +// TestStartWebSocketRoundTrip runs the full path: users/me, the websocket +// upgrade with bearer auth, the in-band authentication challenge, one posted +// event delivered to the sink, then a clean ctx.Err() shutdown. +func TestStartWebSocketRoundTrip(t *testing.T) { + upgrader := websocket.Upgrader{} + event := postedEvent(t, post{ + ID: "p1", UserID: "u1", ChannelID: "dm1", Message: "hello @membot", + }, "D", "@alice") + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer tok" { + t.Errorf("Authorization = %q, want %q", got, "Bearer tok") + } + switch r.URL.Path { + case "/api/v4/users/me": + fmt.Fprint(w, `{"id":"botid","username":"membot"}`) + case "/api/v4/websocket": + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + t.Errorf("upgrade: %v", err) + return + } + defer conn.Close() + // Expect the documented in-band auth challenge before any events. + var chal struct { + Action string `json:"action"` + Data struct { + Token string `json:"token"` + } `json:"data"` + } + if err := conn.ReadJSON(&chal); err != nil { + t.Errorf("read challenge: %v", err) + return + } + if chal.Action != "authentication_challenge" || chal.Data.Token != "tok" { + t.Errorf("challenge = %+v, want authentication_challenge with token", chal) + } + if err := conn.WriteMessage(websocket.TextMessage, event); err != nil { + return + } + // Hold the socket open until the client hangs up on ctx cancel. + for { + if _, _, err := conn.ReadMessage(); err != nil { + return + } + } + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + got := make(chan channels.Inbound, 1) + ctx, cancel := context.WithCancel(context.Background()) + errCh := make(chan error, 1) + c := New(srv.URL, "tok", "") + go func() { + errCh <- c.Start(ctx, sinkFunc(func(_ context.Context, inb channels.Inbound) error { + got <- inb + return nil + })) + }() + + select { + case inb := <-got: + if inb.Principal != "u1" || inb.Conversation != "dm1" || !inb.IsDirect || !inb.Mentioned { + t.Errorf("inbound = %+v", inb) + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for inbound") + } + + cancel() + select { + case err := <-errCh: + if err != context.Canceled { + t.Errorf("Start returned %v, want context.Canceled", err) + } + case <-time.After(5 * time.Second): + t.Fatal("Start did not return after cancel") + } +} diff --git a/internal/channels/media.go b/internal/channels/media.go new file mode 100644 index 0000000..b8fc76e --- /dev/null +++ b/internal/channels/media.go @@ -0,0 +1,133 @@ +package channels + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "mime" + "os" + "path/filepath" + "strings" + "time" +) + +// MaxAttachmentBytes caps a single downloaded attachment. Keeps a hostile or +// oversized upload from filling the disk; also comfortably above every +// transcription provider's audio cap. +const MaxAttachmentBytes = 25 << 20 // 25 MiB + +func filepathBase(p string) string { return filepath.Base(p) } + +// KindForMime maps a MIME type (and filename, as a fallback) to a coarse +// attachment kind. +func KindForMime(mimeType, name string) string { + mt := strings.ToLower(strings.TrimSpace(mimeType)) + if i := strings.IndexByte(mt, ';'); i >= 0 { + mt = strings.TrimSpace(mt[:i]) + } + switch { + case strings.HasPrefix(mt, "image/"): + return KindImage + case strings.HasPrefix(mt, "audio/"): + return KindAudio + case mt == "application/pdf": + return KindPDF + } + switch strings.ToLower(filepath.Ext(name)) { + case ".png", ".jpg", ".jpeg", ".gif", ".webp": + return KindImage + case ".ogg", ".oga", ".opus", ".mp3", ".m4a", ".wav", ".aac", ".flac", ".amr": + return KindAudio + case ".pdf": + return KindPDF + } + return KindFile +} + +// SaveToSpool streams r into the media spool as . (content-addressed: +// the same bytes land once) and returns the Attachment. The write is capped at +// MaxAttachmentBytes; an over-cap stream is an error, never a truncated file. +func SaveToSpool(dir string, r io.Reader, mimeType, name string) (Attachment, error) { + if err := os.MkdirAll(dir, 0o700); err != nil { + return Attachment{}, err + } + tmp, err := os.CreateTemp(dir, "spool-*") + if err != nil { + return Attachment{}, err + } + defer os.Remove(tmp.Name()) + h := sha256.New() + n, err := io.Copy(io.MultiWriter(tmp, h), io.LimitReader(r, MaxAttachmentBytes+1)) + if cerr := tmp.Close(); err == nil { + err = cerr + } + if err != nil { + return Attachment{}, err + } + if n > MaxAttachmentBytes { + return Attachment{}, fmt.Errorf("attachment %q exceeds %d bytes", name, int64(MaxAttachmentBytes)) + } + id := hex.EncodeToString(h.Sum(nil)) + spoolExt(mimeType, name) + final := filepath.Join(dir, id) + if _, statErr := os.Stat(final); statErr == nil { + // Content-addressed dedup hit: refresh the mtime so the spool pruner + // (which evicts by age) doesn't reclaim a file a still-pending task + // references. Best-effort. + now := time.Now() + _ = os.Chtimes(final, now, now) + return Attachment{Path: final, Kind: KindForMime(mimeType, name), Mime: mimeType, Name: name}, nil + } + if err := os.Rename(tmp.Name(), final); err != nil { + return Attachment{}, err + } + if err := os.Chmod(final, 0o600); err != nil { + return Attachment{}, err + } + return Attachment{Path: final, Kind: KindForMime(mimeType, name), Mime: mimeType, Name: name}, nil +} + +// ResolveSpoolID resolves a spool ID back to a path STRICTLY inside dir — the +// spool is the trust boundary, so an ID carrying separators, "..", or anything +// but a bare filename is rejected rather than resolved. +func ResolveSpoolID(dir, id string) (string, error) { + if id == "" || id != filepath.Base(id) || strings.HasPrefix(id, ".") || strings.ContainsAny(id, `/\`) { + return "", fmt.Errorf("invalid media id %q", id) + } + p := filepath.Join(dir, id) + fi, err := os.Stat(p) + if err != nil { + return "", err + } + if !fi.Mode().IsRegular() { + return "", fmt.Errorf("media id %q is not a regular file", id) + } + return p, nil +} + +// spoolExt picks a filename extension: the platform-reported MIME type first, +// the original name's extension second, ".bin" last. +func spoolExt(mimeType, name string) string { + mt := strings.ToLower(strings.TrimSpace(mimeType)) + if i := strings.IndexByte(mt, ';'); i >= 0 { + mt = strings.TrimSpace(mt[:i]) + } + // Common types get stable, unsurprising extensions (mime.ExtensionsByType + // ordering is platform-dependent). + known := map[string]string{ + "image/jpeg": ".jpg", "image/png": ".png", "image/gif": ".gif", "image/webp": ".webp", + "audio/ogg": ".ogg", "audio/opus": ".ogg", "audio/mpeg": ".mp3", "audio/mp4": ".m4a", + "audio/x-m4a": ".m4a", "audio/wav": ".wav", "audio/webm": ".webm", "audio/amr": ".amr", + "application/pdf": ".pdf", "text/plain": ".txt", + } + if ext, ok := known[mt]; ok { + return ext + } + if exts, _ := mime.ExtensionsByType(mt); len(exts) > 0 { + return exts[0] + } + if ext := strings.ToLower(filepath.Ext(name)); ext != "" && len(ext) <= 8 { + return ext + } + return ".bin" +} diff --git a/internal/channels/media_test.go b/internal/channels/media_test.go new file mode 100644 index 0000000..dcb86ee --- /dev/null +++ b/internal/channels/media_test.go @@ -0,0 +1,83 @@ +package channels + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestSaveToSpoolAndResolve(t *testing.T) { + dir := t.TempDir() + att, err := SaveToSpool(dir, strings.NewReader("hello"), "image/png", "shot.png") + if err != nil { + t.Fatal(err) + } + if att.Kind != KindImage || !strings.HasSuffix(att.Path, ".png") { + t.Errorf("attachment = %+v", att) + } + // Content-addressed: the same bytes land in the same file. + again, err := SaveToSpool(dir, strings.NewReader("hello"), "image/png", "other.png") + if err != nil || again.Path != att.Path { + t.Errorf("dedup: %v, %q vs %q", err, again.Path, att.Path) + } + // The ID round-trips through the resolver. + p, err := ResolveSpoolID(dir, att.ID()) + if err != nil || p != att.Path { + t.Errorf("resolve = %q, %v", p, err) + } + b, _ := os.ReadFile(p) + if string(b) != "hello" { + t.Errorf("content = %q", b) + } +} + +// The spool is the trust boundary: IDs that aren't bare spool filenames never +// resolve, so a corrupted context file can't point a job at arbitrary files. +func TestResolveSpoolIDRejectsEscapes(t *testing.T) { + dir := t.TempDir() + outside := filepath.Join(t.TempDir(), "secret.txt") + if err := os.WriteFile(outside, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + for _, id := range []string{"", "../secret.txt", outside, "a/b.png", ".hidden", `..\evil`} { + if _, err := ResolveSpoolID(dir, id); err == nil { + t.Errorf("id %q must not resolve", id) + } + } + // A valid-shaped but absent ID errors rather than inventing a path. + if _, err := ResolveSpoolID(dir, "deadbeef.png"); err == nil { + t.Error("missing id must not resolve") + } +} + +func TestKindForMime(t *testing.T) { + cases := map[[2]string]string{ + {"image/jpeg", "x"}: KindImage, + {"audio/ogg; codecs=opus", "v"}: KindAudio, + {"application/pdf", "doc"}: KindPDF, + {"", "voice.opus"}: KindAudio, + {"", "report.pdf"}: KindPDF, + {"application/octet-stream", ""}: KindFile, + } + for in, want := range cases { + if got := KindForMime(in[0], in[1]); got != want { + t.Errorf("KindForMime(%q,%q) = %q, want %q", in[0], in[1], got, want) + } + } +} + +func TestSaveToSpoolCap(t *testing.T) { + dir := t.TempDir() + huge := strings.NewReader(strings.Repeat("x", MaxAttachmentBytes+1)) + if _, err := SaveToSpool(dir, huge, "application/octet-stream", "big"); err == nil { + t.Fatal("over-cap attachment must be refused") + } + // No partial file left behind. + entries, _ := os.ReadDir(dir) + for _, e := range entries { + if strings.HasPrefix(e.Name(), "spool-") { + t.Errorf("temp file leaked: %s", e.Name()) + } + } +} diff --git a/internal/channels/msteams/msteams.go b/internal/channels/msteams/msteams.go new file mode 100644 index 0000000..c0ea5cf --- /dev/null +++ b/internal/channels/msteams/msteams.go @@ -0,0 +1,395 @@ +// Package msteams is the gateway's Microsoft Teams adapter over the Bot +// Framework: an Azure Bot registration POSTs activities to our webhook, and +// replies go back to the activity's serviceUrl as REST calls authenticated with +// an Azure AD client-credentials token. Inbound requests carry a Bot Framework +// JWT we verify against the published JWKS — Teams has no shared-secret HMAC, +// the JWT IS the sender authentication. Credentials (TEAMS_APP_ID, +// TEAMS_APP_PASSWORD, TEAMS_TENANT_ID) are read by the caller and passed to +// New; this package never reads the environment. +package msteams + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/memcode-ai/memcode/internal/channels" + "github.com/memcode-ai/memcode/internal/webjwt" +) + +// botFrameworkIssuer is the issuer every Bot Framework connector token carries. +const botFrameworkIssuer = "https://api.botframework.com" + +// msServiceSuffixes are the host suffixes a legitimate Bot Framework serviceUrl +// or attachment lives under. The connector bearer is sent ONLY to these, and a +// reply is refused to any other host — so a manipulated serviceUrl or +// contentUrl can't redirect the agent's output (and its Authorization header) +// to an attacker. +var msServiceSuffixes = []string{ + ".botframework.com", ".skype.com", "smba.trafficmanager.net", + ".sharepoint.com", ".office.com", ".microsoft.com", ".microsoftonline.com", + ".core.windows.net", ".azureedge.net", +} + +// defaultMetadataURL is the Bot Framework OpenID metadata document; it points +// at the JWKS the connector signs inbound tokens with. +const defaultMetadataURL = "https://login.botframework.com/v1/.well-known/openidconfiguration" + +// defaultTokenBase is the Azure AD endpoint the outbound client-credentials +// token comes from ("{base}/{tenant}/oauth2/v2.0/token"). +const defaultTokenBase = "https://login.microsoftonline.com" + +// botFrameworkScope is the scope for the outbound connector token. +const botFrameworkScope = "https://api.botframework.com/.default" + +const maxBody = 2 << 20 // 2 MiB + +// teamsMaxMessage caps one outbound text activity. Teams rejects activities +// past ~28 KB of serialized payload; 25000 leaves headroom for the JSON frame. +const teamsMaxMessage = 25000 + +// Channel is a Microsoft Teams Bot Framework connection. +type Channel struct { + appID string + appPassword string + tenantID string + mediaDir string // media spool; "" disables inbound media downloads + tokenBase string // Azure AD token endpoint base; overridable in tests + client *http.Client + dl *http.Client // SSRF-guarded client for attachment downloads + trustHost func(host string) bool // serviceUrl/content host trust check; nil = the msServiceSuffixes allowlist (test seam) + verify *webjwt.Verifier // the shared inbound-JWT verifier; tests point its MetadataURL at a fake + + // tokMu guards the cached outbound bearer; refreshed ~60s before expiry so + // an in-flight Send never races the token's edge. + tokMu sync.Mutex + tok string + tokExp time.Time +} + +// New builds a Teams channel from the Azure Bot app id, its client secret, and +// the AAD tenant the bot is registered in. mediaDir is the gateway media spool +// inbound attachments are downloaded into; "" disables media handling. +func New(appID, appPassword, tenantID, mediaDir string) *Channel { + client := &http.Client{Timeout: 30 * time.Second} + return &Channel{ + dl: channels.SafeHTTPClient(30 * time.Second), + appID: appID, + appPassword: appPassword, + tenantID: tenantID, + mediaDir: mediaDir, + tokenBase: defaultTokenBase, + client: client, + verify: &webjwt.Verifier{ + MetadataURL: defaultMetadataURL, + Issuer: botFrameworkIssuer, + Audience: appID, + Client: client, + }, + } +} + +// Name returns the adapter identifier. +func (c *Channel) Name() string { return "msteams" } + +// activity is the subset of a Bot Framework activity we read. +type activity struct { + Type string `json:"type"` + ID string `json:"id"` + Text string `json:"text"` + From struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"from"` + Recipient struct { + ID string `json:"id"` + } `json:"recipient"` + Conversation struct { + ID string `json:"id"` + ConversationType string `json:"conversationType"` + } `json:"conversation"` + ServiceURL string `json:"serviceUrl"` + Entities []struct { + Type string `json:"type"` + Mentioned struct { + ID string `json:"id"` + } `json:"mentioned"` + } `json:"entities"` + Attachments []struct { + ContentType string `json:"contentType"` + ContentURL string `json:"contentUrl"` + Name string `json:"name"` + } `json:"attachments"` +} + +// Handler returns the webhook HTTP handler for POST /webhook/teams. It +// verifies the Bot Framework JWT, maps message activities to Inbound, and acks +// 200 only after every Deliver returned nil (503 makes the connector retry). +func (c *Channel) Handler(sink channels.Sink) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + raw, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") + if !ok || c.verify.Verify(r.Context(), raw) != nil { + // Unauthenticated caller: nothing is delivered. 401, not 503 — a + // forged request must not be invited to retry. + http.Error(w, "invalid token", http.StatusUnauthorized) + return + } + body, err := io.ReadAll(io.LimitReader(r.Body, maxBody)) + if err != nil { + http.Error(w, "read error", http.StatusBadRequest) + return + } + var act activity + if err := json.Unmarshal(body, &act); err != nil { + http.Error(w, "bad activity", http.StatusBadRequest) + return + } + // Non-message activities (conversationUpdate, typing, invoke, …) are + // acked and dropped — the gateway only acts on user messages. + if act.Type != "message" || act.Conversation.ID == "" { + w.WriteHeader(http.StatusOK) + return + } + inb := c.toInbound(act) + inb.Attachments = c.download(r.Context(), act) + if err := sink.Deliver(r.Context(), inb); err != nil { + w.WriteHeader(http.StatusServiceUnavailable) // not recorded — Bot Framework retries + return + } + w.WriteHeader(http.StatusOK) + }) +} + +// toInbound normalizes a message activity. The conversation string encodes +// BOTH the conversation id and the serviceUrl ("id|url") because a Teams reply +// must be posted to the serviceUrl the activity arrived from; serviceUrl never +// contains "|", so the first "|" is an unambiguous split point in Send. +func (c *Channel) toInbound(act activity) channels.Inbound { + mentioned := false + for _, e := range act.Entities { + if e.Type == "mention" && e.Mentioned.ID != "" && e.Mentioned.ID == act.Recipient.ID { + mentioned = true + break + } + } + return channels.Inbound{ + Channel: "msteams", + Conversation: act.Conversation.ID + "|" + act.ServiceURL, + Principal: act.From.ID, // AAD object id — stable across display-name changes + Text: stripAtTags(act.Text), + MessageID: act.ID, + IsDirect: act.Conversation.ConversationType == "personal", + Mentioned: mentioned, + } +} + +// stripAtTags removes the "" spans Teams prepends for bot mentions. +// This cuts only the literal at-tag spans by string search — it is not (and +// must not become) an HTML parser. +func stripAtTags(s string) string { + const openTag, closeTag = "", "" + for { + i := strings.Index(s, openTag) + if i < 0 { + break + } + j := strings.Index(s[i+len(openTag):], closeTag) + if j < 0 { + break + } + s = s[:i] + s[i+len(openTag)+j+len(closeTag):] + } + return strings.TrimSpace(s) +} + +// download fetches attachment content into the spool. Best-effort: a failed +// download drops that attachment, the message still flows. Teams file URLs +// generally require the connector bearer, but some (public blobs) reject +// extraneous auth — so a 401/403 with the bearer is retried without it. +func (c *Channel) download(ctx context.Context, act activity) []channels.Attachment { + if c.mediaDir == "" { + return nil + } + var out []channels.Attachment + for _, a := range act.Attachments { + if !strings.HasPrefix(a.ContentURL, "http://") && !strings.HasPrefix(a.ContentURL, "https://") { + continue + } + // text/html is the message body echoed as an attachment, and card + // payloads are UI, not media — neither is a file for the agent. + ct := strings.ToLower(a.ContentType) + if ct == "text/html" || strings.HasPrefix(ct, "application/vnd.microsoft.card") { + continue + } + att, err := c.downloadOne(ctx, a.ContentURL, a.ContentType, a.Name) + if err != nil { + continue + } + out = append(out, att) + } + return out +} + +// hostTrusted reports whether u is a first-party Bot Framework host the +// connector bearer may be sent to. https-only in production; a test seam +// (trustHost) relaxes it for httptest servers. +func (c *Channel) hostTrusted(u *url.URL) bool { + if u == nil { + return false + } + if c.trustHost != nil { + return c.trustHost(u.Host) + } + return u.Scheme == "https" && channels.HostAllowed(u.Host, msServiceSuffixes...) +} + +func (c *Channel) downloadOne(ctx context.Context, contentURL, mimeType, name string) (channels.Attachment, error) { + // The connector bearer (aud api.botframework.com) is attached ONLY when the + // content host is a first-party Microsoft host, so a contentUrl pointing + // anywhere else can't harvest the gateway's token. The SSRF-guarded client + // independently refuses any internal address. + u, perr := url.Parse(contentURL) + if perr != nil { + return channels.Attachment{}, perr + } + bearer := "" + if c.hostTrusted(u) { + bearer, _ = c.token(ctx) + } + resp, err := c.fetch(ctx, contentURL, bearer) + if err != nil { + return channels.Attachment{}, err + } + if bearer != "" && (resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden) { + resp.Body.Close() + if resp, err = c.fetch(ctx, contentURL, ""); err != nil { + return channels.Attachment{}, err + } + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + return channels.Attachment{}, fmt.Errorf("msteams attachment download: status %d", resp.StatusCode) + } + if name == "" { + name = "attachment" + } + return channels.SaveToSpool(c.mediaDir, resp.Body, mimeType, name) +} + +func (c *Channel) fetch(ctx context.Context, u, bearer string) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, err + } + if bearer != "" { + req.Header.Set("Authorization", "Bearer "+bearer) + } + return c.dl.Do(req) +} + +// token returns a valid outbound connector bearer, minting one via the Azure +// AD client-credentials grant when the cache is empty or within 60s of expiry +// (the margin keeps a token from expiring mid-Send). +func (c *Channel) token(ctx context.Context) (string, error) { + c.tokMu.Lock() + defer c.tokMu.Unlock() + if c.tok != "" && time.Now().Before(c.tokExp.Add(-60*time.Second)) { + return c.tok, nil + } + form := url.Values{ + "grant_type": {"client_credentials"}, + "client_id": {c.appID}, + "client_secret": {c.appPassword}, + "scope": {botFrameworkScope}, + } + endpoint := fmt.Sprintf("%s/%s/oauth2/v2.0/token", strings.TrimRight(c.tokenBase, "/"), c.tenantID) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := c.client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + return "", fmt.Errorf("msteams token: status %d", resp.StatusCode) + } + var tok struct { + AccessToken string `json:"access_token"` + ExpiresIn int64 `json:"expires_in"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, maxBody)).Decode(&tok); err != nil { + return "", err + } + if tok.AccessToken == "" { + return "", errors.New("msteams token: empty access_token") + } + c.tok = tok.AccessToken + c.tokExp = time.Now().Add(time.Duration(tok.ExpiresIn) * time.Second) + return c.tok, nil +} + +// Send posts a reply. The conversation string carries "convID|serviceUrl" +// (see toInbound); the first "|" splits them because a serviceUrl never +// contains one. Text is chunked so an over-long agent reply never bounces. +func (c *Channel) Send(ctx context.Context, conversation string, msg channels.Outbound) error { + convID, serviceURL, ok := strings.Cut(conversation, "|") + if !ok || convID == "" || serviceURL == "" { + return fmt.Errorf("msteams send: malformed conversation %q", conversation) + } + // serviceUrl came from the (JWT-authenticated but attacker-shapeable) + // activity body. Refuse to send the reply — and its connector bearer — to + // any host that isn't a first-party Bot Framework endpoint, so a forged + // serviceUrl can't redirect the agent's output to an attacker. + su, perr := url.Parse(serviceURL) + if perr != nil || !c.hostTrusted(su) { + return fmt.Errorf("msteams send: refusing reply to untrusted serviceUrl %q", serviceURL) + } + bearer, err := c.token(ctx) + if err != nil { + return err + } + endpoint := fmt.Sprintf("%s/v3/conversations/%s/activities", strings.TrimRight(serviceURL, "/"), url.PathEscape(convID)) + for _, part := range channels.Chunk(msg.Text, teamsMaxMessage) { + if err := c.sendOne(ctx, endpoint, bearer, part); err != nil { + return err + } + } + return nil +} + +func (c *Channel) sendOne(ctx context.Context, endpoint, bearer, text string) error { + body, err := json.Marshal(map[string]string{"type": "message", "text": text}) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+bearer) + req.Header.Set("Content-Type", "application/json") + resp, err := c.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + return fmt.Errorf("msteams send: status %d", resp.StatusCode) + } + return nil +} diff --git a/internal/channels/msteams/msteams_test.go b/internal/channels/msteams/msteams_test.go new file mode 100644 index 0000000..9d46b02 --- /dev/null +++ b/internal/channels/msteams/msteams_test.go @@ -0,0 +1,358 @@ +package msteams + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "errors" + "io" + "math/big" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/memcode-ai/memcode/internal/channels" +) + +const testAppID = "app-id-123" + +// recordingSink captures delivered inbounds; err (when set) makes Deliver fail. +type recordingSink struct { + got []channels.Inbound + err error +} + +func (s *recordingSink) Deliver(_ context.Context, inb channels.Inbound) error { + if s.err != nil { + return s.err + } + s.got = append(s.got, inb) + return nil +} + +// jwksServer serves a fake Bot Framework OpenID config + JWKS for key. Returns +// the metadata URL to plug into the verifier. +func jwksServer(t *testing.T, kid string, key *rsa.PrivateKey) string { + t.Helper() + mux := http.NewServeMux() + var srv *httptest.Server + mux.HandleFunc("/openid", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]string{"jwks_uri": srv.URL + "/keys"}) + }) + mux.HandleFunc("/keys", func(w http.ResponseWriter, _ *http.Request) { + pub := key.Public().(*rsa.PublicKey) + _ = json.NewEncoder(w).Encode(map[string]any{ + "keys": []map[string]string{{ + "kty": "RSA", + "kid": kid, + "n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()), + "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes()), + }}, + }) + }) + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv.URL + "/openid" +} + +// signToken mints an RS256 token with the given kid/iss/aud, expiring in 1h. +func signToken(t *testing.T, key *rsa.PrivateKey, kid, iss, aud string) string { + t.Helper() + tok := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{ + "iss": iss, + "aud": aud, + "exp": time.Now().Add(time.Hour).Unix(), + }) + tok.Header["kid"] = kid + s, err := tok.SignedString(key) + if err != nil { + t.Fatalf("sign token: %v", err) + } + return s +} + +func newTestChannel(t *testing.T, metadataURL string) *Channel { + t.Helper() + c := New(testAppID, "secret", "tenant", "") + c.verify.MetadataURL = metadataURL + return c +} + +func postActivity(t *testing.T, h http.Handler, bearer, body string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "/webhook/teams", strings.NewReader(body)) + if bearer != "" { + req.Header.Set("Authorization", "Bearer "+bearer) + } + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + return w +} + +func TestHandlerMapsActivity(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + c := newTestChannel(t, jwksServer(t, "kid-1", key)) + sink := &recordingSink{} + bearer := signToken(t, key, "kid-1", botFrameworkIssuer, testAppID) + + body := `{ + "type": "message", + "id": "act-1", + "text": "memcode fix the build", + "from": {"id": "aad-user-1", "name": "Tim"}, + "recipient": {"id": "28:bot-id"}, + "conversation": {"id": "19:thread@thread.v2", "conversationType": "channel"}, + "serviceUrl": "https://smba.example.com/emea/", + "entities": [{"type": "mention", "mentioned": {"id": "28:bot-id"}}] + }` + w := postActivity(t, c.Handler(sink), bearer, body) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (body %q)", w.Code, w.Body.String()) + } + if len(sink.got) != 1 { + t.Fatalf("delivered %d inbounds, want 1", len(sink.got)) + } + inb := sink.got[0] + if inb.Channel != "msteams" { + t.Errorf("Channel = %q", inb.Channel) + } + if want := "19:thread@thread.v2|https://smba.example.com/emea/"; inb.Conversation != want { + t.Errorf("Conversation = %q, want %q", inb.Conversation, want) + } + if inb.Principal != "aad-user-1" { + t.Errorf("Principal = %q", inb.Principal) + } + if inb.Text != "fix the build" { + t.Errorf("Text = %q, want at-tag stripped", inb.Text) + } + if inb.MessageID != "act-1" { + t.Errorf("MessageID = %q", inb.MessageID) + } + if inb.IsDirect { + t.Error("IsDirect = true for a channel conversation") + } + if !inb.Mentioned { + t.Error("Mentioned = false despite a mention entity for the bot") + } + + // A personal conversation without a mention is a DM. + dm := `{ + "type": "message", + "id": "act-2", + "text": "hello", + "from": {"id": "aad-user-1"}, + "recipient": {"id": "28:bot-id"}, + "conversation": {"id": "a:dm", "conversationType": "personal"}, + "serviceUrl": "https://smba.example.com/emea/" + }` + if w := postActivity(t, c.Handler(sink), bearer, dm); w.Code != http.StatusOK { + t.Fatalf("dm status = %d", w.Code) + } + got := sink.got[len(sink.got)-1] + if !got.IsDirect || got.Mentioned { + t.Errorf("dm mapping: IsDirect=%v Mentioned=%v, want true/false", got.IsDirect, got.Mentioned) + } + + // Non-message activities are acked and dropped. + before := len(sink.got) + if w := postActivity(t, c.Handler(sink), bearer, `{"type":"conversationUpdate","conversation":{"id":"x"}}`); w.Code != http.StatusOK { + t.Fatalf("conversationUpdate status = %d", w.Code) + } + if len(sink.got) != before { + t.Error("non-message activity was delivered") + } +} + +func TestHandlerRejectsBadTokens(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + c := newTestChannel(t, jwksServer(t, "kid-1", key)) + sink := &recordingSink{} + h := c.Handler(sink) + body := `{"type":"message","id":"a","text":"hi","conversation":{"id":"x"},"serviceUrl":"https://s"}` + + // Signed by a key the JWKS doesn't know: signature can't verify. + otherKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + if w := postActivity(t, h, signToken(t, otherKey, "kid-1", botFrameworkIssuer, testAppID), body); w.Code != http.StatusUnauthorized { + t.Errorf("bad signature: status = %d, want 401", w.Code) + } + // Right key, wrong audience: token minted for some other bot. + if w := postActivity(t, h, signToken(t, key, "kid-1", botFrameworkIssuer, "someone-else"), body); w.Code != http.StatusUnauthorized { + t.Errorf("wrong aud: status = %d, want 401", w.Code) + } + // Right key, wrong issuer. + if w := postActivity(t, h, signToken(t, key, "kid-1", "https://evil.example", testAppID), body); w.Code != http.StatusUnauthorized { + t.Errorf("wrong iss: status = %d, want 401", w.Code) + } + // No token at all. + if w := postActivity(t, h, "", body); w.Code != http.StatusUnauthorized { + t.Errorf("missing token: status = %d, want 401", w.Code) + } + if len(sink.got) != 0 { + t.Fatalf("unauthenticated requests delivered %d inbounds, want 0", len(sink.got)) + } +} + +func TestHandlerDeliverFailure(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + c := newTestChannel(t, jwksServer(t, "kid-1", key)) + sink := &recordingSink{err: errors.New("db down")} + bearer := signToken(t, key, "kid-1", botFrameworkIssuer, testAppID) + body := `{"type":"message","id":"a","text":"hi","from":{"id":"u"},"conversation":{"id":"x"},"serviceUrl":"https://s"}` + if w := postActivity(t, c.Handler(sink), bearer, body); w.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503 so the connector retries", w.Code) + } +} + +func TestStripAtTags(t *testing.T) { + cases := map[string]string{ + "Bot do it": "do it", + "do Bot it Two": "do it", + "no tags here": "no tags here", + "unclosed do it": "unclosed do it", + "a < b and stray": "a < b and stray", + } + for in, want := range cases { + if got := stripAtTags(in); got != want { + t.Errorf("stripAtTags(%q) = %q, want %q", in, got, want) + } + } +} + +func TestSendChunksAndCachesToken(t *testing.T) { + var tokenHits atomic.Int64 + tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tokenHits.Add(1) + if err := r.ParseForm(); err != nil { + t.Errorf("parse token form: %v", err) + } + if g := r.Form.Get("grant_type"); g != "client_credentials" { + t.Errorf("grant_type = %q", g) + } + if s := r.Form.Get("scope"); s != botFrameworkScope { + t.Errorf("scope = %q", s) + } + if id := r.Form.Get("client_id"); id != testAppID { + t.Errorf("client_id = %q", id) + } + _ = json.NewEncoder(w).Encode(map[string]any{"access_token": "tok-abc", "expires_in": 3600}) + })) + defer tokenSrv.Close() + + type sent struct { + bearer, path, text string + } + var posts []sent + svcSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var act struct { + Type string `json:"type"` + Text string `json:"text"` + } + if err := json.Unmarshal(body, &act); err != nil { + t.Errorf("bad activity body: %v", err) + } + if act.Type != "message" { + t.Errorf("activity type = %q", act.Type) + } + posts = append(posts, sent{bearer: r.Header.Get("Authorization"), path: r.URL.Path, text: act.Text}) + w.WriteHeader(http.StatusCreated) + })) + defer svcSrv.Close() + + c := New(testAppID, "secret", "tenant-1", "") + c.tokenBase = tokenSrv.URL + c.trustHost = func(string) bool { return true } // test seam: allow the httptest serviceUrl + + long := strings.Repeat("a", teamsMaxMessage) + " tail" + conv := "19:conv-1|" + svcSrv.URL + if err := c.Send(context.Background(), conv, channels.Outbound{Text: long}); err != nil { + t.Fatalf("send: %v", err) + } + if len(posts) != 2 { + t.Fatalf("posted %d activities, want 2 chunks", len(posts)) + } + for _, p := range posts { + if p.bearer != "Bearer tok-abc" { + t.Errorf("bearer = %q", p.bearer) + } + if want := "/v3/conversations/19:conv-1/activities"; p.path != want { + t.Errorf("path = %q, want %q", p.path, want) + } + } + // Chunk is loss-free: the concatenation must equal the input. + if got := posts[0].text + posts[1].text; got != long { + t.Errorf("chunked text lost content: %d+%d runes", len(posts[0].text), len(posts[1].text)) + } + + // Second send reuses the cached token — the token endpoint is hit once. + if err := c.Send(context.Background(), conv, channels.Outbound{Text: "again"}); err != nil { + t.Fatalf("second send: %v", err) + } + if n := tokenHits.Load(); n != 1 { + t.Fatalf("token endpoint hit %d times, want 1 (cached)", n) + } + if len(posts) != 3 || posts[2].text != "again" { + t.Fatalf("second send posts = %+v", posts) + } +} + +func TestSendMalformedConversation(t *testing.T) { + c := New(testAppID, "secret", "tenant", "") + if err := c.Send(context.Background(), "no-service-url", channels.Outbound{Text: "x"}); err == nil { + t.Fatal("expected error for conversation without a serviceUrl") + } +} + +// A reply is refused when the serviceUrl host isn't a first-party Bot Framework +// host — a forged serviceUrl can't redirect the agent's output (and its bearer). +func TestSendRefusesUntrustedServiceURL(t *testing.T) { + c := New(testAppID, "secret", "tenant-1", "") + // No trustHost seam here → the real allowlist applies. + err := c.Send(context.Background(), "19:conv|https://attacker.example.com", channels.Outbound{Text: "hi"}) + if err == nil { + t.Fatal("reply to untrusted serviceUrl must be refused") + } + // A genuine Bot Framework host passes the allowlist (it will fail later at + // the network layer, which is fine — we only assert the host gate here). + err = c.Send(context.Background(), "19:conv|https://smba.trafficmanager.net/amer/", channels.Outbound{Text: "hi"}) + if err != nil && strings.Contains(err.Error(), "untrusted serviceUrl") { + t.Fatalf("trusted host wrongly rejected: %v", err) + } +} + +func TestHostTrustedGate(t *testing.T) { + c := New("app", "s", "t", "") + trusted := []string{"https://smba.trafficmanager.net/x", "https://foo.botframework.com/y", "https://team.sharepoint.com/z"} + for _, u := range trusted { + pu, _ := url.Parse(u) + if !c.hostTrusted(pu) { + t.Errorf("%s should be trusted", u) + } + } + untrusted := []string{"http://smba.trafficmanager.net/x", "https://169.254.169.254/", "https://evil.com/", "https://smba.trafficmanager.net.evil.com/"} + for _, u := range untrusted { + pu, _ := url.Parse(u) + if c.hostTrusted(pu) { + t.Errorf("%s should NOT be trusted", u) + } + } +} diff --git a/internal/channels/safedial.go b/internal/channels/safedial.go new file mode 100644 index 0000000..9410813 --- /dev/null +++ b/internal/channels/safedial.go @@ -0,0 +1,88 @@ +package channels + +import ( + "fmt" + "net" + "net/http" + "strings" + "syscall" + "time" +) + +// The media-download SSRF guard. Every channel that fetches an attachment from +// a URL taken (even indirectly) from an inbound message routes the fetch +// through SafeHTTPClient, so a hostile contentUrl / downloadUri / MediaUrl can +// never make the gateway reach a loopback, private, link-local, or cloud +// metadata address — the class OpenClaw shipped `dangerouslyAllowPrivateNetwork` +// to defend and Hermes' central SSRF floor blocks. The check runs at DIAL time +// on the resolved IP, so DNS names that resolve to an internal address (and +// DNS-rebinding across redirects) are caught too, not just literal IPs. + +// blockedIP reports whether dialing ip would reach a non-public destination. +func blockedIP(ip net.IP) bool { + if ip == nil { + return true + } + if ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() || + ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsMulticast() { + return true // link-local covers 169.254.0.0/16 and fe80::/10 (cloud metadata lives at 169.254.169.254) + } + // Carrier-grade NAT (100.64.0.0/10) — often fronts internal infra. + if ip4 := ip.To4(); ip4 != nil && ip4[0] == 100 && ip4[1]&0xc0 == 64 { + return true + } + return false +} + +// safeControl is a net.Dialer.Control hook: it runs after resolution with the +// actual ip:port about to be dialed, and refuses any non-public address. +func safeControl(_, address string, _ syscall.RawConn) error { + host, _, err := net.SplitHostPort(address) + if err != nil { + return err + } + if blockedIP(net.ParseIP(host)) { + return fmt.Errorf("refusing to connect to non-public address %s", host) + } + return nil +} + +// SafeHTTPClient returns an HTTP client whose every connection (including those +// made following redirects) is refused if it would reach a non-public IP. Use +// it for ALL outbound fetches of URLs derived from inbound messages. +func SafeHTTPClient(timeout time.Duration) *http.Client { + dialer := &net.Dialer{Timeout: 10 * time.Second, Control: safeControl} + return &http.Client{ + Timeout: timeout, + Transport: &http.Transport{ + DialContext: dialer.DialContext, + TLSHandshakeTimeout: 10 * time.Second, + ResponseHeaderTimeout: timeout, + DisableKeepAlives: true, + }, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 5 { + return fmt.Errorf("stopped after %d redirects", len(via)) + } + return nil // each redirect re-dials through safeControl, so an internal target is still blocked + }, + } +} + +// HostAllowed reports whether host (or a subdomain of it) is in suffixes — for +// gating a privileged credential to first-party hosts only. Suffixes are +// matched on a dot boundary, so "evil-botframework.com" does not match +// ".botframework.com". +func HostAllowed(host string, suffixes ...string) bool { + host = strings.ToLower(strings.TrimSuffix(host, ".")) + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + for _, suf := range suffixes { + suf = strings.ToLower(suf) + if host == strings.TrimPrefix(suf, ".") || strings.HasSuffix(host, suf) { + return true + } + } + return false +} diff --git a/internal/channels/safedial_test.go b/internal/channels/safedial_test.go new file mode 100644 index 0000000..f817d34 --- /dev/null +++ b/internal/channels/safedial_test.go @@ -0,0 +1,67 @@ +package channels + +import ( + "context" + "net" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestBlockedIP(t *testing.T) { + blocked := []string{"127.0.0.1", "::1", "10.1.2.3", "192.168.0.1", "172.16.0.1", + "169.254.169.254", "100.64.1.1", "0.0.0.0", "fe80::1"} + for _, s := range blocked { + if !blockedIP(net.ParseIP(s)) { + t.Errorf("%s should be blocked", s) + } + } + allowed := []string{"8.8.8.8", "1.1.1.1", "93.184.216.34", "2606:2800:220:1::"} + for _, s := range allowed { + if blockedIP(net.ParseIP(s)) { + t.Errorf("%s should be allowed", s) + } + } + if !blockedIP(nil) { + t.Error("nil IP must be blocked") + } +} + +// The SSRF-guarded client refuses to connect to a loopback server, even though +// the URL is a normal http:// address — the block happens at dial on the +// resolved IP. +func TestSafeHTTPClientBlocksLoopback(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("secret")) + })) + defer srv.Close() + + c := SafeHTTPClient(5 * time.Second) + req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, srv.URL, nil) + if resp, err := c.Do(req); err == nil { + resp.Body.Close() + t.Fatal("loopback fetch must be refused") + } +} + +func TestHostAllowed(t *testing.T) { + yes := [][2]string{ + {"smba.trafficmanager.net", "smba.trafficmanager.net"}, + {"foo.botframework.com", ".botframework.com"}, + {"a.b.googleusercontent.com", ".googleusercontent.com"}, + {"foo.botframework.com:443", ".botframework.com"}, + } + for _, c := range yes { + if !HostAllowed(c[0], c[1]) { + t.Errorf("HostAllowed(%q,%q) = false", c[0], c[1]) + } + } + // A lookalike domain must NOT match on a dot boundary. + if HostAllowed("evil-botframework.com", ".botframework.com") { + t.Error("lookalike host matched") + } + if HostAllowed("botframework.com.attacker.net", ".botframework.com") { + t.Error("suffix-in-the-middle matched") + } +} diff --git a/internal/channels/signal/signal.go b/internal/channels/signal/signal.go new file mode 100644 index 0000000..2df483f --- /dev/null +++ b/internal/channels/signal/signal.go @@ -0,0 +1,357 @@ +// Package signal is the gateway's Signal channel adapter. It talks to a +// signal-cli daemon running in native HTTP mode (`signal-cli daemon --http`): +// inbound messages arrive over the daemon's SSE event stream, replies go out as +// JSON-RPC `send` calls. Nobody embeds libsignal — both Hermes and OpenClaw +// require the same companion daemon; we spawn nothing and just connect to it +// (SIGNAL_CLI_URL, default http://127.0.0.1:8080). The account is a LINKED +// DEVICE (`signal-cli link`) — use a dedicated number, not your main one. +// +// Attachments: the daemon writes them to its own data directory; the adapter +// reads them from there (attachmentsDir) and copies them into the media spool. +package signal + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "math/rand/v2" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/memcode-ai/memcode/internal/channels" +) + +const ( + maxBackoff = 60 * time.Second + // signalMaxMessage keeps outbound parts well under Signal's practical cap. + signalMaxMessage = 4000 + // groupPrefix marks a group conversation id (a DM conversation is the peer's + // number/uuid directly, so replies know which JSON-RPC shape to use). + groupPrefix = "group:" +) + +// Channel is a connection to a signal-cli HTTP daemon. +type Channel struct { + baseURL string // daemon base, e.g. http://127.0.0.1:8080 + account string // our own E.164 number (loop prevention + mention detection) + attachmentsDir string // signal-cli's attachment store; "" disables media + mediaDir string // gateway media spool; "" disables media + client *http.Client + sse *http.Client // no timeout: the event stream is long-lived + + // idMu guards a learned number->uuid map. signal-cli usually surfaces both a + // uuid and a number; we remember the pairing so a later delivery that carries + // ONLY the number still resolves to the same stable uuid — otherwise the + // dedup key (and authz principal) could flip between uuid and number for the + // same account and re-run a paid job. + idMu sync.Mutex + byNum map[string]string +} + +// New builds a Signal channel. baseURL "" uses the local daemon default. +func New(baseURL, account, attachmentsDir, mediaDir string) *Channel { + if baseURL == "" { + baseURL = "http://127.0.0.1:8080" + } + return &Channel{ + baseURL: strings.TrimRight(baseURL, "/"), + account: strings.TrimSpace(account), + attachmentsDir: attachmentsDir, + mediaDir: mediaDir, + client: &http.Client{Timeout: 30 * time.Second}, + sse: &http.Client{}, + byNum: map[string]string{}, + } +} + +// resolveUUID records a number->uuid pairing when both are present and, for a +// number-only envelope, returns the previously-learned uuid so identity stays +// stable across deliveries. +func (c *Channel) resolveUUID(uuid, number string) string { + c.idMu.Lock() + defer c.idMu.Unlock() + if uuid != "" { + if number != "" { + c.byNum[number] = uuid + } + return uuid + } + return c.byNum[number] // "" if never seen with a uuid +} + +// Name returns the adapter identifier. +func (c *Channel) Name() string { return "signal" } + +// envelope mirrors the signal-cli receive envelope fields we use. +type envelope struct { + Envelope struct { + SourceNumber string `json:"sourceNumber"` + SourceUUID string `json:"sourceUuid"` + Timestamp int64 `json:"timestamp"` + DataMessage *struct { + Message string `json:"message"` + GroupInfo *struct { + GroupID string `json:"groupId"` + } `json:"groupInfo"` + Mentions []struct { + Number string `json:"number"` + UUID string `json:"uuid"` + } `json:"mentions"` + Quote *struct { + Author string `json:"author"` + } `json:"quote"` + Attachments []struct { + ContentType string `json:"contentType"` + Filename string `json:"filename"` + ID string `json:"id"` + } `json:"attachments"` + } `json:"dataMessage"` + } `json:"envelope"` +} + +// Start consumes the daemon's SSE event stream until ctx is cancelled, +// reconnecting with jittered backoff on any error. Delivery is best-effort +// like Discord's websocket: the daemon has no per-message replay, so a failed +// durable record can't be re-driven from the provider side. +func (c *Channel) Start(ctx context.Context, sink channels.Sink) error { + backoff := time.Second + for { + if err := ctx.Err(); err != nil { + return err + } + err := c.stream(ctx, sink) + if ctx.Err() != nil { + return ctx.Err() + } + _ = err + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(jitter(backoff)): + } + backoff = min(backoff*2, maxBackoff) + } +} + +// stream opens one SSE connection and processes events until it breaks. +func (c *Channel) stream(ctx context.Context, sink channels.Sink) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/api/v1/events", nil) + if err != nil { + return err + } + req.Header.Set("Accept", "text/event-stream") + resp, err := c.sse.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + return fmt.Errorf("signal events: status %d", resp.StatusCode) + } + sc := bufio.NewScanner(resp.Body) + sc.Buffer(make([]byte, 0, 64*1024), 4<<20) + var data bytes.Buffer + for sc.Scan() { + line := sc.Text() + switch { + case strings.HasPrefix(line, "data:"): + data.WriteString(strings.TrimPrefix(strings.TrimPrefix(line, "data:"), " ")) + case line == "" && data.Len() > 0: + c.handleEvent(ctx, sink, data.Bytes()) + data.Reset() + } + } + return sc.Err() +} + +// handleEvent parses one SSE payload, stabilizes the sender identity to a uuid +// where possible, and forwards a usable message. +func (c *Channel) handleEvent(ctx context.Context, sink channels.Sink, raw []byte) { + inb, meta, refs, ok := parseEnvelope(raw, c.account) + if !ok { + return + } + // Prefer the account uuid for the principal and dedup key; if this delivery + // carried only a number, fall back to a previously-learned uuid so the same + // account never flips identity between deliveries. + stable := c.resolveUUID(meta.uuid, meta.number) + if stable == "" { + stable = meta.number // never seen with a uuid; the number is all we have + } + if inb.IsDirect { + inb.Conversation = stable + } + inb.Principal = stable + inb.MessageID = fmt.Sprintf("%s:%d", stable, meta.timestamp) + inb.Attachments = c.collect(refs) + _ = sink.Deliver(ctx, inb) // best-effort; see Start +} + +// attachmentRef names one attachment in the daemon's store. +type attachmentRef struct { + id string + mime string + name string +} + +// identity carries the raw sender fields so handleEvent can stabilize them to a +// uuid (see resolveUUID) before setting the principal/conversation/dedup key. +type identity struct { + uuid string + number string + timestamp int64 +} + +// parseEnvelope normalizes a signal-cli receive envelope, or ok=false for +// receipts/typing/own messages/empty events. account is our own number. The +// returned Inbound has placeholder identity fields; handleEvent finalizes them. +func parseEnvelope(raw []byte, account string) (channels.Inbound, identity, []attachmentRef, bool) { + var ev envelope + if err := json.Unmarshal(raw, &ev); err != nil { + return channels.Inbound{}, identity{}, nil, false + } + env := ev.Envelope + dm := env.DataMessage + if dm == nil { + return channels.Inbound{}, identity{}, nil, false // receipt/typing/sync — not a message + } + // Skip our own messages (loop prevention). Authorization is on the account + // UUID (stable); handleEvent resolves the final principal. + if (env.SourceUUID == "" && env.SourceNumber == "") || env.SourceNumber == account || env.SourceUUID == account { + return channels.Inbound{}, identity{}, nil, false + } + var refs []attachmentRef + for _, a := range dm.Attachments { + if a.ID == "" { + continue + } + refs = append(refs, attachmentRef{id: a.ID, mime: a.ContentType, name: a.Filename}) + } + if strings.TrimSpace(dm.Message) == "" && len(refs) == 0 { + return channels.Inbound{}, identity{}, nil, false + } + isDirect := dm.GroupInfo == nil || dm.GroupInfo.GroupID == "" + // Mentioned: an explicit @mention of our number, or a quote-reply to us. + mentioned := false + if account != "" { + for _, m := range dm.Mentions { + if m.Number == account { + mentioned = true + } + } + if dm.Quote != nil && dm.Quote.Author == account { + mentioned = true + } + } + inb := channels.Inbound{ + Channel: "signal", + Text: dm.Message, + IsDirect: isDirect, + Mentioned: mentioned, + } + if !isDirect { + inb.Conversation = groupPrefix + dm.GroupInfo.GroupID + } + return inb, identity{uuid: env.SourceUUID, number: env.SourceNumber, timestamp: env.Timestamp}, refs, true +} + +// collect copies referenced attachments from the daemon's store into the media +// spool, best-effort. IDs are treated as bare filenames — anything else is +// skipped rather than resolved outside the store. +func (c *Channel) collect(refs []attachmentRef) []channels.Attachment { + if c.mediaDir == "" || c.attachmentsDir == "" || len(refs) == 0 { + return nil + } + var out []channels.Attachment + for _, ref := range refs { + if ref.id == "" || ref.id != filepath.Base(ref.id) || strings.HasPrefix(ref.id, ".") { + continue + } + f, err := os.Open(filepath.Join(c.attachmentsDir, ref.id)) + if err != nil { + continue + } + name := ref.name + if name == "" { + name = ref.id + } + att, err := channels.SaveToSpool(c.mediaDir, f, ref.mime, name) + f.Close() + if err != nil { + continue + } + out = append(out, att) + } + return out +} + +// rpcRequest is one JSON-RPC 2.0 call to the daemon. +type rpcRequest struct { + JSONRPC string `json:"jsonrpc"` + ID int64 `json:"id"` + Method string `json:"method"` + Params map[string]any `json:"params"` +} + +// Send posts a reply — to a peer (DM) or a group — as JSON-RPC send calls, +// split with the shared chunker. A synthesized voice note (VoicePath) rides +// the first part as an attachment path; the daemon runs on the same machine, +// so the spool path is directly readable. +func (c *Channel) Send(ctx context.Context, conversation string, msg channels.Outbound) error { + parts := channels.Chunk(msg.Text, signalMaxMessage) + for i, part := range parts { + params := map[string]any{"message": part} + if gid, ok := strings.CutPrefix(conversation, groupPrefix); ok { + params["groupId"] = gid + } else { + params["recipient"] = []string{conversation} + } + if i == 0 && msg.VoicePath != "" { + params["attachments"] = []string{msg.VoicePath} + } + if err := c.rpc(ctx, "send", params); err != nil { + return err + } + } + return nil +} + +func (c *Channel) rpc(ctx context.Context, method string, params map[string]any) error { + body, err := json.Marshal(rpcRequest{JSONRPC: "2.0", ID: time.Now().UnixNano(), Method: method, Params: params}) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/rpc", bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + resp, err := c.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + return fmt.Errorf("signal %s: status %d", method, resp.StatusCode) + } + var out struct { + Error *struct { + Message string `json:"message"` + } `json:"error"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err == nil && out.Error != nil { + return fmt.Errorf("signal %s: %s", method, out.Error.Message) + } + return nil +} + +// jitter scales d by [0.75, 1.25) so reconnects don't resonate. +func jitter(d time.Duration) time.Duration { + return time.Duration(float64(d) * (0.75 + rand.Float64()*0.5)) +} diff --git a/internal/channels/signal/signal_test.go b/internal/channels/signal/signal_test.go new file mode 100644 index 0000000..e80b195 --- /dev/null +++ b/internal/channels/signal/signal_test.go @@ -0,0 +1,159 @@ +package signal + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/memcode-ai/memcode/internal/channels" +) + +const dmEnvelope = `{"envelope":{"sourceNumber":"+15551230000","sourceUuid":"uuid-1","timestamp":1700000000001,"dataMessage":{"message":"fix the build"}},"account":"+15550009999"}` + +func deliverOne(t *testing.T, c *Channel, raw string) (channels.Inbound, bool) { + t.Helper() + var got channels.Inbound + var ok bool + c.handleEvent(context.Background(), sinkFn(func(inb channels.Inbound) error { + got, ok = inb, true + return nil + }), []byte(raw)) + return got, ok +} + +func TestParseEnvelopeDM(t *testing.T) { + c := New("", "+15550009999", "", "") + inb, ok := deliverOne(t, c, dmEnvelope) + if !ok { + t.Fatal("not delivered") + } + // Principal is the STABLE uuid; the phone number is only a fallback. + if inb.Channel != "signal" || inb.Principal != "uuid-1" || inb.Conversation != "uuid-1" { + t.Errorf("inbound = %+v", inb) + } + if !inb.IsDirect || inb.Mentioned { + t.Errorf("gating = %+v", inb) + } + if inb.MessageID != "uuid-1:1700000000001" { + t.Errorf("MessageID = %q (dedup key is sender:timestamp)", inb.MessageID) + } + if len(inb.Attachments) != 0 { + t.Errorf("attachments = %v", inb.Attachments) + } +} + +// A number-only redelivery of a sender first seen WITH a uuid resolves to the +// same uuid, so the dedup key and principal never flip (review finding M2). +func TestSignalNumberResolvesToLearnedUUID(t *testing.T) { + c := New("", "+15550009999", "", "") + // First delivery carries both uuid and number. + if _, ok := deliverOne(t, c, dmEnvelope); !ok { + t.Fatal("first delivery dropped") + } + // A later delivery of the same account carrying ONLY the number. + numOnly := `{"envelope":{"sourceNumber":"+15551230000","timestamp":1700000000002,"dataMessage":{"message":"again"}}}` + inb, ok := deliverOne(t, c, numOnly) + if !ok { + t.Fatal("number-only delivery dropped") + } + if inb.Principal != "uuid-1" || inb.Conversation != "uuid-1" { + t.Errorf("identity flipped to number: %+v", inb) + } + if inb.MessageID != "uuid-1:1700000000002" { + t.Errorf("MessageID = %q, want uuid-keyed", inb.MessageID) + } +} + +func TestParseEnvelopeGroupAndMentions(t *testing.T) { + raw := `{"envelope":{"sourceNumber":"+15551230000","timestamp":2, + "dataMessage":{"message":"@bot do it","groupInfo":{"groupId":"g99"}, + "mentions":[{"number":"+15550009999"}]}}}` + c := New("", "+15550009999", "", "") + inb, ok := deliverOne(t, c, raw) + if !ok { + t.Fatal("not delivered") + } + if inb.IsDirect || inb.Conversation != "group:g99" || !inb.Mentioned { + t.Errorf("group inbound = %+v", inb) + } + + // Quote-reply to us also counts as addressed. + raw = `{"envelope":{"sourceNumber":"+15551230000","timestamp":3, + "dataMessage":{"message":"and this?","groupInfo":{"groupId":"g99"}, + "quote":{"author":"+15550009999"}}}}` + inb, _ = deliverOne(t, c, raw) + if !inb.Mentioned { + t.Error("quote-reply must count as mentioned") + } +} + +func TestParseEnvelopeSkips(t *testing.T) { + for name, raw := range map[string]string{ + "receipt": `{"envelope":{"sourceNumber":"+1555","timestamp":1,"receiptMessage":{}}}`, + "own": `{"envelope":{"sourceNumber":"+15550009999","timestamp":1,"dataMessage":{"message":"hi"}}}`, + "empty": `{"envelope":{"sourceNumber":"+1555","timestamp":1,"dataMessage":{"message":""}}}`, + "malformed": `not json`, + } { + if _, _, _, ok := parseEnvelope([]byte(raw), "+15550009999"); ok { + t.Errorf("%s must be skipped", name) + } + } +} + +func TestSendDMAndGroup(t *testing.T) { + var calls []map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + var req map[string]any + _ = json.Unmarshal(b, &req) + calls = append(calls, req) + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","result":{}}`)) + })) + defer srv.Close() + + c := New(srv.URL, "+15550009999", "", "") + if err := c.Send(context.Background(), "+15551230000", channels.Outbound{Text: "done"}); err != nil { + t.Fatal(err) + } + if err := c.Send(context.Background(), "group:g99", channels.Outbound{Text: strings.Repeat("x", signalMaxMessage+10)}); err != nil { + t.Fatal(err) + } + if len(calls) != 3 { // 1 DM + 2 chunked group parts + t.Fatalf("calls = %d", len(calls)) + } + p0 := calls[0]["params"].(map[string]any) + if rec, ok := p0["recipient"].([]any); !ok || rec[0] != "+15551230000" { + t.Errorf("DM params = %+v", p0) + } + p1 := calls[1]["params"].(map[string]any) + if p1["groupId"] != "g99" { + t.Errorf("group params = %+v", p1) + } +} + +// The SSE stream is parsed into envelopes and delivered best-effort. +func TestStreamDelivers(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, "data: "+dmEnvelope+"\n\n") + })) + defer srv.Close() + + c := New(srv.URL, "+15550009999", "", "") + var got []channels.Inbound + sink := sinkFn(func(inb channels.Inbound) error { got = append(got, inb); return nil }) + if err := c.stream(context.Background(), sink); err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].Text != "fix the build" { + t.Fatalf("delivered %+v", got) + } +} + +type sinkFn func(channels.Inbound) error + +func (s sinkFn) Deliver(_ context.Context, inb channels.Inbound) error { return s(inb) } diff --git a/internal/channels/slack/slack.go b/internal/channels/slack/slack.go index 010358f..ba2608a 100644 --- a/internal/channels/slack/slack.go +++ b/internal/channels/slack/slack.go @@ -93,9 +93,12 @@ func (c *Channel) Start(ctx context.Context, sink channels.Sink) error { // toInbound converts a Slack message event to a normalized Inbound. It skips bot // messages (including our own replies, which carry a bot id), message subtypes -// (edits/joins/etc.), and empty or userless messages. +// (edits/joins/etc.), and empty or userless messages. "file_share" is the one +// subtype allowed through: a message with an uploaded file still carries its +// text (this SDK version doesn't surface the file list, so the file itself is +// not downloaded — a known limitation, better than dropping the message). func toInbound(me *slackevents.MessageEvent, botID string) (channels.Inbound, bool) { - if me == nil || me.BotID != "" || me.SubType != "" { + if me == nil || me.BotID != "" || (me.SubType != "" && me.SubType != "file_share") { return channels.Inbound{}, false } if me.User == "" || strings.TrimSpace(me.Text) == "" { @@ -110,7 +113,7 @@ func toInbound(me *slackevents.MessageEvent, botID string) (channels.Inbound, bo Conversation: me.Channel, Principal: me.User, Text: me.Text, - MessageID: me.TimeStamp, // Slack's per-message ts, unique within a channel + MessageID: me.Channel + ":" + me.TimeStamp, // channel-qualified: ts is unique only WITHIN a channel IsDirect: isDirect, Mentioned: mentioned, }, true diff --git a/internal/channels/slack/slack_test.go b/internal/channels/slack/slack_test.go index 208313b..9111025 100644 --- a/internal/channels/slack/slack_test.go +++ b/internal/channels/slack/slack_test.go @@ -1,6 +1,7 @@ package slack import ( + "reflect" "testing" "github.com/slack-go/slack/slackevents" @@ -33,8 +34,8 @@ func TestToInbound(t *testing.T) { if !ok { return } - want := channels.Inbound{Channel: "slack", Conversation: tt.wantConvo, Principal: tt.wantWho, Text: tt.wantText, MessageID: "ts1"} - if got != want { + want := channels.Inbound{Channel: "slack", Conversation: tt.wantConvo, Principal: tt.wantWho, Text: tt.wantText, MessageID: tt.wantConvo + ":ts1"} + if !reflect.DeepEqual(got, want) { t.Errorf("got %+v, want %+v", got, want) } }) diff --git a/internal/channels/telegram/telegram.go b/internal/channels/telegram/telegram.go index eaef2e6..da13c8e 100644 --- a/internal/channels/telegram/telegram.go +++ b/internal/channels/telegram/telegram.go @@ -10,9 +10,12 @@ import ( "context" "encoding/json" "fmt" + "io" "math/rand/v2" + "mime/multipart" "net/http" "net/url" + "os" "strconv" "strings" "time" @@ -37,23 +40,27 @@ type OffsetStore interface { // Channel is a Telegram bot connection. type Channel struct { - token string - base string // API base; overridable in tests - client *http.Client - store OffsetStore + token string + base string // API base; overridable in tests + client *http.Client + store OffsetStore + mediaDir string // media spool; "" disables attachment downloads } // New builds a Telegram channel for the given bot token. store may be nil, in // which case the poll offset lives only in memory (and a restart re-reads the -// backlog, which the router's dedup then discards). -func New(token string, store OffsetStore) *Channel { +// backlog, which the router's dedup then discards). mediaDir is the gateway +// media spool photos/voice notes/documents are downloaded into; "" disables +// attachment handling (messages still flow as text). +func New(token string, store OffsetStore, mediaDir string) *Channel { return &Channel{ token: token, base: defaultBase, // The HTTP timeout must exceed the long-poll timeout so getUpdates can // block server-side for the full window without the client giving up. - client: &http.Client{Timeout: 65 * time.Second}, - store: store, + client: &http.Client{Timeout: 65 * time.Second}, + store: store, + mediaDir: mediaDir, } } @@ -68,11 +75,27 @@ type update struct { } type tgMessage struct { - From *tgUser `json:"from"` - Chat *tgChat `json:"chat"` - Text string `json:"text"` - Entities []tgEntity `json:"entities"` - ReplyToMessage *tgMessage `json:"reply_to_message"` + From *tgUser `json:"from"` + Chat *tgChat `json:"chat"` + Text string `json:"text"` + Caption string `json:"caption"` + Entities []tgEntity `json:"entities"` + ReplyToMessage *tgMessage `json:"reply_to_message"` + Photo []tgPhotoSize `json:"photo"` + Voice *tgFileMeta `json:"voice"` + Audio *tgFileMeta `json:"audio"` + Document *tgFileMeta `json:"document"` +} + +type tgPhotoSize struct { + FileID string `json:"file_id"` + FileSize int64 `json:"file_size"` +} + +type tgFileMeta struct { + FileID string `json:"file_id"` + MimeType string `json:"mime_type"` + FileName string `json:"file_name"` } type tgUser struct { @@ -130,7 +153,8 @@ func (c *Channel) Start(ctx context.Context, sink channels.Sink) error { } backoff = time.Second // recovered — reset the ladder for _, u := range ups { - if inb, ok := toInbound(u, botID, botUsername); ok { + if inb, refs, ok := toInbound(u, botID, botUsername); ok { + inb.Attachments = c.download(ctx, refs) if err := sink.Deliver(ctx, inb); err != nil { if ctx.Err() != nil { return ctx.Err() @@ -157,29 +181,133 @@ func jitter(d time.Duration) time.Duration { return time.Duration(float64(d) * (0.75 + rand.Float64()*0.5)) } -// toInbound converts a Telegram update to a normalized Inbound, or ok=false if -// it carries no usable text message. botID/botUsername identify this bot so a -// group message can be recognized as addressed to it. -func toInbound(u update, botID int64, botUsername string) (channels.Inbound, bool) { - if u.Message == nil || u.Message.Chat == nil || u.Message.Text == "" { - return channels.Inbound{}, false +// fileRef names one downloadable piece of media on a message. +type fileRef struct { + fileID string + mime string + name string +} + +// toInbound converts a Telegram update to a normalized Inbound plus the media +// it references, or ok=false if it carries neither text nor media. +// botID/botUsername identify this bot so a group message can be recognized as +// addressed to it. +func toInbound(u update, botID int64, botUsername string) (channels.Inbound, []fileRef, bool) { + if u.Message == nil || u.Message.Chat == nil { + return channels.Inbound{}, nil, false + } + m := u.Message + text := m.Text + if text == "" { + text = m.Caption // a photo/voice with a caption: the caption is the task text + } + var refs []fileRef + if len(m.Photo) > 0 { + best := m.Photo[0] + for _, p := range m.Photo[1:] { // Telegram lists sizes ascending; take the largest + if p.FileSize >= best.FileSize { + best = p + } + } + refs = append(refs, fileRef{fileID: best.FileID, mime: "image/jpeg", name: "photo.jpg"}) + } + if v := m.Voice; v != nil && v.FileID != "" { + refs = append(refs, fileRef{fileID: v.FileID, mime: orMime(v.MimeType, "audio/ogg"), name: "voice.ogg"}) + } + if a := m.Audio; a != nil && a.FileID != "" { + refs = append(refs, fileRef{fileID: a.FileID, mime: orMime(a.MimeType, "audio/mpeg"), name: orName(a.FileName, "audio")}) + } + if d := m.Document; d != nil && d.FileID != "" { + refs = append(refs, fileRef{fileID: d.FileID, mime: d.MimeType, name: orName(d.FileName, "document")}) + } + if text == "" && len(refs) == 0 { + return channels.Inbound{}, nil, false } // Principal is the STABLE numeric user id, never the mutable @username — the // allow-list authorizes on ids so a username change (or a lookalike handle) // can't grant or revoke access. principal := "" - if f := u.Message.From; f != nil { + if f := m.From; f != nil { principal = strconv.FormatInt(f.ID, 10) } return channels.Inbound{ Channel: "telegram", - Conversation: strconv.FormatInt(u.Message.Chat.ID, 10), + Conversation: strconv.FormatInt(m.Chat.ID, 10), Principal: principal, - Text: u.Message.Text, + Text: text, MessageID: strconv.FormatInt(u.UpdateID, 10), - IsDirect: u.Message.Chat.Type == "private", + IsDirect: m.Chat.Type == "private", Mentioned: mentionsBot(u, botID, botUsername), - }, true + }, refs, true +} + +func orMime(v, fallback string) string { + if v != "" { + return v + } + return fallback +} + +func orName(v, fallback string) string { + if v != "" { + return v + } + return fallback +} + +// download fetches each referenced file into the media spool (getFile → file +// download endpoint), best-effort: a failed download drops that attachment, the +// message itself still flows. +func (c *Channel) download(ctx context.Context, refs []fileRef) []channels.Attachment { + if c.mediaDir == "" || len(refs) == 0 { + return nil + } + var out []channels.Attachment + for _, ref := range refs { + att, err := c.downloadOne(ctx, ref) + if err != nil { + continue + } + out = append(out, att) + } + return out +} + +func (c *Channel) downloadOne(ctx context.Context, ref fileRef) (channels.Attachment, error) { + // getFile resolves the file_id to a downloadable path. + endpoint := fmt.Sprintf("%s/bot%s/getFile?file_id=%s", c.base, c.token, url.QueryEscape(ref.fileID)) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return channels.Attachment{}, err + } + resp, err := c.client.Do(req) + if err != nil { + return channels.Attachment{}, err + } + defer resp.Body.Close() + var out struct { + OK bool `json:"ok"` + Result struct { + FilePath string `json:"file_path"` + } `json:"result"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil || !out.OK || out.Result.FilePath == "" { + return channels.Attachment{}, fmt.Errorf("telegram getFile failed") + } + dl := fmt.Sprintf("%s/file/bot%s/%s", c.base, c.token, out.Result.FilePath) + dreq, err := http.NewRequestWithContext(ctx, http.MethodGet, dl, nil) + if err != nil { + return channels.Attachment{}, err + } + dresp, err := c.client.Do(dreq) + if err != nil { + return channels.Attachment{}, err + } + defer dresp.Body.Close() + if dresp.StatusCode/100 != 2 { + return channels.Attachment{}, fmt.Errorf("telegram file download: status %d", dresp.StatusCode) + } + return channels.SaveToSpool(c.mediaDir, dresp.Body, ref.mime, ref.name) } // mentionsBot reports whether the message addresses this bot: a reply to one of @@ -201,7 +329,11 @@ func mentionsBot(u update, botID int64, botUsername string) bool { return true } case "bot_command": - if strings.Contains(strings.ToLower(entityText(m.Text, e.Offset, e.Length)), want) { + // A command is "/cmd" or "/cmd@botusername". Match the exact + // "@botusername" suffix on a token boundary — not a substring, so + // "/cmd@botusernameX" (a different bot) does not trigger us. + cmd := strings.ToLower(entityText(m.Text, e.Offset, e.Length)) + if at := strings.IndexByte(cmd, '@'); at >= 0 && cmd[at:] == want { return true } } @@ -277,8 +409,13 @@ func (c *Channel) getUpdates(ctx context.Context, offset int64) ([]update, error // Send posts a text reply to a chat, split with the shared chunker to respect // Telegram's per-message limit. Each part honors a 429 flood-wait; a permanent -// error (any other non-2xx) fails fast instead of retrying forever. +// error (any other non-2xx) fails fast instead of retrying forever. A +// synthesized voice rendition (VoicePath, OGG/Opus) is sent first as a native +// voice bubble, best-effort — the text always follows. func (c *Channel) Send(ctx context.Context, conversation string, msg channels.Outbound) error { + if msg.VoicePath != "" { + _ = c.sendVoice(ctx, conversation, msg.VoicePath) // best-effort; text is the reply of record + } for _, part := range channels.Chunk(msg.Text, telegramMaxMessage) { if err := c.sendOne(ctx, conversation, part); err != nil { return err @@ -287,6 +424,45 @@ func (c *Channel) Send(ctx context.Context, conversation string, msg channels.Ou return nil } +// sendVoice uploads an OGG/Opus file as a voice message (multipart sendVoice). +func (c *Channel) sendVoice(ctx context.Context, conversation, path string) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + var body bytes.Buffer + mw := multipart.NewWriter(&body) + if err := mw.WriteField("chat_id", conversation); err != nil { + return err + } + fw, err := mw.CreateFormFile("voice", "voice.ogg") + if err != nil { + return err + } + if _, err := io.Copy(fw, f); err != nil { + return err + } + if err := mw.Close(); err != nil { + return err + } + endpoint := fmt.Sprintf("%s/bot%s/sendVoice", c.base, c.token) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, &body) + if err != nil { + return err + } + req.Header.Set("Content-Type", mw.FormDataContentType()) + resp, err := c.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + return fmt.Errorf("telegram sendVoice: status %d", resp.StatusCode) + } + return nil +} + // sendOne posts a single (already length-bounded) message, retrying only on a // 429 for the flood-wait Telegram asks for. Never spawn a fallback send on a // rate limit — that's the burst that escalates the penalty. diff --git a/internal/channels/telegram/telegram_test.go b/internal/channels/telegram/telegram_test.go index ae5668a..6d2c63a 100644 --- a/internal/channels/telegram/telegram_test.go +++ b/internal/channels/telegram/telegram_test.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "net/http/httptest" + "reflect" "strings" "testing" "time" @@ -61,7 +62,7 @@ func TestToInbound(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, ok := toInbound(tt.u, 0, "") + got, _, ok := toInbound(tt.u, 0, "") if ok != tt.wantOK { t.Fatalf("ok = %v, want %v", ok, tt.wantOK) } @@ -69,7 +70,7 @@ func TestToInbound(t *testing.T) { return } want := channels.Inbound{Channel: "telegram", Conversation: tt.wantConvo, Principal: tt.wantPrincipal, Text: tt.wantText, MessageID: "1"} - if got != want { + if !reflect.DeepEqual(got, want) { t.Errorf("got %+v, want %+v", got, want) } }) @@ -85,7 +86,7 @@ func TestGetUpdates(t *testing.T) { })) defer srv.Close() - c := New("TOKEN", nil) + c := New("TOKEN", nil, "") c.base = srv.URL ups, err := c.getUpdates(context.Background(), 0) if err != nil { @@ -102,7 +103,7 @@ func TestGetUpdatesAPIError(t *testing.T) { })) defer srv.Close() - c := New("TOKEN", nil) + c := New("TOKEN", nil, "") c.base = srv.URL if _, err := c.getUpdates(context.Background(), 0); err == nil || !strings.Contains(err.Error(), "unauthorized") { t.Fatalf("want unauthorized error, got %v", err) @@ -122,7 +123,7 @@ func TestStartLoadsPersistedOffset(t *testing.T) { })) defer srv.Close() - c := New("TOKEN", &fakeOffsetStore{offset: 100}) + c := New("TOKEN", &fakeOffsetStore{offset: 100}, "") c.base = srv.URL ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -145,7 +146,7 @@ func TestDoSendRetryAfter(t *testing.T) { })) defer srv.Close() - c := New("TOKEN", nil) + c := New("TOKEN", nil, "") c.base = srv.URL status, retryAfter, err := c.doSend(context.Background(), "42", "hi") if err != nil { @@ -172,7 +173,7 @@ func TestSend(t *testing.T) { })) defer srv.Close() - c := New("TOKEN", nil) + c := New("TOKEN", nil, "") c.base = srv.URL if err := c.Send(context.Background(), "42", channels.Outbound{Text: "yo"}); err != nil { t.Fatalf("Send: %v", err) @@ -190,7 +191,7 @@ func TestGatingSignals(t *testing.T) { priv := update{UpdateID: 1, Message: &tgMessage{ Text: "do it", Chat: &tgChat{ID: 1, Type: "private"}, From: &tgUser{ID: 7}, }} - if inb, _ := toInbound(priv, botID, botUser); !inb.IsDirect || inb.Mentioned { + if inb, _, _ := toInbound(priv, botID, botUser); !inb.IsDirect || inb.Mentioned { t.Errorf("private: IsDirect=%v Mentioned=%v, want true/false", inb.IsDirect, inb.Mentioned) } @@ -198,7 +199,7 @@ func TestGatingSignals(t *testing.T) { plain := update{UpdateID: 2, Message: &tgMessage{ Text: "hi all", Chat: &tgChat{ID: -100, Type: "supergroup"}, From: &tgUser{ID: 7}, }} - if inb, _ := toInbound(plain, botID, botUser); inb.IsDirect || inb.Mentioned { + if inb, _, _ := toInbound(plain, botID, botUser); inb.IsDirect || inb.Mentioned { t.Errorf("group plain: IsDirect=%v Mentioned=%v, want false/false", inb.IsDirect, inb.Mentioned) } @@ -208,7 +209,7 @@ func TestGatingSignals(t *testing.T) { Text: text, Chat: &tgChat{ID: -100, Type: "supergroup"}, From: &tgUser{ID: 7}, Entities: []tgEntity{{Type: "mention", Offset: 0, Length: len([]rune("@memcodebot"))}}, }} - if inb, _ := toInbound(mentioned, botID, botUser); !inb.Mentioned { + if inb, _, _ := toInbound(mentioned, botID, botUser); !inb.Mentioned { t.Error("group @mention not detected") } @@ -218,7 +219,7 @@ func TestGatingSignals(t *testing.T) { Text: cmd, Chat: &tgChat{ID: -100, Type: "group"}, From: &tgUser{ID: 7}, Entities: []tgEntity{{Type: "bot_command", Offset: 0, Length: len([]rune(cmd))}}, }} - if inb, _ := toInbound(command, botID, botUser); !inb.Mentioned { + if inb, _, _ := toInbound(command, botID, botUser); !inb.Mentioned { t.Error("/command@bot not detected") } @@ -227,7 +228,7 @@ func TestGatingSignals(t *testing.T) { Text: "thanks", Chat: &tgChat{ID: -100, Type: "group"}, From: &tgUser{ID: 7}, ReplyToMessage: &tgMessage{From: &tgUser{ID: botID}}, }} - if inb, _ := toInbound(reply, botID, botUser); !inb.Mentioned { + if inb, _, _ := toInbound(reply, botID, botUser); !inb.Mentioned { t.Error("reply-to-bot not treated as a mention") } @@ -237,7 +238,7 @@ func TestGatingSignals(t *testing.T) { Text: other, Chat: &tgChat{ID: -100, Type: "group"}, From: &tgUser{ID: 7}, Entities: []tgEntity{{Type: "mention", Offset: 0, Length: len([]rune("@someoneelse"))}}, }} - if inb, _ := toInbound(othermention, botID, botUser); inb.Mentioned { + if inb, _, _ := toInbound(othermention, botID, botUser); inb.Mentioned { t.Error("mention of another user should not count as addressing this bot") } } diff --git a/internal/gateway/config/config.go b/internal/gateway/config/config.go index 4aa98a3..9184e8a 100644 --- a/internal/gateway/config/config.go +++ b/internal/gateway/config/config.go @@ -33,6 +33,36 @@ const ( EnvWhatsAppToken = "WHATSAPP_ACCESS_TOKEN" EnvWhatsAppVerify = "WHATSAPP_VERIFY_TOKEN" EnvWhatsAppSecret = "WHATSAPP_APP_SECRET" // Meta app secret — signs inbound POSTs + // Email: a DEDICATED mailbox the agent answers (app password for Gmail/ + // Outlook), never your personal inbox. Hosts may carry :port (defaults + // 993 IMAP-SSL / 587 SMTP-STARTTLS). + EnvEmailAddress = "EMAIL_ADDRESS" + EnvEmailPassword = "EMAIL_PASSWORD" + EnvEmailIMAPHost = "EMAIL_IMAP_HOST" + EnvEmailSMTPHost = "EMAIL_SMTP_HOST" + // Signal: a signal-cli daemon in native HTTP mode owns the account (linked + // device via `signal-cli link`; use a dedicated number). The URL points at + // that daemon; the number is our own account (loop prevention). + EnvSignalNumber = "SIGNAL_NUMBER" + EnvSignalCLIURL = "SIGNAL_CLI_URL" // optional; default http://127.0.0.1:8080 + // Matrix: any homeserver, access-token login. Plain rooms only (no E2EE). + EnvMatrixHomeserver = "MATRIX_HOMESERVER" + EnvMatrixToken = "MATRIX_ACCESS_TOKEN" + // Mattermost: self-hosted server URL + a bot (or personal) access token. + EnvMattermostURL = "MATTERMOST_URL" + EnvMattermostToken = "MATTERMOST_TOKEN" + // SMS via Twilio. The webhook URL (signature input) is non-secret config: + // channels.sms.webhook_url in gateway.yaml. + EnvTwilioAccountSID = "TWILIO_ACCOUNT_SID" + EnvTwilioAuthToken = "TWILIO_AUTH_TOKEN" + EnvTwilioFromNumber = "TWILIO_FROM_NUMBER" + // Microsoft Teams (Bot Framework / Azure bot registration). + EnvTeamsAppID = "TEAMS_APP_ID" + EnvTeamsAppPassword = "TEAMS_APP_PASSWORD" + EnvTeamsTenantID = "TEAMS_TENANT_ID" + // Google Chat: path to the app's service-account JSON key. The verification + // audience (project number) is non-secret: channels.googlechat.audience. + EnvGoogleChatSAKey = "GOOGLE_CHAT_SA_KEY" ) // Settings is the NON-secret gateway configuration (gateway.yaml). A channel's @@ -187,6 +217,22 @@ type Channel struct { // until the Meta business is verified and the operator flips this to true — // verification is an external account state the gateway can't detect. Active bool `yaml:"active,omitempty"` + // Poll (email) is the mailbox poll cadence as a Go duration ("15s", "1m"). + // Empty uses the adapter default. + Poll string `yaml:"poll,omitempty"` + // WebhookURL (sms) is the EXACT public URL Twilio posts to — the signature + // input, which a proxied server can't reliably reconstruct. Without it the + // SMS webhook rejects everything (fail closed). + WebhookURL string `yaml:"webhook_url,omitempty"` + // Audience (googlechat) is the app's project number — the JWT audience + // inbound Chat events are verified against. + Audience string `yaml:"audience,omitempty"` + // VoiceReplies controls synthesized speech replies on channels that can + // carry voice notes (Telegram, WhatsApp, Signal, Discord, Matrix): + // "off" (default — voice output costs money and speaks replies aloud, so + // it is a deliberate opt-in), "in_kind" (a voice note in gets a voice + // reply out), or "always". The full text reply is always sent too. + VoiceReplies string `yaml:"voice_replies,omitempty"` } // Get returns the settings for a channel (a zero Channel if unset), so callers @@ -266,6 +312,19 @@ func ContextPath(session string) (string, error) { return filepath.Join(dir, "context", session+".json"), nil } +// MediaDir is the gateway's media spool: downloaded inbound attachments and +// synthesized voice replies, content-addressed (.). Gateway-owned +// and global like the rest of the operational state; pruned with the inbox. +// The spool is the TRUST BOUNDARY for job media: jobs receive spool IDs, never +// paths, and resolve them only inside this directory. +func MediaDir() (string, error) { + dir, err := Dir() + if err != nil { + return "", err + } + return filepath.Join(dir, "media"), nil +} + // PersonaHome is a persona's state directory: ~/.memcode/agents/, holding its // own memory.md, MEMCODE.md, and skills. Distinct from the project (the cwd) and // from user-global ~/.memcode (shared by all personas). @@ -333,5 +392,27 @@ func EnabledChannels() []string { if os.Getenv(EnvWhatsAppToken) != "" { names = append(names, "whatsapp") } + if os.Getenv(EnvEmailAddress) != "" && os.Getenv(EnvEmailPassword) != "" && + os.Getenv(EnvEmailIMAPHost) != "" && os.Getenv(EnvEmailSMTPHost) != "" { + names = append(names, "email") + } + if os.Getenv(EnvSignalNumber) != "" { + names = append(names, "signal") + } + if os.Getenv(EnvMatrixHomeserver) != "" && os.Getenv(EnvMatrixToken) != "" { + names = append(names, "matrix") + } + if os.Getenv(EnvMattermostURL) != "" && os.Getenv(EnvMattermostToken) != "" { + names = append(names, "mattermost") + } + if os.Getenv(EnvTwilioAccountSID) != "" && os.Getenv(EnvTwilioAuthToken) != "" && os.Getenv(EnvTwilioFromNumber) != "" { + names = append(names, "sms") + } + if os.Getenv(EnvTeamsAppID) != "" && os.Getenv(EnvTeamsAppPassword) != "" && os.Getenv(EnvTeamsTenantID) != "" { + names = append(names, "msteams") + } + if os.Getenv(EnvGoogleChatSAKey) != "" { + names = append(names, "googlechat") + } return names } diff --git a/internal/gateway/server/dispatch.go b/internal/gateway/server/dispatch.go index e3cb737..1c9219f 100644 --- a/internal/gateway/server/dispatch.go +++ b/internal/gateway/server/dispatch.go @@ -21,43 +21,60 @@ type dispatcher struct { sem chan struct{} mu sync.Mutex - convs map[string]chan func() + convs map[string]*convWorker +} + +// convWorker is one conversation's serial queue plus a count of in-flight +// submissions, so the worker can retire itself (freeing the goroutine and the +// map entry) once idle — otherwise a daemon talking to many rooms over its +// lifetime would leak a goroutine + channel per distinct conversation. +type convWorker struct { + ch chan func() + pending int } func newDispatcher() *dispatcher { return &dispatcher{ sem: make(chan struct{}, maxConcurrentJobs), - convs: make(map[string]chan func()), + convs: make(map[string]*convWorker), } } // submit enqueues fn to run on key's serial worker, creating the worker on first -// use. Ordering is per key. +// use. Ordering is per key. An idle worker is retired, so the goroutine/map +// entry don't accumulate across many short-lived conversations. func (d *dispatcher) submit(ctx context.Context, key string, fn func()) { d.mu.Lock() - ch, ok := d.convs[key] + w, ok := d.convs[key] if !ok { - ch = make(chan func(), 64) - d.convs[key] = ch - go d.serve(ctx, ch) + w = &convWorker{ch: make(chan func(), 64)} + d.convs[key] = w + go d.serve(ctx, key, w) } + w.pending++ d.mu.Unlock() select { - case ch <- fn: + case w.ch <- fn: case <-ctx.Done(): + d.mu.Lock() + w.pending-- // never ran; keep the counter honest + d.mu.Unlock() } } -// serve runs one conversation's functions sequentially until ctx is cancelled. -// Each passes through the global semaphore so total concurrency stays bounded -// even across many conversations. -func (d *dispatcher) serve(ctx context.Context, ch <-chan func()) { +// serve runs one conversation's functions sequentially until ctx is cancelled or +// the queue drains. Each passes through the global semaphore so total +// concurrency stays bounded even across many conversations. When no work is +// pending the worker retires and deletes its map entry under the lock; a racing +// submit that already incremented pending re-creates a worker, so nothing is +// dropped. +func (d *dispatcher) serve(ctx context.Context, key string, w *convWorker) { for { select { case <-ctx.Done(): return - case fn := <-ch: + case fn := <-w.ch: select { case d.sem <- struct{}{}: case <-ctx.Done(): @@ -65,6 +82,14 @@ func (d *dispatcher) serve(ctx context.Context, ch <-chan func()) { } fn() <-d.sem + d.mu.Lock() + w.pending-- + if w.pending == 0 { + delete(d.convs, key) + d.mu.Unlock() + return + } + d.mu.Unlock() } } } diff --git a/internal/gateway/server/dispatch_test.go b/internal/gateway/server/dispatch_test.go index fe3a090..738aab2 100644 --- a/internal/gateway/server/dispatch_test.go +++ b/internal/gateway/server/dispatch_test.go @@ -57,7 +57,7 @@ func TestDispatcherOrdersWithinKey(t *testing.T) { func TestDispatcherBoundsConcurrency(t *testing.T) { const cap = 2 - d := &dispatcher{sem: make(chan struct{}, cap), convs: make(map[string]chan func())} + d := &dispatcher{sem: make(chan struct{}, cap), convs: make(map[string]*convWorker)} var cur, max int32 release := make(chan struct{}) diff --git a/internal/gateway/server/media.go b/internal/gateway/server/media.go new file mode 100644 index 0000000..9286d53 --- /dev/null +++ b/internal/gateway/server/media.go @@ -0,0 +1,186 @@ +package server + +import ( + "bytes" + "context" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/memcode-ai/memcode/internal/channels" + "github.com/memcode-ai/memcode/internal/gateway/state" + "github.com/memcode-ai/memcode/internal/providers/gemini" + openaiprov "github.com/memcode-ai/memcode/internal/providers/openai" +) + +// transcriber converts an audio file to text. Implemented by the provider +// homes (openai, gemini) so their SDKs stay contained there. +type transcriber interface { + Transcribe(ctx context.Context, path, mime string) (string, error) +} + +// newTranscriber picks a speech-to-text backend from the credentials present +// in the environment: OpenAI first (a dedicated, accurate STT endpoint), then +// Gemini (audio-in generate). nil when neither key is set — voice notes then +// get an honest "not configured" reply instead of silence. +func newTranscriber() transcriber { + if k := strings.TrimSpace(os.Getenv(openaiprov.EnvOpenAIKey)); k != "" { + return openaiprov.NewOpenAI(k) + } + if k := strings.TrimSpace(os.Getenv(gemini.EnvGeminiKey)); k != "" { + return gemini.NewGemini(k) + } + return nil +} + +// speaker synthesizes speech (OGG/Opus bytes) for a reply. Implemented by the +// openai provider home; nil when no key is configured. +type speaker interface { + Speak(ctx context.Context, text string) ([]byte, error) +} + +// newSpeaker picks a text-to-speech backend from present credentials. OpenAI +// only for now — its speech endpoint emits Opus directly, so no transcoding +// (and no ffmpeg) exists anywhere in the pipeline. +func newSpeaker() speaker { + if k := strings.TrimSpace(os.Getenv(openaiprov.EnvOpenAIKey)); k != "" { + return openaiprov.NewOpenAI(k) + } + return nil +} + +// maybeSpeak synthesizes a voice rendition of a reply when the channel's +// voice_replies policy asks for one, returning the SPOOL ID ("" = text only) — +// the durable handle that rides the replied row, so retries and restarts +// re-send the same file instead of re-billing TTS. Policy: "always", or +// "in_kind" when the task arrived with a voice note. Failures degrade silently +// to text — a reply is never lost to TTS. +func (r *runtime) maybeSpeak(ctx context.Context, it state.Item, reply string) string { + if r.tts == nil { + return "" + } + switch r.cfg().Get(it.Channel).VoiceReplies { + case "always": + case "in_kind": + hadVoice := false + for _, id := range it.Attachments { + if audioSpoolID(id) { + hadVoice = true + } + } + if !hadVoice { + return "" + } + default: // "" / "off" — deliberate opt-in + return "" + } + spoken := spokenSummary(reply) + if spoken == "" { + return "" + } + data, err := r.tts.Speak(ctx, spoken) + if err != nil { + fmt.Fprintf(r.out, "gateway: voice reply synthesis failed: %v\n", err) + return "" + } + att, err := channels.SaveToSpool(r.mediaDir, bytes.NewReader(data), "audio/ogg", "reply.ogg") + if err != nil { + return "" + } + return att.ID() +} + +// spokenSummary renders a reply as speakable text: code blocks dropped (nobody +// wants a diff read aloud), whitespace collapsed, capped at ~600 runes with the +// full text always arriving alongside as a message. +func spokenSummary(reply string) string { + var kept []string + inFence := false + for _, line := range strings.Split(reply, "\n") { + if strings.HasPrefix(strings.TrimSpace(line), "```") { + inFence = !inFence + continue + } + if !inFence { + kept = append(kept, line) + } + } + s := strings.Join(strings.Fields(strings.Join(kept, " ")), " ") + runes := []rune(s) + if len(runes) > 600 { + s = string(runes[:600]) + "… full details in the text reply." + } + return strings.TrimSpace(s) +} + +// audioSpoolID reports whether a spool ID names an audio file (the spool is +// content-addressed with a MIME-derived extension, so the extension is ours). +func audioSpoolID(id string) bool { + switch strings.ToLower(filepath.Ext(id)) { + case ".ogg", ".oga", ".opus", ".mp3", ".m4a", ".wav", ".webm", ".amr", ".aac", ".flac": + return true + } + return false +} + +// transcribeAudio resolves and transcribes the audio attachments of a task, +// returning the composed task text and the remaining (non-audio) spool IDs. +// The transcript is labeled so the model knows it is machine-transcribed +// speech, not typed text. missing reports audio that could not be handled +// because no transcription provider is configured. +func (r *runtime) transcribeAudio(ctx context.Context, text string, ids []string) (task string, rest []string, missing bool) { + var transcripts []string + for _, id := range ids { + if !audioSpoolID(id) { + rest = append(rest, id) + continue + } + path, err := channels.ResolveSpoolID(r.mediaDir, id) + if err != nil { + continue + } + if r.stt == nil { + missing = true + continue + } + t, err := r.stt.Transcribe(ctx, path, "") + if err != nil { + fmt.Fprintf(r.out, "gateway: transcribing %s failed: %v\n", id, err) + missing = true + continue + } + transcripts = append(transcripts, t) + } + task = text + if len(transcripts) > 0 { + joined := strings.Join(transcripts, "\n") + if strings.TrimSpace(task) == "" { + task = joined + } else { + task = task + "\n\n[transcribed voice note]\n" + joined + } + } + return task, rest, missing +} + +// pruneSpool deletes media spool files older than the cutoff — the same +// retention as the durable inbox, so an attachment outlives every task that +// could still reference it. Best-effort: a prune failure never blocks startup. +func pruneSpool(dir string, before time.Time) { + entries, err := os.ReadDir(dir) + if err != nil { + return + } + for _, e := range entries { + if !e.Type().IsRegular() { + continue + } + info, err := e.Info() + if err != nil || !info.ModTime().Before(before) { + continue + } + _ = os.Remove(filepath.Join(dir, e.Name())) + } +} diff --git a/internal/gateway/server/media_test.go b/internal/gateway/server/media_test.go new file mode 100644 index 0000000..7247a41 --- /dev/null +++ b/internal/gateway/server/media_test.go @@ -0,0 +1,105 @@ +package server + +import ( + "context" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/memcode-ai/memcode/internal/channels" + gwconfig "github.com/memcode-ai/memcode/internal/gateway/config" + "github.com/memcode-ai/memcode/internal/gateway/state" +) + +type fakeSTT struct{ text string } + +func (f fakeSTT) Transcribe(_ context.Context, path, _ string) (string, error) { + return f.text, nil +} + +func TestTranscribeAudioComposesTask(t *testing.T) { + dir := t.TempDir() + // One audio file, one image in the spool. + for _, name := range []string{"aa.ogg", "bb.png"} { + if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + } + rt := &runtime{mediaDir: dir, stt: fakeSTT{text: "fix the login bug"}, out: io.Discard} + + // Voice-only message: the transcript IS the task; the image stays attached. + task, rest, missing := rt.transcribeAudio(context.Background(), "", []string{"aa.ogg", "bb.png"}) + if task != "fix the login bug" || missing { + t.Errorf("task = %q missing=%v", task, missing) + } + if len(rest) != 1 || rest[0] != "bb.png" { + t.Errorf("rest = %v", rest) + } + + // Text + voice: transcript is appended, labeled. + task, _, _ = rt.transcribeAudio(context.Background(), "context here", []string{"aa.ogg"}) + if task == "context here" || !strings.Contains(task, "[transcribed voice note]") { + t.Errorf("composed task = %q", task) + } + + // No STT configured → missing reported, audio dropped, non-audio kept. + rt.stt = nil + task, rest, missing = rt.transcribeAudio(context.Background(), "", []string{"aa.ogg", "bb.png"}) + if !missing || task != "" || len(rest) != 1 { + t.Errorf("no-stt: task=%q rest=%v missing=%v", task, rest, missing) + } +} + +type fakeTTS struct{ called int } + +func (f *fakeTTS) Speak(_ context.Context, _ string) ([]byte, error) { + f.called++ + return []byte("OggS-fake"), nil +} + +// voice_replies is a deliberate opt-in: default off, in_kind only when the +// task carried a voice note, always speaks everything. +func TestMaybeSpeakPolicy(t *testing.T) { + dir := t.TempDir() + tts := &fakeTTS{} + rt := &runtime{mediaDir: dir, tts: tts, out: io.Discard} + itVoice := state.Item{Channel: "telegram", Attachments: []string{"aa.ogg"}} + itText := state.Item{Channel: "telegram"} + + // Default: off, even for a voice note. + if p := rt.maybeSpeak(context.Background(), itVoice, "done"); p != "" || tts.called != 0 { + t.Errorf("default must be off: %q %d", p, tts.called) + } + rt.settings = gwconfig.Settings{Channels: map[string]gwconfig.Channel{"telegram": {VoiceReplies: "in_kind"}}} + if p := rt.maybeSpeak(context.Background(), itText, "done"); p != "" { + t.Errorf("in_kind must not speak for text-only tasks: %q", p) + } + p := rt.maybeSpeak(context.Background(), itVoice, "done") + if p == "" || tts.called != 1 { + t.Fatalf("in_kind with voice note: %q %d", p, tts.called) + } + // The return is a durable spool ID, not a path — it must resolve in the spool. + if resolved, err := channels.ResolveSpoolID(dir, p); err != nil { + t.Errorf("voice id %q must resolve in the spool: %v", p, err) + } else if _, err := os.Stat(resolved); err != nil { + t.Errorf("voice file missing: %v", err) + } + rt.settings = gwconfig.Settings{Channels: map[string]gwconfig.Channel{"telegram": {VoiceReplies: "always"}}} + if p := rt.maybeSpeak(context.Background(), itText, "done"); p == "" { + t.Error("always must speak") + } +} + +func TestSpokenSummary(t *testing.T) { + in := "Fixed it.\n```go\nfunc x() {}\n```\nAll tests green." + got := spokenSummary(in) + if strings.Contains(got, "func x") || !strings.Contains(got, "All tests green") { + t.Errorf("summary = %q", got) + } + long := strings.Repeat("word ", 300) + if s := spokenSummary(long); len([]rune(s)) > 700 || !strings.Contains(s, "full details in the text reply") { + t.Errorf("cap failed: %d runes", len([]rune(s))) + } +} diff --git a/internal/gateway/server/persona.go b/internal/gateway/server/persona.go index 1bb62ae..70f04e1 100644 --- a/internal/gateway/server/persona.go +++ b/internal/gateway/server/persona.go @@ -16,9 +16,16 @@ import ( type jobContext struct { Items []agentrt.ContextItem `json:"items,omitempty"` SkillRoots []string `json:"skill_roots,omitempty"` + // Attachments are media spool IDs (bare . filenames) riding this + // task. IDs, never paths: the child resolves them strictly inside the gateway + // media spool, so a corrupted or stale context file cannot point a job at + // arbitrary local files. + Attachments []string `json:"attachments,omitempty"` } -func (jc jobContext) empty() bool { return len(jc.Items) == 0 && len(jc.SkillRoots) == 0 } +func (jc jobContext) empty() bool { + return len(jc.Items) == 0 && len(jc.SkillRoots) == 0 && len(jc.Attachments) == 0 +} // jobContextFor composes everything a bound persona layers onto a run: its // instructions and memory as generic ContextItems, and its own skills dir as an diff --git a/internal/gateway/server/reply_test.go b/internal/gateway/server/reply_test.go index e789917..593b143 100644 --- a/internal/gateway/server/reply_test.go +++ b/internal/gateway/server/reply_test.go @@ -35,7 +35,7 @@ func TestDeliverReplySurvivesSendFailure(t *testing.T) { it := state.Item{Channel: "telegram", MessageID: "m1", Conversation: "42", Principal: "p", Text: "hi"} gw.Accept(ctx, it, time.Unix(1000, 0)) - if err := gw.SetReplied(ctx, "telegram", "m1", "the answer"); err != nil { + if err := gw.SetReplied(ctx, "telegram", "m1", "the answer", ""); err != nil { t.Fatal(err) } diff --git a/internal/gateway/server/selection_test.go b/internal/gateway/server/selection_test.go index 7f57835..e04a299 100644 --- a/internal/gateway/server/selection_test.go +++ b/internal/gateway/server/selection_test.go @@ -3,7 +3,9 @@ package server import ( "context" "io" + "strings" "testing" + "time" "github.com/memcode-ai/memcode/internal/channels" gwconfig "github.com/memcode-ai/memcode/internal/gateway/config" @@ -112,3 +114,41 @@ func TestChannelProjectPolicy(t *testing.T) { t.Errorf("resolveSelection default = %q, want www (channel policy)", p) } } + +// A queued task whose snapshotted project is disabled or deleted before +// execution is REFUSED — never run in the gateway default root (P1 from the +// channels-baseline review). +func TestRunJobRefusesUnresolvableProject(t *testing.T) { + ctx := context.Background() + gw, err := state.Open(ctx, t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer gw.Close() + + sender := &capturingSender{} + rt := &runtime{ + root: t.TempDir(), + gw: gw, + settings: gwconfig.Settings{ + // The id is allowed on the channel, but the project itself is disabled. + Projects: map[string]gwconfig.Project{"www": {Path: t.TempDir(), Enabled: false}}, + }, + mediaDir: t.TempDir(), + byName: map[string]replySender{"telegram": sender}, + out: io.Discard, + notify: make(chan struct{}, 1), + } + it := state.Item{Channel: "telegram", MessageID: "m1", Conversation: "1", Principal: "me", Text: "do it", Project: "www"} + if _, err := gw.Accept(ctx, it, time.Now()); err != nil { + t.Fatal(err) + } + rt.runJob(ctx, it) + if !strings.Contains(sender.last, "no longer available") { + t.Fatalf("want refusal reply, got %q", sender.last) + } + // The refusal is durable: the item moved past pending without spawning. + if p, _ := gw.Pending(ctx); len(p) != 0 { + t.Errorf("item still pending: %+v", p) + } +} diff --git a/internal/gateway/server/server.go b/internal/gateway/server/server.go index 988b696..7cd5905 100644 --- a/internal/gateway/server/server.go +++ b/internal/gateway/server/server.go @@ -16,6 +16,7 @@ import ( "io" "net/http" "os" + "path/filepath" "strings" "sync" "time" @@ -25,6 +26,11 @@ import ( "github.com/memcode-ai/memcode/internal/agent/permissions" "github.com/memcode-ai/memcode/internal/channels" "github.com/memcode-ai/memcode/internal/channels/discord" + "github.com/memcode-ai/memcode/internal/channels/email" + "github.com/memcode-ai/memcode/internal/channels/matrix" + "github.com/memcode-ai/memcode/internal/channels/mattermost" + "github.com/memcode-ai/memcode/internal/channels/msteams" + signalch "github.com/memcode-ai/memcode/internal/channels/signal" "github.com/memcode-ai/memcode/internal/channels/slack" "github.com/memcode-ai/memcode/internal/channels/telegram" "github.com/memcode-ai/memcode/internal/events" @@ -33,6 +39,8 @@ import ( "github.com/memcode-ai/memcode/internal/jobs" "github.com/memcode-ai/memcode/internal/store" githubtrigger "github.com/memcode-ai/memcode/internal/triggers/github" + "github.com/memcode-ai/memcode/internal/triggers/googlechat" + "github.com/memcode-ai/memcode/internal/triggers/sms" "github.com/memcode-ai/memcode/internal/triggers/whatsapp" ) @@ -63,6 +71,9 @@ type runtime struct { mainStore store.Store // main .memcode event log; may be nil (events best-effort) mu sync.RWMutex settings gwconfig.Settings // guarded by mu; hot-reloaded from gateway.yaml (see maybeReload) + mediaDir string // the media spool (attachments in, synthesized voice out) + stt transcriber // speech-to-text for inbound voice notes; nil = not configured + tts speaker // text-to-speech for voice replies; nil = not configured byName map[string]replySender disp *dispatcher out io.Writer @@ -99,18 +110,27 @@ func Run(ctx context.Context, root string, mainStore store.Store, settings gwcon warnOpenSurfaces(settings, out) + mediaDir, err := gwconfig.MediaDir() + if err != nil { + return err + } + pruneSpool(mediaDir, time.Now().Add(-30*24*time.Hour)) // same retention as the inbox + rt := &runtime{ root: root, gw: gw, mainStore: mainStore, settings: settings, + mediaDir: mediaDir, + stt: newTranscriber(), + tts: newSpeaker(), byName: make(map[string]replySender, 4), disp: newDispatcher(), out: out, notify: make(chan struct{}, 1), } - chs := channelsFrom(settings, gw, out) + chs := channelsFrom(settings, gw, mediaDir, out) for _, ch := range chs { rt.byName[ch.Name()] = ch ch := ch @@ -292,10 +312,14 @@ func (r *runtime) Deliver(ctx context.Context, inb channels.Inbound) error { // Snapshot the conversation's current persona + project at receipt, so a later // /project changes only the NEXT task, never this queued one. agent, project := r.resolveSelection(ctx, inb.Channel, inb.Conversation) + ids := make([]string, 0, len(inb.Attachments)) + for _, a := range inb.Attachments { + ids = append(ids, a.ID()) // spool IDs only — paths never enter the durable row + } fresh, err := r.gw.Accept(ctx, state.Item{ Channel: inb.Channel, MessageID: inb.MessageID, Conversation: inb.Conversation, Principal: inb.Principal, Text: inb.Text, Trusted: inb.Trusted, - Agent: agent, Project: project, + Agent: agent, Project: project, Attachments: ids, }, time.Now()) if err != nil { return err // NOT durably recorded — adapter must not ack @@ -395,45 +419,48 @@ func (r *runtime) runJob(ctx context.Context, it state.Item) { cfg := settings.Get(it.Channel) session := conversationSession(it.Channel, it.Conversation, it.Agent) // Resolve the snapshotted project id to its canonical root. The registry plus - // the channel's project policy is the authorization boundary: an id that no - // longer resolves — or that the channel is no longer allowed to use — falls - // back to the gateway default rather than executing somewhere unauthorized. + // the channel's project policy is the authorization boundary, re-checked at + // EXECUTION, not only at snapshot: a task whose project was disallowed, + // disabled, or deleted while queued is REFUSED — never "helpfully" run in the + // gateway default root instead. root := r.root if it.Project != "" { - // The channel's project policy is re-checked at execution, not only at - // snapshot: if it tightened while this task was queued, refuse — never - // "helpfully" run the task somewhere the channel wasn't pointed. if !settings.ProjectAllowed(it.Channel, it.Project) { - msg := fmt.Sprintf("Project %q is not allowed on this channel anymore; nothing was run.", it.Project) - if serr := r.gw.SetReplied(ctx, it.Channel, it.MessageID, msg); serr != nil { - fmt.Fprintf(r.out, "gateway: recording policy refusal for %s: %v\n", it.Channel, serr) - return - } - r.deliverReply(ctx, it, msg) + r.refuse(ctx, it, fmt.Sprintf("Project %q is not allowed on this channel anymore; nothing was run.", it.Project)) return } - if resolved, rerr := settings.ResolveProject(it.Project); rerr == nil { - root = resolved - } else { - fmt.Fprintf(r.out, "gateway: project %q for %s no longer resolves (%v); using default\n", it.Project, it.Channel, rerr) + resolved, rerr := settings.ResolveProject(it.Project) + if rerr != nil { + r.refuse(ctx, it, fmt.Sprintf("Project %q is no longer available (%v); nothing was run.", it.Project, rerr)) + return } + root = resolved + } + // Voice notes are transcribed HERE — after the durable record, before the + // spawn — so the transcript becomes task text and audio never reaches the + // engine. A voice note that can't be transcribed gets an honest reply, and a + // message that was ONLY untranscribable audio is refused rather than run as + // an empty task. + task, rest, sttMissing := r.transcribeAudio(ctx, it.Text, it.Attachments) + if strings.TrimSpace(task) == "" && sttMissing { + r.refuse(ctx, it, "Voice note received, but no transcription provider is configured. Set OPENAI_API_KEY or GEMINI_API_KEY on the gateway machine, or send text.") + return } - // Compose the snapshotted persona's context + skill roots and persist it keyed - // by session; the spawned child self-discovers it (no jobs.Spawn signature - // change). No persona → empty envelope → the coding engine runs exactly as the CLI. - if err := writeContext(session, jobContextFor(it.Agent)); err != nil { + it.Text = task + // Compose the snapshotted persona's context + skill roots + this message's + // media (as spool IDs) and persist it keyed by session; the spawned child + // self-discovers it (no jobs.Spawn signature change). No persona and no media + // → empty envelope → the coding engine runs exactly as the CLI. + jc := jobContextFor(it.Agent) + jc.Attachments = rest + if err := writeContext(session, jc); err != nil { fmt.Fprintf(r.out, "gateway: composing context for %s: %v\n", it.Channel, err) } job, err := jobs.Spawn(root, it.Text, string(permissions.ModeAuto), cfg.Tier, false, true, session) if err != nil { // A spawn failure won't succeed on replay; record the error as the reply so // it rides the same durable delivery path instead of being lost. - msg := "Couldn't start that: " + err.Error() - if serr := r.gw.SetReplied(ctx, it.Channel, it.MessageID, msg); serr != nil { - fmt.Fprintf(r.out, "gateway: recording spawn failure for %s: %v\n", it.Channel, serr) - return - } - r.deliverReply(ctx, it, msg) + r.refuse(ctx, it, "Couldn't start that: "+err.Error()) return } r.event(ctx, events.KindGatewayJobSpawned, eventPayload{Channel: it.Channel, Conversation: it.Conversation, PrincipalID: it.Principal, MessageID: it.MessageID, JobID: job.ID}) @@ -443,16 +470,33 @@ func (r *runtime) runJob(ctx context.Context, it state.Item) { if strings.TrimSpace(reply) == "" { reply = "Done." } + // Synthesize any voice rendition ONCE, here — before the durable handoff — so + // a delivery retry or a restart re-sends the same spool file instead of + // re-billing TTS or losing the in_kind decision (the pending-replies replay + // carries the voice spool ID, not the attachment list). + voice := r.maybeSpeak(ctx, it, reply) // Durable handoff: the job is finished and must never re-run, even if delivery // below fails or the process crashes. From here the reply is the worker's to // deliver. A rare DB write failure leaves the item pending and re-runs it. - if err := r.gw.SetReplied(ctx, it.Channel, it.MessageID, reply); err != nil { + if err := r.gw.SetReplied(ctx, it.Channel, it.MessageID, reply, voice); err != nil { fmt.Fprintf(r.out, "gateway: recording reply for %s failed: %v\n", it.Channel, err) return } + it.Voice = voice r.deliverReply(ctx, it, reply) } +// refuse records msg as the task's durable reply (no job runs, no voice is +// synthesized) and delivers it — the one shape every policy/config refusal uses. +func (r *runtime) refuse(ctx context.Context, it state.Item, msg string) { + it.Voice = "" + if serr := r.gw.SetReplied(ctx, it.Channel, it.MessageID, msg, ""); serr != nil { + fmt.Fprintf(r.out, "gateway: recording refusal for %s: %v\n", it.Channel, serr) + return + } + r.deliverReply(ctx, it, msg) +} + // deliverReply sends a finished job's reply and, on success, marks the item done. // A transient send failure is retried in-process a few times; if it still fails // the item stays 'replied' and the worker retries it on a later tick and after a @@ -466,6 +510,14 @@ func (r *runtime) deliverReply(ctx context.Context, it state.Item, reply string) if strings.TrimSpace(reply) == "" { reply = "Done." } + out := channels.Outbound{Text: reply} + // The voice rendition was synthesized once at job completion; delivery only + // resolves its spool ID (missing/pruned file → text only, never an error). + if it.Voice != "" { + if p, err := channels.ResolveSpoolID(r.mediaDir, it.Voice); err == nil { + out.VoicePath = p + } + } var sendErr error for attempt := 0; attempt < 3; attempt++ { if attempt > 0 { @@ -475,7 +527,7 @@ func (r *runtime) deliverReply(ctx context.Context, it state.Item, reply string) case <-time.After(time.Duration(attempt) * 500 * time.Millisecond): } } - if sendErr = ch.Send(ctx, it.Conversation, channels.Outbound{Text: reply}); sendErr == nil { + if sendErr = ch.Send(ctx, it.Conversation, out); sendErr == nil { break } } @@ -498,13 +550,13 @@ func (r *runtime) event(ctx context.Context, kind events.Kind, p eventPayload) { // channelsFrom builds a live channel for each one whose secret is present in the // environment. A channel whose constructor fails is logged and skipped. -func channelsFrom(settings gwconfig.Settings, gw *state.Store, out io.Writer) []channels.Channel { +func channelsFrom(settings gwconfig.Settings, gw *state.Store, mediaDir string, out io.Writer) []channels.Channel { var chs []channels.Channel if tok := strings.TrimSpace(os.Getenv(gwconfig.EnvTelegramToken)); tok != "" { - chs = append(chs, telegram.New(tok, gw)) + chs = append(chs, telegram.New(tok, gw, mediaDir)) } if tok := strings.TrimSpace(os.Getenv(gwconfig.EnvDiscordToken)); tok != "" { - if ch, err := discord.New(tok); err != nil { + if ch, err := discord.New(tok, mediaDir); err != nil { fmt.Fprintf(out, "gateway: discord disabled: %v\n", err) } else { chs = append(chs, ch) @@ -515,9 +567,48 @@ func channelsFrom(settings gwconfig.Settings, gw *state.Store, out io.Writer) [] if app != "" && bot != "" { chs = append(chs, slack.New(app, bot)) } + addr := strings.TrimSpace(os.Getenv(gwconfig.EnvEmailAddress)) + pass := os.Getenv(gwconfig.EnvEmailPassword) + imapHost := strings.TrimSpace(os.Getenv(gwconfig.EnvEmailIMAPHost)) + smtpHost := strings.TrimSpace(os.Getenv(gwconfig.EnvEmailSMTPHost)) + if addr != "" && pass != "" && imapHost != "" && smtpHost != "" { + var poll time.Duration + if p := strings.TrimSpace(settings.Get("email").Poll); p != "" { + if d, err := time.ParseDuration(p); err == nil && d > 0 { + poll = d + } else { + fmt.Fprintf(out, "gateway: email.poll %q is not a duration; using default\n", p) + } + } + chs = append(chs, email.New(addr, pass, imapHost, smtpHost, poll, mediaDir)) + } + if number := strings.TrimSpace(os.Getenv(gwconfig.EnvSignalNumber)); number != "" { + attDir := defaultSignalAttachments() + chs = append(chs, signalch.New(strings.TrimSpace(os.Getenv(gwconfig.EnvSignalCLIURL)), number, attDir, mediaDir)) + } + if hs := strings.TrimSpace(os.Getenv(gwconfig.EnvMatrixHomeserver)); hs != "" { + if tok := strings.TrimSpace(os.Getenv(gwconfig.EnvMatrixToken)); tok != "" { + chs = append(chs, matrix.New(hs, tok, gw, mediaDir)) + } + } + if mmURL := strings.TrimSpace(os.Getenv(gwconfig.EnvMattermostURL)); mmURL != "" { + if tok := strings.TrimSpace(os.Getenv(gwconfig.EnvMattermostToken)); tok != "" { + chs = append(chs, mattermost.New(mmURL, tok, mediaDir)) + } + } return chs } +// defaultSignalAttachments is where signal-cli keeps received attachments on +// this machine (the daemon and the gateway share a host by design). +func defaultSignalAttachments() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".local", "share", "signal-cli", "attachments") +} + // startWebhooks mounts each configured inbound trigger on an HTTP server and // starts it, returning whether any were mounted. rt is the sink each trigger // delivers into; WhatsApp also registers its Send in byName so replies route back. @@ -539,7 +630,7 @@ func startWebhooks(ctx context.Context, settings gwconfig.Settings, rt *runtime, case appSecret == "": fmt.Fprintf(out, "gateway: whatsapp inactive: set %s (Meta app secret) to verify inbound messages\n", gwconfig.EnvWhatsAppSecret) default: - wc := whatsapp.New(pn, token, verify, appSecret) + wc := whatsapp.New(pn, token, verify, appSecret, rt.mediaDir) rt.byName[wc.Name()] = wc mux.Handle("/webhook/whatsapp", wc.Handler(rt)) fmt.Fprintf(out, "gateway: whatsapp webhook on /webhook/whatsapp\n") @@ -547,6 +638,52 @@ func startWebhooks(ctx context.Context, settings gwconfig.Settings, rt *runtime, } } + // Microsoft Teams (Bot Framework): webhook in, serviceUrl replies out. + tAppID := strings.TrimSpace(os.Getenv(gwconfig.EnvTeamsAppID)) + tAppPw := strings.TrimSpace(os.Getenv(gwconfig.EnvTeamsAppPassword)) + tTenant := strings.TrimSpace(os.Getenv(gwconfig.EnvTeamsTenantID)) + if tAppID != "" && tAppPw != "" && tTenant != "" { + ms := msteams.New(tAppID, tAppPw, tTenant, rt.mediaDir) + rt.byName[ms.Name()] = ms + mux.Handle("/webhook/teams", ms.Handler(rt)) + fmt.Fprintf(out, "gateway: msteams webhook on POST /webhook/teams\n") + mounted = true + } + + // Google Chat: webhook in (Google-signed JWT), Chat REST out (service account). + if keyPath := strings.TrimSpace(os.Getenv(gwconfig.EnvGoogleChatSAKey)); keyPath != "" { + switch key, err := os.ReadFile(keyPath); { + case err != nil: + fmt.Fprintf(out, "gateway: googlechat disabled: reading %s: %v\n", keyPath, err) + case strings.TrimSpace(settings.Get("googlechat").Audience) == "": + fmt.Fprintf(out, "gateway: googlechat disabled: set channels.googlechat.audience (the app's project number)\n") + default: + gc := googlechat.New(key, strings.TrimSpace(settings.Get("googlechat").Audience), rt.mediaDir) + rt.byName[gc.Name()] = gc + mux.Handle("/webhook/googlechat", gc.Handler(rt)) + fmt.Fprintf(out, "gateway: googlechat webhook on POST /webhook/googlechat\n") + mounted = true + } + } + + // SMS (Twilio) rides the same mux; it is also a valid github.reply_to target, + // so it registers in byName before GitHub validates its route. + tsid := strings.TrimSpace(os.Getenv(gwconfig.EnvTwilioAccountSID)) + ttok := strings.TrimSpace(os.Getenv(gwconfig.EnvTwilioAuthToken)) + tfrom := strings.TrimSpace(os.Getenv(gwconfig.EnvTwilioFromNumber)) + if tsid != "" && ttok != "" && tfrom != "" { + hook := strings.TrimSpace(settings.Get("sms").WebhookURL) + if hook == "" { + fmt.Fprintf(out, "gateway: sms inactive: set channels.sms.webhook_url (the exact public URL) so inbound signatures can be verified\n") + } else { + sc := sms.New(tsid, ttok, tfrom, hook, rt.mediaDir) + rt.byName[sc.Name()] = sc + mux.Handle("/webhook/sms", sc.Handler(rt)) + fmt.Fprintf(out, "gateway: sms webhook on POST /webhook/sms\n") + mounted = true + } + } + if secret := strings.TrimSpace(os.Getenv(gwconfig.EnvGitHubSecret)); secret != "" { replyCh, _, ok := parseRoute(settings.Get("github").ReplyTo) switch { @@ -572,7 +709,15 @@ func startWebhooks(ctx context.Context, settings gwconfig.Settings, rt *runtime, if addr == "" { addr = defaultWebhookAddr } - srv := &http.Server{Addr: addr, Handler: mux} + srv := &http.Server{ + Addr: addr, + Handler: mux, + // Bound a slow client: without a read-header deadline a slowloris + // connection can hold a handler open indefinitely. + ReadHeaderTimeout: 15 * time.Second, + ReadTimeout: 60 * time.Second, + IdleTimeout: 120 * time.Second, + } go func() { <-ctx.Done() shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) @@ -601,13 +746,24 @@ func parseRoute(replyTo string) (channel, conversation string, ok bool) { // waitForJob polls until the job leaves the running state and returns text to post // back: the agent's result on success, or a pointer to the log on failure. +// maxJobWait backstops a hung agent child: the per-conversation dispatcher is +// serial, so a job that never finishes would block that conversation's queue +// forever. After this the gateway stops waiting and reports back (the child is +// still reaped by its own process lifecycle); it's generous enough that no real +// task hits it. +const maxJobWait = 30 * time.Minute + func waitForJob(ctx context.Context, root, id string) string { tick := time.NewTicker(2 * time.Second) defer tick.Stop() + deadline := time.NewTimer(maxJobWait) + defer deadline.Stop() for { select { case <-ctx.Done(): return "Interrupted before it finished." + case <-deadline.C: + return fmt.Sprintf("That task is still running after %s — I stopped waiting. Details in .memcode/jobs/%s/log", maxJobWait, id) case <-tick.C: j, err := jobs.Get(root, id) if err != nil { diff --git a/internal/gateway/state/state.go b/internal/gateway/state/state.go index 01f890e..21e0fa0 100644 --- a/internal/gateway/state/state.go +++ b/internal/gateway/state/state.go @@ -13,6 +13,7 @@ package state import ( "context" "database/sql" + "encoding/json" "fmt" "os" "path/filepath" @@ -34,6 +35,8 @@ CREATE TABLE IF NOT EXISTS inbox ( reply TEXT NOT NULL DEFAULT '', -- the job's result, held durably until delivered agent TEXT NOT NULL DEFAULT '', -- persona snapshot at receipt (immutable for this task) project TEXT NOT NULL DEFAULT '', -- project id snapshot at receipt (immutable for this task) + attachments TEXT NOT NULL DEFAULT '', -- JSON array of media spool IDs riding this message + voice TEXT NOT NULL DEFAULT '', -- spool ID of the synthesized voice reply (synthesized ONCE, at job completion) received_at TEXT NOT NULL, PRIMARY KEY (channel, message_id) ); @@ -44,6 +47,14 @@ CREATE TABLE IF NOT EXISTS poll_offsets ( offset_val INTEGER NOT NULL ); +-- String ack cursors for channels whose resume token isn't an integer +-- (Matrix /sync since-token). Same contract as poll_offsets: advanced only +-- after a durable Deliver, so a restart resumes instead of replaying. +CREATE TABLE IF NOT EXISTS cursors ( + channel TEXT PRIMARY KEY, + cursor TEXT NOT NULL +); + -- Durable per-conversation selection: which persona and project this -- conversation is currently pointed at. /agent and /project update these; a task -- snapshots them at receipt, so changing them affects only subsequent tasks. @@ -98,8 +109,10 @@ type Item struct { Text string Trusted bool Reply string - Agent string // persona snapshot at receipt - Project string // project id snapshot at receipt + Agent string // persona snapshot at receipt + Project string // project id snapshot at receipt + Attachments []string // media spool IDs (bare filenames; resolved only inside the spool) + Voice string // spool ID of the synthesized voice reply ("" = text only) } // Store is the gateway's durable state. @@ -150,6 +163,8 @@ func Open(ctx context.Context, dir string) (*Store, error) { `ALTER TABLE inbox ADD COLUMN reply TEXT NOT NULL DEFAULT ''`, `ALTER TABLE inbox ADD COLUMN agent TEXT NOT NULL DEFAULT ''`, `ALTER TABLE inbox ADD COLUMN project TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE inbox ADD COLUMN attachments TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE inbox ADD COLUMN voice TEXT NOT NULL DEFAULT ''`, } { if _, err := db.ExecContext(ctx, col); err != nil && !strings.Contains(err.Error(), "duplicate column") { _ = db.Close() @@ -206,10 +221,10 @@ func (s *Store) Close() error { func (s *Store) Accept(ctx context.Context, it Item, now time.Time) (bool, error) { res, err := s.db.ExecContext(ctx, `INSERT OR IGNORE INTO inbox - (channel, message_id, conversation, principal, text, trusted, status, agent, project, received_at) - VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?)`, + (channel, message_id, conversation, principal, text, trusted, status, agent, project, attachments, received_at) + VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?)`, it.Channel, it.MessageID, it.Conversation, it.Principal, it.Text, boolInt(it.Trusted), - it.Agent, it.Project, now.UTC().Format(time.RFC3339Nano), + it.Agent, it.Project, encodeIDs(it.Attachments), now.UTC().Format(time.RFC3339Nano), ) if err != nil { return false, fmt.Errorf("accept inbound: %w", err) @@ -225,8 +240,8 @@ func (s *Store) Accept(ctx context.Context, it Item, now time.Time) (bool, error // worker and, on startup, to replay anything a prior crash left unprocessed. func (s *Store) Pending(ctx context.Context) ([]Item, error) { rows, err := s.db.QueryContext(ctx, - `SELECT channel, message_id, conversation, principal, text, trusted, agent, project - FROM inbox WHERE status = 'pending' ORDER BY received_at`) + `SELECT channel, message_id, conversation, principal, text, trusted, agent, project, attachments + FROM inbox WHERE status = 'pending' ORDER BY received_at LIMIT 500`) if err != nil { return nil, fmt.Errorf("pending inbox: %w", err) } @@ -235,23 +250,27 @@ func (s *Store) Pending(ctx context.Context) ([]Item, error) { for rows.Next() { var it Item var trusted int - if err := rows.Scan(&it.Channel, &it.MessageID, &it.Conversation, &it.Principal, &it.Text, &trusted, &it.Agent, &it.Project); err != nil { + var atts string + if err := rows.Scan(&it.Channel, &it.MessageID, &it.Conversation, &it.Principal, &it.Text, &trusted, &it.Agent, &it.Project, &atts); err != nil { return nil, err } it.Trusted = trusted != 0 + it.Attachments = decodeIDs(atts) out = append(out, it) } return out, rows.Err() } -// SetReplied durably records a finished job's reply and moves the item to -// 'replied'. From here the job is never re-run; only the reply's delivery is -// retried, so a send failure or a crash after the job completes cannot lose the -// result or repeat the work. -func (s *Store) SetReplied(ctx context.Context, channel, messageID, reply string) error { +// SetReplied durably records a finished job's reply — and, when one was +// synthesized, the spool ID of its voice rendition — and moves the item to +// 'replied'. From here the job is never re-run and the voice is never +// re-synthesized; only the reply's delivery is retried, so a send failure, a +// crash, or a down channel cannot lose the result, repeat the work, or bill +// TTS twice. +func (s *Store) SetReplied(ctx context.Context, channel, messageID, reply, voice string) error { _, err := s.db.ExecContext(ctx, - `UPDATE inbox SET status = 'replied', reply = ? WHERE channel = ? AND message_id = ?`, - reply, channel, messageID) + `UPDATE inbox SET status = 'replied', reply = ?, voice = ? WHERE channel = ? AND message_id = ?`, + reply, voice, channel, messageID) return err } @@ -260,8 +279,8 @@ func (s *Store) SetReplied(ctx context.Context, channel, messageID, reply string // and replayed after a restart. func (s *Store) PendingReplies(ctx context.Context) ([]Item, error) { rows, err := s.db.QueryContext(ctx, - `SELECT channel, message_id, conversation, principal, text, trusted, reply - FROM inbox WHERE status = 'replied' ORDER BY received_at`) + `SELECT channel, message_id, conversation, principal, text, trusted, reply, voice + FROM inbox WHERE status = 'replied' ORDER BY received_at LIMIT 500`) if err != nil { return nil, fmt.Errorf("pending replies: %w", err) } @@ -270,7 +289,7 @@ func (s *Store) PendingReplies(ctx context.Context) ([]Item, error) { for rows.Next() { var it Item var trusted int - if err := rows.Scan(&it.Channel, &it.MessageID, &it.Conversation, &it.Principal, &it.Text, &trusted, &it.Reply); err != nil { + if err := rows.Scan(&it.Channel, &it.MessageID, &it.Conversation, &it.Principal, &it.Text, &trusted, &it.Reply, &it.Voice); err != nil { return nil, err } it.Trusted = trusted != 0 @@ -321,6 +340,31 @@ func (s *Store) SetOffset(ctx context.Context, channel string, offset int64) err return nil } +// Cursor returns the persisted string ack cursor for a channel ("" if none). +func (s *Store) Cursor(ctx context.Context, channel string) (string, error) { + var v string + err := s.db.QueryRowContext(ctx, `SELECT cursor FROM cursors WHERE channel = ?`, channel).Scan(&v) + if err == sql.ErrNoRows { + return "", nil + } + if err != nil { + return "", fmt.Errorf("read cursor: %w", err) + } + return v, nil +} + +// SetCursor durably records a channel's string ack cursor. +func (s *Store) SetCursor(ctx context.Context, channel, cursor string) error { + _, err := s.db.ExecContext(ctx, + `INSERT INTO cursors (channel, cursor) VALUES (?, ?) + ON CONFLICT(channel) DO UPDATE SET cursor = excluded.cursor`, + channel, cursor) + if err != nil { + return fmt.Errorf("set cursor: %w", err) + } + return nil +} + // Conversation returns the persona and project this conversation currently // points at (empty when unset — the caller applies channel/gateway defaults). func (s *Store) Conversation(ctx context.Context, channel, conversation string) (agent, project string, err error) { @@ -437,6 +481,31 @@ func (s *Store) TakePairing(ctx context.Context, code string, now time.Time) (Pa return p, nil } +// encodeIDs/decodeIDs carry the media spool IDs through the inbox row as JSON. +// IDs are bare spool filenames — never paths; the consumer resolves them only +// inside the spool directory. +func encodeIDs(ids []string) string { + if len(ids) == 0 { + return "" + } + b, err := json.Marshal(ids) + if err != nil { + return "" + } + return string(b) +} + +func decodeIDs(s string) []string { + if strings.TrimSpace(s) == "" { + return nil + } + var ids []string + if json.Unmarshal([]byte(s), &ids) != nil { + return nil + } + return ids +} + func boolInt(b bool) int { if b { return 1 diff --git a/internal/gateway/state/state_test.go b/internal/gateway/state/state_test.go index b82c426..4c7af01 100644 --- a/internal/gateway/state/state_test.go +++ b/internal/gateway/state/state_test.go @@ -104,7 +104,7 @@ func TestReplyQueueDurability(t *testing.T) { // Job finished: pending → replied, reply held. It leaves the fresh-job queue // but joins the outbound queue, so a delivery failure never re-runs the job. - if err := s.SetReplied(ctx, "telegram", "1", "the answer"); err != nil { + if err := s.SetReplied(ctx, "telegram", "1", "the answer", ""); err != nil { t.Fatal(err) } if p, _ := s.Pending(ctx); len(p) != 0 { @@ -135,7 +135,7 @@ func TestReplySurvivesReopen(t *testing.T) { t.Fatal(err) } s.Accept(ctx, item("telegram", "1"), time.Unix(1000, 0)) - s.SetReplied(ctx, "telegram", "1", "durable answer") + s.SetReplied(ctx, "telegram", "1", "durable answer", "vv.ogg") s.Close() // simulate a crash before the reply was delivered s2, err := Open(ctx, dir) @@ -234,3 +234,25 @@ func TestOffsetRoundTrip(t *testing.T) { t.Errorf("offset after upsert = %d, want 99999", v) } } + +// Attachments (media spool IDs) ride the durable inbox row as JSON. +func TestItemAttachmentsRoundTrip(t *testing.T) { + ctx := context.Background() + gw, err := Open(ctx, t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer gw.Close() + it := Item{Channel: "telegram", MessageID: "m1", Conversation: "c", Principal: "p", Text: "look", + Attachments: []string{"abc.png", "def.ogg"}} + if _, err := gw.Accept(ctx, it, time.Now()); err != nil { + t.Fatal(err) + } + got, err := gw.Pending(ctx) + if err != nil || len(got) != 1 { + t.Fatalf("pending: %v %d", err, len(got)) + } + if len(got[0].Attachments) != 2 || got[0].Attachments[0] != "abc.png" || got[0].Attachments[1] != "def.ogg" { + t.Errorf("attachments = %+v", got[0].Attachments) + } +} diff --git a/internal/guard/guard_test.go b/internal/guard/guard_test.go index 7aa6ac2..d4c0c21 100644 --- a/internal/guard/guard_test.go +++ b/internal/guard/guard_test.go @@ -86,8 +86,12 @@ var vendorSDKs = map[string]string{ "github.com/openai/openai-go": modulePrefix + "/internal/providers/openai", "github.com/anthropics/anthropic-sdk-go": modulePrefix + "/internal/providers/anthropic", "google.golang.org/genai": modulePrefix + "/internal/providers/gemini", + "github.com/emersion/go-imap": modulePrefix + "/internal/channels/email", "github.com/bwmarrin/discordgo": modulePrefix + "/internal/channels/discord", "github.com/slack-go/slack": modulePrefix + "/internal/channels/slack", + "github.com/golang-jwt/jwt": modulePrefix + "/internal/webjwt", + "github.com/gorilla/websocket": modulePrefix + "/internal/channels/mattermost", + "golang.org/x/oauth2": modulePrefix + "/internal/triggers/googlechat", } func directImports(t *testing.T, pkg string) []string { diff --git a/internal/providers/gemini/audio.go b/internal/providers/gemini/audio.go new file mode 100644 index 0000000..cfd3db2 --- /dev/null +++ b/internal/providers/gemini/audio.go @@ -0,0 +1,48 @@ +package gemini + +import ( + "context" + "fmt" + "os" + "strings" + + "google.golang.org/genai" +) + +// transcribeAudioModel is the cheap multimodal tier — audio-in text-out is a +// plain generate call on Gemini, no dedicated speech endpoint needed. +const transcribeAudioModel = "gemini-2.5-flash" + +// Transcribe converts an audio file to text by sending it inline to a +// multimodal generate call. Exported from the provider home so the genai SDK +// stays contained here; the gateway calls it for inbound voice notes when a +// Gemini key is the available credential. +func (g *Gemini) Transcribe(ctx context.Context, path, mime string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + if mime == "" { + mime = "audio/ogg" + } + client, err := g.client(ctx) + if err != nil { + return "", err + } + contents := []*genai.Content{{ + Role: "user", + Parts: []*genai.Part{ + {Text: "Transcribe this audio verbatim. Output ONLY the transcript text, nothing else."}, + {InlineData: &genai.Blob{MIMEType: mime, Data: data}}, + }, + }} + resp, err := client.Models.GenerateContent(ctx, transcribeAudioModel, contents, nil) + if err != nil { + return "", fmt.Errorf("gemini transcription: %w", err) + } + text := strings.TrimSpace(resp.Text()) + if text == "" { + return "", fmt.Errorf("gemini transcription: empty text") + } + return text, nil +} diff --git a/internal/providers/openai/audio.go b/internal/providers/openai/audio.go new file mode 100644 index 0000000..4ebd67f --- /dev/null +++ b/internal/providers/openai/audio.go @@ -0,0 +1,82 @@ +package openai + +import ( + "context" + "fmt" + "io" + "os" + "strings" + + oai "github.com/openai/openai-go/v3" +) + +// Audio models: cheap-and-fast defaults with the open-source Whisper as the +// fallback for accounts that haven't enabled the 4o audio models. +const ( + transcribeModel = "gpt-4o-mini-transcribe" + transcribeFallbackModel = "whisper-1" + speechModel = "gpt-4o-mini-tts" + speechVoice = "alloy" +) + +// Transcribe converts an audio file (ogg/mp3/m4a/wav/…) to text via the audio +// transcriptions endpoint. Exported from the provider home so the OpenAI SDK +// stays contained here (TestVendorSDKsOnlyInTheirAdapters); the gateway calls +// it for inbound voice notes. Tries the 4o mini transcribe model first and +// falls back to whisper-1 once on any API error. +func (o *OpenAI) Transcribe(ctx context.Context, path, mime string) (string, error) { + text, err := o.transcribeWith(ctx, path, transcribeModel) + if err == nil { + return text, nil + } + if text, ferr := o.transcribeWith(ctx, path, transcribeFallbackModel); ferr == nil { + return text, nil + } + return "", err +} + +func (o *OpenAI) transcribeWith(ctx context.Context, path, model string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + client := o.client() + resp, err := client.Audio.Transcriptions.New(ctx, oai.AudioTranscriptionNewParams{ + File: f, + Model: oai.AudioModel(model), + }) + if err != nil { + return "", fmt.Errorf("openai transcription (%s): %w", model, err) + } + text := strings.TrimSpace(resp.Text) + if text == "" { + return "", fmt.Errorf("openai transcription (%s): empty text", model) + } + return text, nil +} + +// Speak synthesizes speech for text and returns OGG/Opus bytes — the container +// chat platforms accept as a voice note directly, so no transcoding step (and +// no ffmpeg) is needed anywhere downstream. +func (o *OpenAI) Speak(ctx context.Context, text string) ([]byte, error) { + client := o.client() + resp, err := client.Audio.Speech.New(ctx, oai.AudioSpeechNewParams{ + Input: text, + Model: speechModel, + Voice: oai.AudioSpeechNewParamsVoiceUnion{OfString: oai.String(speechVoice)}, + ResponseFormat: oai.AudioSpeechNewParamsResponseFormatOpus, + }) + if err != nil { + return nil, fmt.Errorf("openai speech: %w", err) + } + defer resp.Body.Close() + data, err := io.ReadAll(io.LimitReader(resp.Body, 25<<20)) + if err != nil { + return nil, err + } + if len(data) == 0 { + return nil, fmt.Errorf("openai speech: empty audio") + } + return data, nil +} diff --git a/internal/triggers/googlechat/googlechat.go b/internal/triggers/googlechat/googlechat.go new file mode 100644 index 0000000..166df88 --- /dev/null +++ b/internal/triggers/googlechat/googlechat.go @@ -0,0 +1,314 @@ +// Package googlechat is the gateway's Google Chat adapter. A Chat app is +// configured in the Google Cloud console with an HTTP endpoint URL; inbound +// events arrive as POSTs carrying a Google-signed bearer JWT (issuer +// chat@system.gserviceaccount.com) whose audience is the app's project number, +// and outbound replies go to the Chat REST API (spaces.messages.create) +// authenticated as a service account. Credentials: GOOGLE_CHAT_SA_KEY holds the +// path to the service-account JSON key and googlechat.audience (the project +// number) lives in gateway.yaml; the caller reads the key file and hands the +// bytes to New — this package never reads the environment. +package googlechat + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" + + "github.com/memcode-ai/memcode/internal/channels" + "github.com/memcode-ai/memcode/internal/webjwt" +) + +const defaultAPIBase = "https://chat.googleapis.com" + +// defaultJWKSURL serves the public certs Google signs inbound event JWTs with. +const defaultJWKSURL = "https://www.googleapis.com/oauth2/v3/certs" + +// chatIssuer is the only issuer Google Chat signs event tokens as. +const chatIssuer = "chat@system.gserviceaccount.com" + +// chatScope is the two-legged OAuth scope for a Chat app acting as itself. +const chatScope = "https://www.googleapis.com/auth/chat.bot" + +const maxBody = 2 << 20 // 2 MiB + +// chatMaxMessage is our outbound chunk size; Chat rejects text past 4000 +// characters, so we stay under with headroom for the JSON envelope. +const chatMaxMessage = 3900 + +// Channel is a Google Chat app connection: webhook in, REST out. +type Channel struct { + saKeyJSON []byte + audience string // the app's project number; inbound JWT aud must match + mediaDir string // media spool; "" disables inbound attachment downloads + client *http.Client + dl *http.Client // SSRF-guarded client for attachment downloads + + apiBase string // Chat REST base; overridable in tests + verify *webjwt.Verifier // the shared inbound-JWT verifier; tests point its JWKSURL at a fake + + // tokenSource authenticates outbound REST calls (and attachment downloads). + // Defaulted lazily from the service-account key; tests inject a + // oauth2.StaticTokenSource instead. + tokenSource oauth2.TokenSource + tsMu sync.Mutex +} + +// New builds a Google Chat channel from the service-account key JSON, the +// expected JWT audience (the app's project number), and the gateway media +// spool directory ("" disables attachment downloads). +func New(saKeyJSON []byte, audience, mediaDir string) *Channel { + client := &http.Client{Timeout: 30 * time.Second} + return &Channel{ + saKeyJSON: saKeyJSON, + audience: audience, + mediaDir: mediaDir, + client: client, + dl: channels.SafeHTTPClient(30 * time.Second), // attachment fetches: SSRF-guarded + apiBase: defaultAPIBase, + verify: &webjwt.Verifier{ + JWKSURL: defaultJWKSURL, + Issuer: chatIssuer, + Audience: audience, + Client: client, + }, + } +} + +// Name returns the adapter identifier. +func (c *Channel) Name() string { return "googlechat" } + +// Handler returns the webhook HTTP handler. Chat expects a synchronous JSON +// response; we always reply asynchronously through the REST API, so a handled +// event returns 200 with body {} (an empty object means "no synchronous +// message"). A Deliver failure returns 503 so Google redelivers. +func (c *Channel) Handler(sink channels.Sink) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + // Every inbound POST must carry Google's signed bearer JWT; without a + // valid one we cannot tell Google from an internet stranger, so nothing + // is parsed, let alone delivered. + token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") + if !ok || c.verify.Verify(r.Context(), token) != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + body, err := io.ReadAll(io.LimitReader(r.Body, maxBody)) + if err != nil { + http.Error(w, "read error", http.StatusBadRequest) + return + } + inb, deliver := c.toInbound(r.Context(), body) + if !deliver { + w.WriteHeader(http.StatusOK) // non-MESSAGE event (or bot echo): ack, nothing to run + return + } + if err := sink.Deliver(r.Context(), inb); err != nil { + http.Error(w, "not recorded", http.StatusServiceUnavailable) // Google retries + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, "{}") + }) +} + +// event is the subset of a Google Chat event payload we read. +type event struct { + Type string `json:"type"` + Message struct { + Name string `json:"name"` + Text string `json:"text"` + ArgumentText string `json:"argumentText"` + Sender struct { + Name string `json:"name"` // users/ — stable principal + Type string `json:"type"` // "HUMAN" | "BOT" + } `json:"sender"` + Annotations []struct { + Type string `json:"type"` + } `json:"annotations"` + Attachment []chatAttachment `json:"attachment"` + } `json:"message"` + Space struct { + Name string `json:"name"` // spaces/ + SpaceType string `json:"spaceType"` // "DIRECT_MESSAGE" | "SPACE" (newer field) + Type string `json:"type"` // "DM" | "ROOM" (legacy field) + } `json:"space"` +} + +type chatAttachment struct { + ContentName string `json:"contentName"` + ContentType string `json:"contentType"` + DownloadURI string `json:"downloadUri"` +} + +// toInbound maps a MESSAGE event to a normalized Inbound. Every other event +// type (ADDED_TO_SPACE, CARD_CLICKED, …) and messages from other bots are +// acknowledged without delivery. +func (c *Channel) toInbound(ctx context.Context, body []byte) (channels.Inbound, bool) { + var ev event + if err := json.Unmarshal(body, &ev); err != nil || ev.Type != "MESSAGE" { + return channels.Inbound{}, false + } + if ev.Message.Sender.Type == "BOT" || ev.Message.Sender.Name == "" { + return channels.Inbound{}, false // never let bots (ourselves included) trigger turns + } + // argumentText is the message with the app's @mention stripped — the actual + // task — so prefer it when present. + text := ev.Message.ArgumentText + if text == "" { + text = ev.Message.Text + } + isDirect := ev.Space.SpaceType == "DIRECT_MESSAGE" || ev.Space.Type == "DM" || + ev.Space.SpaceType == "DM" || ev.Space.Type == "DIRECT_MESSAGE" + // In a space, a Chat app only receives messages it was @mentioned in, so + // Mentioned is effectively always true there; we still detect it + // structurally from the USER_MENTION annotation rather than assuming. + mentioned := false + for _, a := range ev.Message.Annotations { + if a.Type == "USER_MENTION" { + mentioned = true + break + } + } + return channels.Inbound{ + Channel: "googlechat", + Conversation: ev.Space.Name, + Principal: ev.Message.Sender.Name, + Text: text, + MessageID: ev.Message.Name, + IsDirect: isDirect, + Mentioned: mentioned, + Attachments: c.download(ctx, ev.Message.Attachment), + }, true +} + +// download fetches inbound attachments into the spool. Chat attachment media +// requires authentication, so the OUTBOUND service-account bearer is reused. +// Best-effort: a failed download drops that attachment, the message still flows. +func (c *Channel) download(ctx context.Context, atts []chatAttachment) []channels.Attachment { + if c.mediaDir == "" || len(atts) == 0 { + return nil + } + ts, err := c.source(ctx) + if err != nil { + return nil + } + tok, err := ts.Token() + if err != nil { + return nil + } + var out []channels.Attachment + for _, a := range atts { + if a.DownloadURI == "" { + continue + } + att, err := c.downloadOne(ctx, tok.AccessToken, a) + if err != nil { + continue + } + out = append(out, att) + } + return out +} + +func (c *Channel) downloadOne(ctx context.Context, bearer string, a chatAttachment) (channels.Attachment, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, a.DownloadURI, nil) + if err != nil { + return channels.Attachment{}, err + } + // Attach the service-account bearer ONLY to Google's own hosts; a + // downloadUri pointing elsewhere is fetched without the credential (and the + // SSRF-guarded client still refuses any internal address). + host := "" + if u, uerr := url.Parse(a.DownloadURI); uerr == nil { + host = u.Host + } + if channels.HostAllowed(host, ".google.com", ".googleapis.com", ".googleusercontent.com") { + req.Header.Set("Authorization", "Bearer "+bearer) + } + resp, err := c.dl.Do(req) + if err != nil { + return channels.Attachment{}, err + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + return channels.Attachment{}, fmt.Errorf("googlechat attachment download: status %d", resp.StatusCode) + } + name := a.ContentName + if name == "" { + name = "attachment" + } + return channels.SaveToSpool(c.mediaDir, resp.Body, a.ContentType, name) +} + +// Send posts a text reply to a conversation (a spaces/ name) through the +// Chat REST API, split with the shared chunker. +func (c *Channel) Send(ctx context.Context, conversation string, msg channels.Outbound) error { + ts, err := c.source(ctx) + if err != nil { + return err + } + tok, err := ts.Token() + if err != nil { + return fmt.Errorf("googlechat token: %w", err) + } + for _, part := range channels.Chunk(msg.Text, chatMaxMessage) { + if err := c.sendOne(ctx, tok.AccessToken, conversation, part); err != nil { + return err + } + } + return nil +} + +func (c *Channel) sendOne(ctx context.Context, bearer, conversation, text string) error { + body, err := json.Marshal(map[string]string{"text": text}) + if err != nil { + return err + } + endpoint := fmt.Sprintf("%s/v1/%s/messages", c.apiBase, conversation) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+bearer) + req.Header.Set("Content-Type", "application/json") + resp, err := c.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + return fmt.Errorf("googlechat send: status %d", resp.StatusCode) + } + return nil +} + +// source returns the cached outbound token source, building it lazily from the +// service-account key via the two-legged JWT flow. Cached with a background +// context so one cancelled request can't poison later token refreshes. +func (c *Channel) source(ctx context.Context) (oauth2.TokenSource, error) { + c.tsMu.Lock() + defer c.tsMu.Unlock() + if c.tokenSource != nil { + return c.tokenSource, nil + } + cfg, err := google.JWTConfigFromJSON(c.saKeyJSON, chatScope) + if err != nil { + return nil, fmt.Errorf("googlechat service-account key: %w", err) + } + c.tokenSource = cfg.TokenSource(context.Background()) + return c.tokenSource, nil +} diff --git a/internal/triggers/googlechat/googlechat_test.go b/internal/triggers/googlechat/googlechat_test.go new file mode 100644 index 0000000..8fb7233 --- /dev/null +++ b/internal/triggers/googlechat/googlechat_test.go @@ -0,0 +1,323 @@ +package googlechat + +import ( + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "math/big" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "golang.org/x/oauth2" + + "github.com/memcode-ai/memcode/internal/channels" +) + +const testAudience = "123456789" + +type captureSink struct { + got []channels.Inbound + err error +} + +func (s *captureSink) Deliver(_ context.Context, inb channels.Inbound) error { + if s.err != nil { + return s.err + } + s.got = append(s.got, inb) + return nil +} + +// signJWT builds an RS256 JWT signed with key, mimicking Google's event token. +func signJWT(t *testing.T, key *rsa.PrivateKey, kid string, claims map[string]any) string { + t.Helper() + enc := func(v any) string { + b, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + return base64.RawURLEncoding.EncodeToString(b) + } + signing := enc(map[string]any{"alg": "RS256", "typ": "JWT", "kid": kid}) + "." + enc(claims) + sum := sha256.Sum256([]byte(signing)) + sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, sum[:]) + if err != nil { + t.Fatal(err) + } + return signing + "." + base64.RawURLEncoding.EncodeToString(sig) +} + +// jwksServer serves the key's public half as a JWKS document under kid. +func jwksServer(t *testing.T, key *rsa.PrivateKey, kid string) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + doc := map[string]any{"keys": []map[string]string{{ + "kty": "RSA", + "kid": kid, + "n": base64.RawURLEncoding.EncodeToString(key.PublicKey.N.Bytes()), + "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(key.PublicKey.E)).Bytes()), + }}} + _ = json.NewEncoder(w).Encode(doc) + })) +} + +func testChannel(t *testing.T, jwks *httptest.Server) *Channel { + t.Helper() + c := New(nil, testAudience, "") + c.verify.JWKSURL = jwks.URL + return c +} + +func validClaims() map[string]any { + return map[string]any{"iss": chatIssuer, "aud": testAudience, "exp": 4102444800} // year 2100 +} + +func postEvent(t *testing.T, h http.Handler, bearer string, ev any) *httptest.ResponseRecorder { + t.Helper() + body, err := json.Marshal(ev) + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPost, "/googlechat", strings.NewReader(string(body))) + if bearer != "" { + req.Header.Set("Authorization", "Bearer "+bearer) + } + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + return w +} + +func messageEvent(spaceType, text, argumentText string, mention bool) map[string]any { + msg := map[string]any{ + "name": "spaces/AAA/messages/BBB.CCC", + "text": text, + "sender": map[string]any{"name": "users/42", "type": "HUMAN"}, + } + if argumentText != "" { + msg["argumentText"] = argumentText + } + if mention { + msg["annotations"] = []map[string]any{{"type": "USER_MENTION"}} + } + return map[string]any{ + "type": "MESSAGE", + "message": msg, + "space": map[string]any{"name": "spaces/AAA", "spaceType": spaceType}, + } +} + +func TestHandlerSignedRoundTrip(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + jwks := jwksServer(t, key, "k1") + defer jwks.Close() + c := testChannel(t, jwks) + sink := &captureSink{} + h := c.Handler(sink) + tok := signJWT(t, key, "k1", validClaims()) + + t.Run("dm", func(t *testing.T) { + w := postEvent(t, h, tok, messageEvent("DIRECT_MESSAGE", "hello there", "", false)) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + if got := w.Body.String(); got != "{}" { + t.Fatalf("body = %q, want {}", got) + } + if len(sink.got) != 1 { + t.Fatalf("delivered %d messages, want 1", len(sink.got)) + } + inb := sink.got[0] + want := channels.Inbound{ + Channel: "googlechat", + Conversation: "spaces/AAA", + Principal: "users/42", + Text: "hello there", + MessageID: "spaces/AAA/messages/BBB.CCC", + IsDirect: true, + } + if inb.Channel != want.Channel || inb.Conversation != want.Conversation || + inb.Principal != want.Principal || inb.Text != want.Text || + inb.MessageID != want.MessageID || inb.IsDirect != want.IsDirect || + inb.Mentioned || inb.Trusted { + t.Fatalf("inbound = %+v, want %+v", inb, want) + } + }) + + t.Run("space mention prefers argumentText", func(t *testing.T) { + sink.got = nil + w := postEvent(t, h, tok, messageEvent("SPACE", "@membot do the thing", "do the thing", true)) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + if len(sink.got) != 1 { + t.Fatalf("delivered %d messages, want 1", len(sink.got)) + } + inb := sink.got[0] + if inb.Text != "do the thing" { + t.Fatalf("text = %q, want argumentText preferred", inb.Text) + } + if inb.IsDirect || !inb.Mentioned { + t.Fatalf("isDirect=%v mentioned=%v, want false/true", inb.IsDirect, inb.Mentioned) + } + }) + + t.Run("legacy DM type field", func(t *testing.T) { + sink.got = nil + ev := messageEvent("", "hi", "", false) + ev["space"] = map[string]any{"name": "spaces/AAA", "type": "DM"} + postEvent(t, h, tok, ev) + if len(sink.got) != 1 || !sink.got[0].IsDirect { + t.Fatalf("legacy DM not mapped: %+v", sink.got) + } + }) + + t.Run("non-message event acked without delivery", func(t *testing.T) { + sink.got = nil + w := postEvent(t, h, tok, map[string]any{"type": "ADDED_TO_SPACE"}) + if w.Code != http.StatusOK || len(sink.got) != 0 { + t.Fatalf("status=%d delivered=%d, want 200/0", w.Code, len(sink.got)) + } + }) +} + +func TestHandlerRejectsBadAuth(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + jwks := jwksServer(t, key, "k1") + defer jwks.Close() + c := testChannel(t, jwks) + sink := &captureSink{} + h := c.Handler(sink) + ev := messageEvent("DIRECT_MESSAGE", "hi", "", false) + + cases := map[string]string{ + "missing token": "", + "wrong aud": signJWT(t, key, "k1", map[string]any{"iss": chatIssuer, "aud": "999", "exp": 4102444800}), + "wrong issuer": signJWT(t, key, "k1", map[string]any{"iss": "evil@example.com", "aud": testAudience, "exp": 4102444800}), + "expired": signJWT(t, key, "k1", map[string]any{"iss": chatIssuer, "aud": testAudience, "exp": 1000}), + "bad signature": signJWT(t, key, "k1", validClaims()) + "x", + } + // A token signed by a key Google never published must also fail. + other, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + cases["unpublished key"] = signJWT(t, other, "k1", validClaims()) + + for name, tok := range cases { + t.Run(name, func(t *testing.T) { + w := postEvent(t, h, tok, ev) + if w.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", w.Code) + } + if len(sink.got) != 0 { + t.Fatalf("delivered %d messages, want 0", len(sink.got)) + } + }) + } +} + +func TestHandlerDeliverFailure(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + jwks := jwksServer(t, key, "k1") + defer jwks.Close() + c := testChannel(t, jwks) + sink := &captureSink{err: fmt.Errorf("inbox down")} + tok := signJWT(t, key, "k1", validClaims()) + w := postEvent(t, c.Handler(sink), tok, messageEvent("DIRECT_MESSAGE", "hi", "", false)) + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503", w.Code) + } +} + +func TestHandlerSkipsBotSender(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + jwks := jwksServer(t, key, "k1") + defer jwks.Close() + c := testChannel(t, jwks) + sink := &captureSink{} + tok := signJWT(t, key, "k1", validClaims()) + ev := messageEvent("SPACE", "bot chatter", "", false) + ev["message"].(map[string]any)["sender"] = map[string]any{"name": "users/bot", "type": "BOT"} + w := postEvent(t, c.Handler(sink), tok, ev) + if w.Code != http.StatusOK || len(sink.got) != 0 { + t.Fatalf("status=%d delivered=%d, want 200/0", w.Code, len(sink.got)) + } +} + +func TestSendChunksWithBearer(t *testing.T) { + type sent struct { + path, bearer, text string + } + var got []sent + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var msg struct { + Text string `json:"text"` + } + _ = json.Unmarshal(body, &msg) + got = append(got, sent{r.URL.Path, r.Header.Get("Authorization"), msg.Text}) + w.WriteHeader(http.StatusOK) + })) + defer api.Close() + + c := New(nil, testAudience, "") + c.apiBase = api.URL + c.tokenSource = oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "test-token"}) + + long := strings.Repeat("a", chatMaxMessage+10) + if err := c.Send(context.Background(), "spaces/AAA", channels.Outbound{Text: long}); err != nil { + t.Fatal(err) + } + if len(got) != 2 { + t.Fatalf("sent %d messages, want 2 chunks", len(got)) + } + var rejoined string + for _, s := range got { + if s.path != "/v1/spaces/AAA/messages" { + t.Fatalf("path = %q", s.path) + } + if s.bearer != "Bearer test-token" { + t.Fatalf("bearer = %q", s.bearer) + } + if len([]rune(s.text)) > chatMaxMessage { + t.Fatalf("chunk of %d runes exceeds cap %d", len([]rune(s.text)), chatMaxMessage) + } + rejoined += s.text + } + if rejoined != long { + t.Fatal("chunking lost content") + } +} + +func TestSendFailsFastOnAPIError(t *testing.T) { + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "denied", http.StatusForbidden) + })) + defer api.Close() + c := New(nil, testAudience, "") + c.apiBase = api.URL + c.tokenSource = oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "t"}) + if err := c.Send(context.Background(), "spaces/AAA", channels.Outbound{Text: "hi"}); err == nil { + t.Fatal("want error on non-2xx send") + } +} diff --git a/internal/triggers/sms/sms.go b/internal/triggers/sms/sms.go new file mode 100644 index 0000000..22708af --- /dev/null +++ b/internal/triggers/sms/sms.go @@ -0,0 +1,220 @@ +// Package sms is the gateway's SMS adapter over Twilio: inbound messages +// arrive as form-encoded webhooks (signature-verified), replies go out through +// the Messages API. SMS is the tactical lane — alerts, approvals, short tasks +// from a phone — not a long-form chat surface. A2P registration (US 10DLC) is +// the operator's responsibility with Twilio. +// +// Signature validation needs the EXACT public URL Twilio posts to (scheme, +// host, path — byte for byte), which a proxied server can't reliably +// reconstruct, so it is explicit config: sms.webhook_url in gateway.yaml. +package sms + +import ( + "context" + "crypto/hmac" + "crypto/sha1" + "encoding/base64" + "fmt" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + "time" + + "github.com/memcode-ai/memcode/internal/channels" +) + +// smsMaxMessage keeps outbound parts within one concatenated-SMS budget; +// Twilio splits further on the wire, but 1500 keeps cost and ordering sane. +const smsMaxMessage = 1500 + +// Channel is a Twilio SMS connection. +type Channel struct { + accountSID string + authToken string + from string // our E.164 sending number + webhookURL string // the exact public URL Twilio posts to (signature input) + base string // API base; overridable in tests + client *http.Client + dl *http.Client // SSRF-guarded client for MMS media downloads + mediaDir string // media spool; "" disables MMS media download +} + +// New builds an SMS channel. webhookURL must be the exact public URL configured +// on the Twilio number; with it empty the handler rejects everything (fail +// closed — unsigned/unverifiable inbound SMS is never delivered). +func New(accountSID, authToken, from, webhookURL, mediaDir string) *Channel { + return &Channel{ + accountSID: accountSID, + authToken: authToken, + from: from, + webhookURL: strings.TrimSpace(webhookURL), + base: "https://api.twilio.com", + client: &http.Client{Timeout: 30 * time.Second}, + dl: channels.SafeHTTPClient(30 * time.Second), // MMS media fetches: SSRF-guarded + mediaDir: mediaDir, + } +} + +// Name returns the adapter identifier. +func (c *Channel) Name() string { return "sms" } + +// Handler returns the inbound webhook handler. Twilio signs each request with +// HMAC-SHA1 over the exact URL plus the sorted form params; a request that +// doesn't verify is rejected before anything is parsed. +func (c *Channel) Handler(sink channels.Sink) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "bad form", http.StatusBadRequest) + return + } + if !c.verifySignature(r.Header.Get("X-Twilio-Signature"), r.PostForm) { + http.Error(w, "bad signature", http.StatusUnauthorized) + return + } + inb, refs, ok := toInbound(r.PostForm) + if !ok { + w.WriteHeader(http.StatusNoContent) + return + } + inb.Attachments = c.download(r.Context(), refs) + if err := sink.Deliver(r.Context(), inb); err != nil { + http.Error(w, "not recorded", http.StatusServiceUnavailable) // Twilio retries + return + } + // An empty TwiML response = no synchronous reply; ours goes out through + // the Messages API when the job finishes. + w.Header().Set("Content-Type", "text/xml") + _, _ = w.Write([]byte(``)) + }) +} + +// verifySignature implements Twilio's scheme: base64(HMAC-SHA1(authToken, +// url + k1v1k2v2… with keys sorted)). Fails closed when the webhook URL isn't +// configured. +func (c *Channel) verifySignature(header string, form url.Values) bool { + if c.webhookURL == "" || c.authToken == "" || header == "" { + return false + } + keys := make([]string, 0, len(form)) + for k := range form { + keys = append(keys, k) + } + sort.Strings(keys) + var b strings.Builder + b.WriteString(c.webhookURL) + for _, k := range keys { + // form.Get takes the FIRST value only. Twilio never sends duplicate + // keys; if one ever appeared, the computed signature would mismatch and + // the request would be rejected — fail closed, not a bypass. + b.WriteString(k) + b.WriteString(form.Get(k)) + } + mac := hmac.New(sha1.New, []byte(c.authToken)) + mac.Write([]byte(b.String())) + want := base64.StdEncoding.EncodeToString(mac.Sum(nil)) + return hmac.Equal([]byte(want), []byte(header)) +} + +// mediaRef is one MMS media item. +type mediaRef struct { + url string + mime string +} + +// toInbound normalizes a Twilio inbound-message form. +func toInbound(form url.Values) (channels.Inbound, []mediaRef, bool) { + from := strings.TrimSpace(form.Get("From")) + sid := strings.TrimSpace(form.Get("MessageSid")) + body := form.Get("Body") + var refs []mediaRef + if n, err := strconv.Atoi(form.Get("NumMedia")); err == nil { + for i := 0; i < n && i < 10; i++ { + u := form.Get(fmt.Sprintf("MediaUrl%d", i)) + if u == "" { + continue + } + refs = append(refs, mediaRef{url: u, mime: form.Get(fmt.Sprintf("MediaContentType%d", i))}) + } + } + if from == "" || sid == "" || (strings.TrimSpace(body) == "" && len(refs) == 0) { + return channels.Inbound{}, nil, false + } + return channels.Inbound{ + Channel: "sms", + Conversation: from, // SMS is 1:1; the sender's number is the reply route + Principal: from, // E.164 — the stable id carriers authenticate + Text: body, + MessageID: sid, + IsDirect: true, + }, refs, true +} + +// download fetches MMS media (Twilio media URLs need basic auth; they also +// expire quickly, which is why this happens at receipt). Best-effort. +func (c *Channel) download(ctx context.Context, refs []mediaRef) []channels.Attachment { + if c.mediaDir == "" || len(refs) == 0 { + return nil + } + var out []channels.Attachment + for _, ref := range refs { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, ref.url, nil) + if err != nil { + continue + } + req.SetBasicAuth(c.accountSID, c.authToken) + resp, err := c.dl.Do(req) + if err != nil { + continue + } + if resp.StatusCode/100 != 2 { + resp.Body.Close() + continue + } + att, err := channels.SaveToSpool(c.mediaDir, resp.Body, ref.mime, "mms") + resp.Body.Close() + if err != nil { + continue + } + out = append(out, att) + } + return out +} + +// Send posts a reply through the Messages API, split with the shared chunker. +func (c *Channel) Send(ctx context.Context, conversation string, msg channels.Outbound) error { + for _, part := range channels.Chunk(msg.Text, smsMaxMessage) { + if err := c.sendOne(ctx, conversation, part); err != nil { + return err + } + } + return nil +} + +func (c *Channel) sendOne(ctx context.Context, to, body string) error { + form := url.Values{} + form.Set("To", to) + form.Set("From", c.from) + form.Set("Body", body) + endpoint := fmt.Sprintf("%s/2010-04-01/Accounts/%s/Messages.json", c.base, c.accountSID) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) + if err != nil { + return err + } + req.SetBasicAuth(c.accountSID, c.authToken) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := c.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + return fmt.Errorf("twilio send: status %d", resp.StatusCode) + } + return nil +} diff --git a/internal/triggers/sms/sms_test.go b/internal/triggers/sms/sms_test.go new file mode 100644 index 0000000..e582fbb --- /dev/null +++ b/internal/triggers/sms/sms_test.go @@ -0,0 +1,139 @@ +package sms + +import ( + "context" + "crypto/hmac" + "crypto/sha1" + "encoding/base64" + "errors" + "io" + "net/http" + "net/http/httptest" + "net/url" + "sort" + "strings" + "testing" + + "github.com/memcode-ai/memcode/internal/channels" +) + +type recSink struct { + got []channels.Inbound + fail bool +} + +func (s *recSink) Deliver(_ context.Context, inb channels.Inbound) error { + if s.fail { + return errors.New("db down") + } + s.got = append(s.got, inb) + return nil +} + +func sign(authToken, webhookURL string, form url.Values) string { + keys := make([]string, 0, len(form)) + for k := range form { + keys = append(keys, k) + } + sort.Strings(keys) + var b strings.Builder + b.WriteString(webhookURL) + for _, k := range keys { + b.WriteString(k) + b.WriteString(form.Get(k)) + } + mac := hmac.New(sha1.New, []byte(authToken)) + mac.Write([]byte(b.String())) + return base64.StdEncoding.EncodeToString(mac.Sum(nil)) +} + +func post(h http.Handler, sig string, form url.Values) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, "/webhook/sms", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + if sig != "" { + req.Header.Set("X-Twilio-Signature", sig) + } + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + return rr +} + +func TestHandlerSignatureAndDelivery(t *testing.T) { + const hook = "https://gw.example.com/webhook/sms" + c := New("AC123", "tok", "+15550009999", hook, "") + sink := &recSink{} + h := c.Handler(sink) + + form := url.Values{} + form.Set("From", "+15551230000") + form.Set("MessageSid", "SM1") + form.Set("Body", "restart the deploy") + form.Set("NumMedia", "0") + + // Bad signature → 401, nothing delivered. + if rr := post(h, "AAAA", form); rr.Code != http.StatusUnauthorized { + t.Fatalf("bad sig: %d", rr.Code) + } + // Missing signature → 401. + if rr := post(h, "", form); rr.Code != http.StatusUnauthorized { + t.Fatalf("no sig: %d", rr.Code) + } + if len(sink.got) != 0 { + t.Fatal("unauthenticated SMS delivered") + } + + // Valid signature → delivered, empty TwiML back. + rr := post(h, sign("tok", hook, form), form) + if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "") { + t.Fatalf("good sig: %d %q", rr.Code, rr.Body.String()) + } + inb := sink.got[0] + if inb.Channel != "sms" || inb.Principal != "+15551230000" || inb.MessageID != "SM1" || !inb.IsDirect { + t.Errorf("inbound = %+v", inb) + } + + // Deliver failure → 503 so Twilio retries. + sink.fail = true + if rr := post(h, sign("tok", hook, form), form); rr.Code != http.StatusServiceUnavailable { + t.Fatalf("deliver fail: %d", rr.Code) + } +} + +// No configured webhook URL → fail closed: nothing verifies. +func TestHandlerFailsClosedWithoutWebhookURL(t *testing.T) { + c := New("AC123", "tok", "+1555", "", "") + sink := &recSink{} + form := url.Values{"From": {"+1"}, "MessageSid": {"SM2"}, "Body": {"x"}} + if rr := post(c.Handler(sink), sign("tok", "https://anything", form), form); rr.Code != http.StatusUnauthorized { + t.Fatalf("code = %d", rr.Code) + } +} + +func TestSendChunksWithBasicAuth(t *testing.T) { + var bodies []string + var auth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + v, _ := url.ParseQuery(string(b)) + bodies = append(bodies, v.Get("Body")) + auth = r.Header.Get("Authorization") + w.WriteHeader(http.StatusCreated) + })) + defer srv.Close() + + c := New("AC123", "tok", "+15550009999", "https://hook", "") + c.base = srv.URL + long := strings.Repeat("y", smsMaxMessage+5) + if err := c.Send(context.Background(), "+15551230000", channels.Outbound{Text: long}); err != nil { + t.Fatal(err) + } + if len(bodies) != 2 { + t.Fatalf("parts = %d", len(bodies)) + } + if got := strings.Join(bodies, ""); got != long { + t.Error("chunking lost content") + } + if !strings.HasPrefix(auth, "Basic ") { + t.Errorf("auth = %q", auth) + } +} diff --git a/internal/triggers/whatsapp/whatsapp.go b/internal/triggers/whatsapp/whatsapp.go index 1a4142f..cd274ff 100644 --- a/internal/triggers/whatsapp/whatsapp.go +++ b/internal/triggers/whatsapp/whatsapp.go @@ -18,7 +18,10 @@ import ( "encoding/json" "fmt" "io" + "mime/multipart" "net/http" + "net/textproto" + "os" "strings" "time" @@ -32,6 +35,9 @@ const defaultBase = "https://graph.facebook.com" const maxBody = 2 << 20 // 2 MiB +// whatsappMaxMessage caps one outbound text; WhatsApp rejects bodies past 4096. +const whatsappMaxMessage = 4096 + // Channel is a WhatsApp Cloud API connection. type Channel struct { phoneNumberID string @@ -40,12 +46,16 @@ type Channel struct { appSecret string // Meta app secret; verifies inbound POST signatures base string // Graph API base; overridable in tests client *http.Client + dl *http.Client // SSRF-guarded client for media downloads + mediaDir string // media spool; "" disables inbound media downloads } // New builds a WhatsApp channel from the phone number id and its tokens. appSecret // is the Meta app secret used to verify inbound message signatures; it must be -// non-empty for the handler to accept POSTed messages. -func New(phoneNumberID, accessToken, verifyToken, appSecret string) *Channel { +// non-empty for the handler to accept POSTed messages. mediaDir is the gateway +// media spool inbound images/voice notes/documents are downloaded into; "" +// disables media handling (text messages still flow). +func New(phoneNumberID, accessToken, verifyToken, appSecret, mediaDir string) *Channel { return &Channel{ phoneNumberID: phoneNumberID, accessToken: accessToken, @@ -53,6 +63,8 @@ func New(phoneNumberID, accessToken, verifyToken, appSecret string) *Channel { appSecret: appSecret, base: defaultBase, client: &http.Client{Timeout: 30 * time.Second}, + dl: channels.SafeHTTPClient(30 * time.Second), // media fetches: SSRF-guarded + mediaDir: mediaDir, } } @@ -85,7 +97,9 @@ func (c *Channel) Handler(sink channels.Sink) http.Handler { http.Error(w, "bad signature", http.StatusUnauthorized) return } - for _, inb := range toInbounds(body) { + for _, pm := range toInbounds(body) { + inb := pm.inb + inb.Attachments = c.download(r.Context(), pm.media) if err := sink.Deliver(r.Context(), inb); err != nil { w.WriteHeader(http.StatusServiceUnavailable) // not recorded — Meta retries return @@ -132,6 +146,15 @@ func verifyChallenge(q map[string][]string, verifyToken string) (string, bool) { return get("hub.challenge"), true } +// waMedia references one piece of media on a message (resolved via the Graph +// media endpoint at download time). +type waMedia struct { + ID string `json:"id"` + MimeType string `json:"mime_type"` + Filename string `json:"filename"` + Caption string `json:"caption"` +} + // inboundPayload is the subset of a WhatsApp webhook payload we read. type inboundPayload struct { Entry []struct { @@ -144,33 +167,59 @@ type inboundPayload struct { Text struct { Body string `json:"body"` } `json:"text"` + Image *waMedia `json:"image"` + Audio *waMedia `json:"audio"` + Document *waMedia `json:"document"` } `json:"messages"` } `json:"value"` } `json:"changes"` } `json:"entry"` } -// toInbounds extracts each text message from a webhook payload as an Inbound. -// Non-text messages (status updates, media, etc.) are skipped. -func toInbounds(body []byte) []channels.Inbound { +// parsedMessage pairs a normalized Inbound with the media it references; the +// caller downloads the media (needs the access token) before delivering. +type parsedMessage struct { + inb channels.Inbound + media []waMedia +} + +// toInbounds extracts each text/image/audio/document message from a webhook +// payload. Status updates and unsupported types are skipped. +func toInbounds(body []byte) []parsedMessage { var p inboundPayload if err := json.Unmarshal(body, &p); err != nil { return nil } - var out []channels.Inbound + var out []parsedMessage for _, e := range p.Entry { for _, ch := range e.Changes { for _, m := range ch.Value.Messages { - if m.Type != "text" || m.From == "" || m.Text.Body == "" { + if m.From == "" { continue } - out = append(out, channels.Inbound{ - Channel: "whatsapp", - Conversation: m.From, - Principal: m.From, - Text: m.Text.Body, - MessageID: m.ID, - IsDirect: true, // WhatsApp Cloud messages are 1:1 with the sender + text := m.Text.Body + var media []waMedia + for _, w := range []*waMedia{m.Image, m.Audio, m.Document} { + if w != nil && w.ID != "" { + media = append(media, *w) + if text == "" { + text = w.Caption // media caption is the task text + } + } + } + if text == "" && len(media) == 0 { + continue + } + out = append(out, parsedMessage{ + inb: channels.Inbound{ + Channel: "whatsapp", + Conversation: m.From, + Principal: m.From, + Text: text, + MessageID: m.ID, + IsDirect: true, // WhatsApp Cloud messages are 1:1 with the sender + }, + media: media, }) } } @@ -178,13 +227,156 @@ func toInbounds(body []byte) []channels.Inbound { return out } -// Send posts a text reply to a conversation (the recipient's phone number). +// download fetches referenced media into the spool: GET / resolves a +// short-lived URL, then the bytes are fetched with the same bearer. Best-effort — +// a failed download drops that attachment, the message still flows. +func (c *Channel) download(ctx context.Context, media []waMedia) []channels.Attachment { + if c.mediaDir == "" || len(media) == 0 { + return nil + } + var out []channels.Attachment + for _, m := range media { + att, err := c.downloadOne(ctx, m) + if err != nil { + continue + } + out = append(out, att) + } + return out +} + +func (c *Channel) downloadOne(ctx context.Context, m waMedia) (channels.Attachment, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/%s/%s", c.base, graphVersion, m.ID), nil) + if err != nil { + return channels.Attachment{}, err + } + req.Header.Set("Authorization", "Bearer "+c.accessToken) + resp, err := c.client.Do(req) + if err != nil { + return channels.Attachment{}, err + } + defer resp.Body.Close() + var meta struct { + URL string `json:"url"` + } + if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil || meta.URL == "" { + return channels.Attachment{}, fmt.Errorf("whatsapp media lookup failed") + } + dreq, err := http.NewRequestWithContext(ctx, http.MethodGet, meta.URL, nil) + if err != nil { + return channels.Attachment{}, err + } + dreq.Header.Set("Authorization", "Bearer "+c.accessToken) + dresp, err := c.dl.Do(dreq) // SSRF-guarded: the media URL is Graph-returned but fetched defensively + if err != nil { + return channels.Attachment{}, err + } + defer dresp.Body.Close() + if dresp.StatusCode/100 != 2 { + return channels.Attachment{}, fmt.Errorf("whatsapp media download: status %d", dresp.StatusCode) + } + name := m.Filename + if name == "" { + name = "media" + } + return channels.SaveToSpool(c.mediaDir, dresp.Body, m.MimeType, name) +} + +// Send posts a text reply to a conversation (the recipient's phone number), +// split with the shared chunker — WhatsApp rejects over-long bodies, and this +// was the one adapter bypassing the shared splitter. A synthesized voice +// rendition (VoicePath) is uploaded and sent first as an audio message, +// best-effort — the text always follows. func (c *Channel) Send(ctx context.Context, conversation string, msg channels.Outbound) error { + if msg.VoicePath != "" { + _ = c.sendVoice(ctx, conversation, msg.VoicePath) // best-effort; text is the reply of record + } + for _, part := range channels.Chunk(msg.Text, whatsappMaxMessage) { + if err := c.sendOne(ctx, conversation, part); err != nil { + return err + } + } + return nil +} + +// sendVoice uploads an OGG/Opus file to the media endpoint and sends it as an +// audio message. +func (c *Channel) sendVoice(ctx context.Context, conversation, path string) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + var body bytes.Buffer + mw := multipart.NewWriter(&body) + if err := mw.WriteField("messaging_product", "whatsapp"); err != nil { + return err + } + h := textproto.MIMEHeader{} + h.Set("Content-Disposition", `form-data; name="file"; filename="voice.ogg"`) + h.Set("Content-Type", "audio/ogg") + fw, err := mw.CreatePart(h) + if err != nil { + return err + } + if _, err := io.Copy(fw, f); err != nil { + return err + } + if err := mw.Close(); err != nil { + return err + } + upload := fmt.Sprintf("%s/%s/%s/media", c.base, graphVersion, c.phoneNumberID) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, upload, &body) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+c.accessToken) + req.Header.Set("Content-Type", mw.FormDataContentType()) + resp, err := c.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + var out struct { + ID string `json:"id"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil || out.ID == "" { + return fmt.Errorf("whatsapp media upload failed") + } + payload := map[string]any{ + "messaging_product": "whatsapp", + "to": conversation, + "type": "audio", + "audio": map[string]string{"id": out.ID}, + } + pb, err := json.Marshal(payload) + if err != nil { + return err + } + send := fmt.Sprintf("%s/%s/%s/messages", c.base, graphVersion, c.phoneNumberID) + sreq, err := http.NewRequestWithContext(ctx, http.MethodPost, send, bytes.NewReader(pb)) + if err != nil { + return err + } + sreq.Header.Set("Authorization", "Bearer "+c.accessToken) + sreq.Header.Set("Content-Type", "application/json") + sresp, err := c.client.Do(sreq) + if err != nil { + return err + } + defer sresp.Body.Close() + if sresp.StatusCode/100 != 2 { + return fmt.Errorf("whatsapp audio send: status %d", sresp.StatusCode) + } + return nil +} + +func (c *Channel) sendOne(ctx context.Context, conversation, text string) error { payload := map[string]any{ "messaging_product": "whatsapp", "to": conversation, "type": "text", - "text": map[string]string{"body": msg.Text}, + "text": map[string]string{"body": text}, } body, err := json.Marshal(payload) if err != nil { diff --git a/internal/triggers/whatsapp/whatsapp_test.go b/internal/triggers/whatsapp/whatsapp_test.go index d86f22b..70eda10 100644 --- a/internal/triggers/whatsapp/whatsapp_test.go +++ b/internal/triggers/whatsapp/whatsapp_test.go @@ -41,16 +41,21 @@ func TestVerifyChallenge(t *testing.T) { func TestToInbounds(t *testing.T) { payload := `{"entry":[{"changes":[{"value":{"messages":[ {"id":"wamid.1","from":"15551230000","type":"text","text":{"body":"do it"}}, - {"id":"wamid.2","from":"15551230000","type":"image","text":{"body":""}}, - {"id":"wamid.3","from":"15559990000","type":"text","text":{"body":"hi"}} + {"id":"wamid.2","from":"15551230000","type":"image","image":{"id":"m9","mime_type":"image/jpeg","caption":"what is this?"}}, + {"id":"wamid.3","from":"15559990000","type":"text","text":{"body":"hi"}}, + {"id":"wamid.4","from":"","type":"text","text":{"body":"orphan"}} ]}}]}]}` got := toInbounds([]byte(payload)) - if len(got) != 2 { - t.Fatalf("want 2 text messages, got %d: %+v", len(got), got) + if len(got) != 3 { + t.Fatalf("want 3 messages, got %d: %+v", len(got), got) } want := channels.Inbound{Channel: "whatsapp", Conversation: "15551230000", Principal: "15551230000", Text: "do it", MessageID: "wamid.1", IsDirect: true} - if got[0] != want { - t.Errorf("got %+v, want %+v", got[0], want) + if got[0].inb.Channel != want.Channel || got[0].inb.Text != want.Text || got[0].inb.MessageID != want.MessageID || got[0].inb.Conversation != want.Conversation { + t.Errorf("got %+v, want %+v", got[0].inb, want) + } + // An image message carries its media reference and uses the caption as text. + if len(got[1].media) != 1 || got[1].media[0].ID != "m9" || got[1].inb.Text != "what is this?" { + t.Errorf("image message parsed wrong: %+v", got[1]) } if n := len(toInbounds([]byte("not json"))); n != 0 { t.Errorf("bad json yielded %d inbounds", n) @@ -69,7 +74,7 @@ func TestSend(t *testing.T) { })) defer srv.Close() - c := New("PN123", "TOKEN", "vt", "sekret") + c := New("PN123", "TOKEN", "vt", "sekret", "") c.base = srv.URL if err := c.Send(context.Background(), "15551230000", channels.Outbound{Text: "yo"}); err != nil { t.Fatalf("Send: %v", err) @@ -94,7 +99,7 @@ func (s *recSink) Deliver(_ context.Context, inb channels.Inbound) error { } func TestHandlerGET(t *testing.T) { - c := New("PN", "tok", "vt", "sekret") + c := New("PN", "tok", "vt", "sekret", "") h := c.Handler(&recSink{}) req := httptest.NewRequest(http.MethodGet, "/webhook/whatsapp?hub.mode=subscribe&hub.verify_token=vt&hub.challenge=99", nil) rr := httptest.NewRecorder() @@ -106,7 +111,7 @@ func TestHandlerGET(t *testing.T) { func TestHandlerPOSTSignature(t *testing.T) { const secret = "sekret" - c := New("PN", "tok", "vt", secret) + c := New("PN", "tok", "vt", secret, "") sink := &recSink{} h := c.Handler(sink) diff --git a/internal/vxui/approval.go b/internal/vxui/approval.go index 639ddc6..1651695 100644 --- a/internal/vxui/approval.go +++ b/internal/vxui/approval.go @@ -18,8 +18,8 @@ type approvalOption struct { // approvalOptions builds the card's rows. Scoped requests (MCP) read Execute / one row per // remember scope / Cancel — Cancel is a PLAIN deny (the model sees the refusal and moves on), -// unlike "tell", which interrupts the turn to redirect it. Everything else keeps the classic -// three: Yes / don't-ask-again / tell. +// unlike "tell", which denies + redirects: the agent keeps working and acts on the typed +// feedback. Everything else keeps the classic three: Yes / don't-ask-again / tell. func (s *appState) approvalOptions() []approvalOption { p := s.pending if len(p.RememberScopes) > 0 { @@ -46,10 +46,9 @@ func (s *appState) approvalOptions() []approvalOption { // approvalEnterAction decides what Enter does on the approval card, given the highlighted // option's kind and whether the user typed feedback. Typed feedback is always "tell" (deny + -// redirect). With nothing typed, "tell" returns "hint" — NOT an interrupt. The old code sent -// Interrupt on an empty "tell" Enter, which silently STOPPED the whole turn when the user just -// meant to pick an option. Esc remains the explicit stop. "cancel" with nothing typed is a -// plain deny, no hint dance — Cancel IS the chosen outcome. +// redirect — the turn CONTINUES and the agent acts on the feedback). With nothing typed, "tell" +// returns "hint" so the user is prompted to type, never a stop. Esc remains the explicit stop. +// "cancel" with nothing typed is a plain deny, no hint dance — Cancel IS the chosen outcome. func approvalEnterAction(kind string, hasFeedback bool) string { if hasFeedback { return "tell" @@ -74,9 +73,10 @@ func (s *appState) answerApproval(d runtime.ApprovalDecision) { } // answerApprovalChoice maps a card outcome to a decision. "tell" is "No, and tell me what to -// do differently": stop the turn and feed the typed redirection back so the agent acts on it -// instead of the path the user just rejected. "cancel" (scoped cards) denies without stopping -// the turn — the model sees the refusal and continues. scope carries the chosen remember key. +// do differently": DENY the rejected action and hand the typed redirection to the agent, which +// keeps working — it reads the feedback and responds (says something / re-plans / asks) instead +// of the turn terminating. (Esc is the hard stop.) "cancel" (scoped cards) denies without +// redirecting — the model sees a plain refusal and continues. scope carries the remember key. func (s *appState) answerApprovalChoice(kind, scope, feedback string) { switch kind { case "yes": @@ -91,7 +91,11 @@ func (s *appState) answerApprovalChoice(kind, scope, feedback string) { case "cancel": s.answerApproval(runtime.ApprovalDecision{}) case "tell": - s.answerApproval(runtime.ApprovalDecision{Interrupt: true, Reason: feedback}) + // Deny + Redirect: skip the rejected action and its siblings, but keep the turn + // alive so the agent acts on the feedback. Frame the reason as a user instruction + // so the model responds to it rather than treating it as a bare tool error. + reason := "The user declined that action and said: " + feedback + s.answerApproval(runtime.ApprovalDecision{Redirect: true, Reason: reason}) s.SetState(s.clearComposerInput) } } diff --git a/internal/webjwt/webjwt.go b/internal/webjwt/webjwt.go new file mode 100644 index 0000000..8ce8773 --- /dev/null +++ b/internal/webjwt/webjwt.go @@ -0,0 +1,190 @@ +// Package webjwt is the ONE verifier for platform-signed inbound-webhook +// bearer tokens (Bot Framework, Google Chat): RS256 against a published JWKS, +// with issuer/audience/expiry enforced and the key set cached — an unknown kid +// triggers a refetch at most once per interval, so key rotation works but a +// forged-kid flood is not amplified into JWKS hammering. Two adapters verifying JWTs two +// different ways is how drift bugs happen; both use this. +// +// The golang-jwt SDK lives ONLY here (guarded by +// TestVendorSDKsOnlyInTheirAdapters). +package webjwt + +import ( + "context" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "math/big" + "net/http" + "sync" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// maxDoc caps a fetched metadata/JWKS document. +const maxDoc = 1 << 20 + +// Verifier validates RS256 bearer tokens against one platform's JWKS. Set +// either JWKSURL directly (Google's certs endpoint) or MetadataURL (an OpenID +// configuration document whose jwks_uri is followed — Bot Framework). Fields +// are read on each Verify, so tests may point them at a fake server after +// construction and before first use. +type Verifier struct { + MetadataURL string // OpenID configuration carrying jwks_uri; used when JWKSURL is empty + JWKSURL string // direct JWKS endpoint; takes precedence + Issuer string // required — an empty issuer never verifies + Audience string // required — an empty audience never verifies + Client *http.Client + + mu sync.Mutex + keys map[string]*rsa.PublicKey + lastRefresh time.Time // throttles refreshes so an unknown-kid flood can't hammer the JWKS host +} + +// minRefreshInterval bounds how often an unknown kid may trigger a JWKS +// refetch. A flood of forged random kids is thus rate-limited to one outbound +// fetch per interval, not one per request. +const minRefreshInterval = 30 * time.Second + +// Verify checks a raw compact JWT. It fails closed: missing configuration, +// unknown alg, unknown kid after one refresh, wrong issuer/audience, or an +// expired (or unexpiring) token are all errors. +func (v *Verifier) Verify(ctx context.Context, raw string) error { + if v.Issuer == "" || v.Audience == "" { + return errors.New("verifier not configured (issuer/audience)") + } + refreshed := false + keyfunc := func(t *jwt.Token) (any, error) { + kid, _ := t.Header["kid"].(string) + if kid == "" { + return nil, errors.New("token missing kid") + } + if k := v.cachedKey(kid); k != nil { + return k, nil + } + if !refreshed { + refreshed = true + if err := v.refreshKeys(ctx); err != nil { + return nil, err + } + if k := v.cachedKey(kid); k != nil { + return k, nil + } + } + return nil, fmt.Errorf("unknown signing key %q", kid) + } + _, err := jwt.Parse(raw, keyfunc, + jwt.WithValidMethods([]string{"RS256"}), + jwt.WithIssuer(v.Issuer), + jwt.WithAudience(v.Audience), + jwt.WithExpirationRequired(), + ) + return err +} + +func (v *Verifier) cachedKey(kid string) *rsa.PublicKey { + v.mu.Lock() + defer v.mu.Unlock() + return v.keys[kid] +} + +// refreshKeys resolves the JWKS location and replaces the key cache. Replacing +// (not merging) means revoked keys actually leave. Throttled: at most one +// outbound fetch per minRefreshInterval, so an unauthenticated flood of forged +// kids can't amplify into JWKS hammering (each caller sees a benign "unknown +// key" once the cache is warm and the interval hasn't elapsed). +func (v *Verifier) refreshKeys(ctx context.Context) error { + v.mu.Lock() + if !v.lastRefresh.IsZero() && time.Since(v.lastRefresh) < minRefreshInterval { + v.mu.Unlock() + return nil // recently refreshed; the caller's kid is treated as unknown + } + v.lastRefresh = time.Now() + v.mu.Unlock() + jwksURL := v.JWKSURL + if jwksURL == "" { + var meta struct { + JWKSURI string `json:"jwks_uri"` + } + if err := v.getJSON(ctx, v.MetadataURL, &meta); err != nil { + return fmt.Errorf("openid metadata: %w", err) + } + if meta.JWKSURI == "" { + return errors.New("openid metadata has no jwks_uri") + } + jwksURL = meta.JWKSURI + } + var set struct { + Keys []struct { + Kty string `json:"kty"` + Kid string `json:"kid"` + N string `json:"n"` + E string `json:"e"` + } `json:"keys"` + } + if err := v.getJSON(ctx, jwksURL, &set); err != nil { + return fmt.Errorf("jwks fetch: %w", err) + } + keys := make(map[string]*rsa.PublicKey, len(set.Keys)) + for _, k := range set.Keys { + if k.Kty != "RSA" || k.Kid == "" { + continue + } + pub, err := rsaFromJWK(k.N, k.E) + if err != nil { + continue // one malformed key must not poison the whole set + } + keys[k.Kid] = pub + } + if len(keys) == 0 { + return errors.New("jwks contained no usable rsa keys") + } + v.mu.Lock() + v.keys = keys + v.mu.Unlock() + return nil +} + +// rsaFromJWK builds an RSA public key from base64url modulus and exponent. +func rsaFromJWK(n64, e64 string) (*rsa.PublicKey, error) { + nb, err := base64.RawURLEncoding.DecodeString(n64) + if err != nil { + return nil, err + } + eb, err := base64.RawURLEncoding.DecodeString(e64) + if err != nil { + return nil, err + } + e := new(big.Int).SetBytes(eb) + if !e.IsInt64() || e.Int64() <= 0 { + return nil, errors.New("bad rsa exponent") + } + return &rsa.PublicKey{N: new(big.Int).SetBytes(nb), E: int(e.Int64())}, nil +} + +func (v *Verifier) getJSON(ctx context.Context, u string, out any) error { + if u == "" { + return errors.New("no jwks location configured") + } + client := v.Client + if client == nil { + client = &http.Client{Timeout: 15 * time.Second} + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return err + } + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + return fmt.Errorf("get %s: status %d", u, resp.StatusCode) + } + return json.NewDecoder(io.LimitReader(resp.Body, maxDoc)).Decode(out) +}