From 1f7d8c9dfec49a6c7e991fecc48592ce89a3afef Mon Sep 17 00:00:00 2001 From: Tim Erwin Date: Sat, 15 Aug 2026 01:31:33 +0700 Subject: [PATCH 01/13] gateway: media/attachment spine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Channels can now carry media. Inbound gains Attachments (downloaded into a content-addressed spool at ~/.config/memcode/media by each adapter); Outbound gains VoicePath for later synthesized replies. The spool is the trust boundary: everything downstream — the durable inbox row, the job context envelope, the spawned child — addresses media by spool ID (bare . filename), and the child resolves IDs strictly inside the spool, so a corrupted context file can't point a job at arbitrary files. Images/PDFs reach the engine through the existing input.Bundle attachment path via a new Session.SetTaskAttachments seam; audio never does (it will be transcribed gateway-side). Adapters: Telegram downloads photos/voice/audio/documents (getFile) and accepts captioned media; WhatsApp downloads image/audio/document media, uses captions as task text, and now routes outbound through the shared chunker (it was the one adapter bypassing it); Discord downloads message attachments; Slack lets file_share messages through as text (this SDK version doesn't surface the file list — noted limitation). Spool prune rides the inbox retention window; per-attachment cap 25 MiB. --- cmd/agent_context.go | 41 ++++- cmd/run.go | 7 +- internal/agent/runtime/chat.go | 6 + internal/agent/runtime/runtime.go | 43 +++-- internal/channels/channels.go | 31 ++++ internal/channels/discord/discord.go | 48 +++++- internal/channels/discord/discord_test.go | 3 +- internal/channels/media.go | 127 ++++++++++++++ internal/channels/media_test.go | 83 ++++++++++ internal/channels/slack/slack.go | 7 +- internal/channels/slack/slack_test.go | 3 +- internal/channels/telegram/telegram.go | 175 +++++++++++++++++--- internal/channels/telegram/telegram_test.go | 27 +-- internal/gateway/config/config.go | 13 ++ internal/gateway/server/media.go | 27 +++ internal/gateway/server/persona.go | 9 +- internal/gateway/server/server.go | 35 ++-- internal/gateway/state/state.go | 45 ++++- internal/gateway/state/state_test.go | 22 +++ internal/triggers/whatsapp/whatsapp.go | 144 ++++++++++++++-- internal/triggers/whatsapp/whatsapp_test.go | 23 ++- 21 files changed, 806 insertions(+), 113 deletions(-) create mode 100644 internal/channels/media.go create mode 100644 internal/channels/media_test.go create mode 100644 internal/gateway/server/media.go 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/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/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/runtime.go b/internal/agent/runtime/runtime.go index feee51c..a55f0cc 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 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..f526646 100644 --- a/internal/channels/discord/discord.go +++ b/internal/channels/discord/discord.go @@ -9,7 +9,9 @@ package discord import ( "context" + "net/http" "strings" + "time" "github.com/bwmarrin/discordgo" @@ -22,18 +24,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 +58,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 +84,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,6 +114,39 @@ 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. func (c *Channel) Send(ctx context.Context, conversation string, msg channels.Outbound) error { 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/media.go b/internal/channels/media.go new file mode 100644 index 0000000..02fdb20 --- /dev/null +++ b/internal/channels/media.go @@ -0,0 +1,127 @@ +package channels + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "mime" + "os" + "path/filepath" + "strings" +) + +// 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 { + 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/slack/slack.go b/internal/channels/slack/slack.go index 010358f..620348c 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) == "" { diff --git a/internal/channels/slack/slack_test.go b/internal/channels/slack/slack_test.go index 208313b..c41ce43 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" @@ -34,7 +35,7 @@ func TestToInbound(t *testing.T) { return } want := channels.Inbound{Channel: "slack", Conversation: tt.wantConvo, Principal: tt.wantWho, Text: tt.wantText, MessageID: "ts1"} - if got != want { + 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..27450c3 100644 --- a/internal/channels/telegram/telegram.go +++ b/internal/channels/telegram/telegram.go @@ -37,23 +37,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 +72,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 +150,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 +178,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 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..beac28c 100644 --- a/internal/gateway/config/config.go +++ b/internal/gateway/config/config.go @@ -266,6 +266,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). diff --git a/internal/gateway/server/media.go b/internal/gateway/server/media.go new file mode 100644 index 0000000..da9562f --- /dev/null +++ b/internal/gateway/server/media.go @@ -0,0 +1,27 @@ +package server + +import ( + "os" + "path/filepath" + "time" +) + +// 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/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/server.go b/internal/gateway/server/server.go index 988b696..d3906f3 100644 --- a/internal/gateway/server/server.go +++ b/internal/gateway/server/server.go @@ -63,6 +63,7 @@ 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) byName map[string]replySender disp *dispatcher out io.Writer @@ -99,18 +100,25 @@ 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, 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 +300,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 @@ -418,10 +430,13 @@ func (r *runtime) runJob(ctx context.Context, it state.Item) { fmt.Fprintf(r.out, "gateway: project %q for %s no longer resolves (%v); using default\n", it.Project, it.Channel, rerr) } } - // 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 { + // 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 = it.Attachments + 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) @@ -498,13 +513,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) @@ -539,7 +554,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") diff --git a/internal/gateway/state/state.go b/internal/gateway/state/state.go index 01f890e..ed83550 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,7 @@ 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 received_at TEXT NOT NULL, PRIMARY KEY (channel, message_id) ); @@ -98,8 +100,9 @@ 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) } // Store is the gateway's durable state. @@ -150,6 +153,7 @@ 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 ''`, } { if _, err := db.ExecContext(ctx, col); err != nil && !strings.Contains(err.Error(), "duplicate column") { _ = db.Close() @@ -206,10 +210,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,7 +229,7 @@ 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 + `SELECT channel, message_id, conversation, principal, text, trusted, agent, project, attachments FROM inbox WHERE status = 'pending' ORDER BY received_at`) if err != nil { return nil, fmt.Errorf("pending inbox: %w", err) @@ -235,10 +239,12 @@ 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() @@ -437,6 +443,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..8135bc3 100644 --- a/internal/gateway/state/state_test.go +++ b/internal/gateway/state/state_test.go @@ -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/triggers/whatsapp/whatsapp.go b/internal/triggers/whatsapp/whatsapp.go index 1a4142f..5c84c5f 100644 --- a/internal/triggers/whatsapp/whatsapp.go +++ b/internal/triggers/whatsapp/whatsapp.go @@ -32,6 +32,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 +43,15 @@ type Channel struct { appSecret string // Meta app secret; verifies inbound POST signatures base string // Graph API base; overridable in tests client *http.Client + 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 +59,7 @@ func New(phoneNumberID, accessToken, verifyToken, appSecret string) *Channel { appSecret: appSecret, base: defaultBase, client: &http.Client{Timeout: 30 * time.Second}, + mediaDir: mediaDir, } } @@ -85,7 +92,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 +141,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 +162,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 +222,79 @@ 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.client.Do(dreq) + 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. func (c *Channel) Send(ctx context.Context, conversation string, msg channels.Outbound) error { + for _, part := range channels.Chunk(msg.Text, whatsappMaxMessage) { + if err := c.sendOne(ctx, conversation, part); err != nil { + return err + } + } + 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) From 79cb2115753b0d0b736273251a51fbb92f3f40d7 Mon Sep 17 00:00:00 2001 From: Tim Erwin Date: Sat, 15 Aug 2026 01:36:40 +0700 Subject: [PATCH 02/13] gateway: email channel (IMAP/SMTP) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dedicated mailbox the agent answers — the biggest capability gap vs both competitors (OpenClaw has no email at all; Hermes ships a plaintext poller). IMAP-SSL poll (default 15s, email.poll to tune) + SMTP-STARTTLS replies with real threading (In-Reply-To/References, single Re:). Durable dedup key is // — the provider-side ack identity, robust against malformed/duplicated Message-IDs (those serve threading only). BODY.PEEK on fetch and \Seen set ONLY after the durable record, so a crash re-fetches instead of losing mail. Filters: Auto-Submitted, noreply/bounce senders, self (loop prevention). Attachments (images/PDFs) land in the media spool and reach the engine — where this beats Hermes' plaintext-only design. Allow-list + pairing work unchanged: an unknown sender's pairing code goes out as an email reply. Dep: emersion/go-imap/v2, guard-homed to internal/channels/email. --- go.mod | 3 + go.sum | 32 ++++ internal/channels/email/compose.go | 67 ++++++++ internal/channels/email/email.go | 228 ++++++++++++++++++++++++++ internal/channels/email/email_test.go | 197 ++++++++++++++++++++++ internal/channels/email/parse.go | 179 ++++++++++++++++++++ internal/channels/email/transport.go | 102 ++++++++++++ internal/gateway/config/config.go | 14 ++ internal/gateway/server/server.go | 16 ++ internal/guard/guard_test.go | 1 + 10 files changed, 839 insertions(+) create mode 100644 internal/channels/email/compose.go create mode 100644 internal/channels/email/email.go create mode 100644 internal/channels/email/email_test.go create mode 100644 internal/channels/email/parse.go create mode 100644 internal/channels/email/transport.go diff --git a/go.mod b/go.mod index 6ffabff..9ccea91 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ 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/mattn/go-runewidth v0.0.24 github.com/modelcontextprotocol/go-sdk v1.6.1 github.com/openai/openai-go/v3 v3.41.1 @@ -45,6 +46,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 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/channels/email/compose.go b/internal/channels/email/compose.go new file mode 100644 index 0000000..af751a0 --- /dev/null +++ b/internal/channels/email/compose.go @@ -0,0 +1,67 @@ +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", 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)) + if th.last != "" { + fmt.Fprintf(&b, "In-Reply-To: %s\r\n", th.last) + } + if refs := threadReferences(th); refs != "" { + fmt.Fprintf(&b, "References: %s\r\n", refs) + } + 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()) +} + +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..f4ffac0 --- /dev/null +++ b/internal/channels/email/email.go @@ -0,0 +1,228 @@ +// 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). +package email + +import ( + "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, strings.NewReader(string(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..1ae4fae --- /dev/null +++ b/internal/channels/email/email_test.go @@ -0,0 +1,197 @@ +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) + } +} diff --git a/internal/channels/email/parse.go b/internal/channels/email/parse.go new file mode 100644 index 0000000..4afbf1c --- /dev/null +++ b/internal/channels/email/parse.go @@ -0,0 +1,179 @@ +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 +} + +// maxParsedAttachment caps a single decoded email attachment. +const maxParsedAttachment = 25 << 20 + +// 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) + 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) { + if depth > 8 { + 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 { + part, err := mr.NextPart() + if err != nil { + return + } + walkPart(partHeader(part), part, p, depth+1) + } + } + + 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 { + 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..903a6c4 --- /dev/null +++ b/internal/channels/email/transport.go @@ -0,0 +1,102 @@ +package email + +import ( + "fmt" + "net/smtp" + "strings" + + "github.com/emersion/go-imap/v2" + "github.com/emersion/go-imap/v2/imapclient" +) + +// 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 { + 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/gateway/config/config.go b/internal/gateway/config/config.go index beac28c..933fe7e 100644 --- a/internal/gateway/config/config.go +++ b/internal/gateway/config/config.go @@ -33,6 +33,13 @@ 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" ) // Settings is the NON-secret gateway configuration (gateway.yaml). A channel's @@ -187,6 +194,9 @@ 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"` } // Get returns the settings for a channel (a zero Channel if unset), so callers @@ -346,5 +356,9 @@ 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") + } return names } diff --git a/internal/gateway/server/server.go b/internal/gateway/server/server.go index d3906f3..3fb6ef6 100644 --- a/internal/gateway/server/server.go +++ b/internal/gateway/server/server.go @@ -25,6 +25,7 @@ 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/slack" "github.com/memcode-ai/memcode/internal/channels/telegram" "github.com/memcode-ai/memcode/internal/events" @@ -530,6 +531,21 @@ func channelsFrom(settings gwconfig.Settings, gw *state.Store, mediaDir string, 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)) + } return chs } diff --git a/internal/guard/guard_test.go b/internal/guard/guard_test.go index 7aa6ac2..6e635aa 100644 --- a/internal/guard/guard_test.go +++ b/internal/guard/guard_test.go @@ -86,6 +86,7 @@ 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", } From 7dc8a3fe53b5596f1d1fcc27b7a4a2103b933582 Mon Sep 17 00:00:00 2001 From: Tim Erwin Date: Sat, 15 Aug 2026 01:40:45 +0700 Subject: [PATCH 03/13] =?UTF-8?q?gateway:=20voice=20notes=20in=20=E2=80=94?= =?UTF-8?q?=20transcription=20to=20task=20text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inbound voice notes (Telegram voice, WhatsApp audio, email audio attachments, Discord uploads) are transcribed gateway-side and become the task text; audio never reaches the coding engine. The STT seam lives in the provider homes so SDK containment holds: openai (audio transcriptions endpoint, gpt-4o-mini-transcribe with whisper-1 fallback) and gemini (audio-in generate on flash). Provider picked by present credentials; with neither key a voice-only message gets an honest 'not configured' reply instead of silence, and mixed text+audio proceeds on the text. Transcription runs after the durable record and before the spawn, so a crash re-runs it rather than losing the note. Replies stay text (TTS is the later, opt-in phase). --- internal/gateway/server/media.go | 77 +++++++++++++++++++++++++ internal/gateway/server/media_test.go | 49 ++++++++++++++++ internal/gateway/server/server.go | 20 ++++++- internal/providers/gemini/audio.go | 48 ++++++++++++++++ internal/providers/openai/audio.go | 82 +++++++++++++++++++++++++++ 5 files changed, 275 insertions(+), 1 deletion(-) create mode 100644 internal/gateway/server/media_test.go create mode 100644 internal/providers/gemini/audio.go create mode 100644 internal/providers/openai/audio.go diff --git a/internal/gateway/server/media.go b/internal/gateway/server/media.go index da9562f..b413f9f 100644 --- a/internal/gateway/server/media.go +++ b/internal/gateway/server/media.go @@ -1,11 +1,88 @@ package server import ( + "context" + "fmt" "os" "path/filepath" + "strings" "time" + + "github.com/memcode-ai/memcode/internal/channels" + "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 +} + +// 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. diff --git a/internal/gateway/server/media_test.go b/internal/gateway/server/media_test.go new file mode 100644 index 0000000..3671f6c --- /dev/null +++ b/internal/gateway/server/media_test.go @@ -0,0 +1,49 @@ +package server + +import ( + "context" + "io" + "os" + "path/filepath" + "strings" + "testing" +) + +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) + } +} diff --git a/internal/gateway/server/server.go b/internal/gateway/server/server.go index 3fb6ef6..a185249 100644 --- a/internal/gateway/server/server.go +++ b/internal/gateway/server/server.go @@ -65,6 +65,7 @@ type runtime struct { 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 byName map[string]replySender disp *dispatcher out io.Writer @@ -113,6 +114,7 @@ func Run(ctx context.Context, root string, mainStore store.Store, settings gwcon mainStore: mainStore, settings: settings, mediaDir: mediaDir, + stt: newTranscriber(), byName: make(map[string]replySender, 4), disp: newDispatcher(), out: out, @@ -431,12 +433,28 @@ func (r *runtime) runJob(ctx context.Context, it state.Item) { fmt.Fprintf(r.out, "gateway: project %q for %s no longer resolves (%v); using default\n", it.Project, it.Channel, rerr) } } + // 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 { + msg := "Voice note received, but no transcription provider is configured. Set OPENAI_API_KEY or GEMINI_API_KEY on the gateway machine, or send text." + if serr := r.gw.SetReplied(ctx, it.Channel, it.MessageID, msg); serr != nil { + fmt.Fprintf(r.out, "gateway: recording voice-note refusal for %s: %v\n", it.Channel, serr) + return + } + r.deliverReply(ctx, it, msg) + return + } + 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 = it.Attachments + jc.Attachments = rest if err := writeContext(session, jc); err != nil { fmt.Fprintf(r.out, "gateway: composing context for %s: %v\n", it.Channel, err) } 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 +} From caa75674e4183fc55e1d26a692b249a00c04e638 Mon Sep 17 00:00:00 2001 From: Tim Erwin Date: Sat, 15 Aug 2026 01:46:53 +0700 Subject: [PATCH 04/13] gateway: Signal, Matrix, and Mattermost channels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The community-signal cluster (highest genuine demand after voice on both competitors' trackers) and the self-host credibility story. Signal: adapter to a signal-cli daemon in native HTTP mode (SSE events in, JSON-RPC send out) — the same companion every gateway in this space requires; we spawn nothing. Linked-device model, dedicated-number advice in docs. Principal = E.164 (uuid fallback); dedup key = sender:timestamp (Signal's own message identity); groups via groupId conversations with mention/quote-reply gating; attachments come from the daemon's store into the media spool; a synthesized voice reply rides send as an attachment. Matrix: raw client-server API, stdlib only, NO E2EE v1 (plain rooms — stated loudly). /sync long-poll with the since-token persisted in a new string-cursor state table, advanced only after every Deliver in the batch is durably recorded; first sync records the token without replaying history. Sends as m.notice (bot convention, loop prevention); m.mentions for group gating; authenticated media download; voice replies upload via the media repo with silent text fallback. Mattermost: WebSocket event stream + REST v4, bot token; posted events (double-encoded post JSON) with DM/type-D detection and word-boundary mention matching; file downloads via files API; chunked posts. gorilla/websocket promoted to a direct dep, guard-homed to mattermost. --- internal/channels/matrix/matrix.go | 525 ++++++++++++++++++ internal/channels/matrix/matrix_test.go | 336 +++++++++++ internal/channels/mattermost/mattermost.go | 366 ++++++++++++ .../channels/mattermost/mattermost_test.go | 306 ++++++++++ internal/channels/signal/signal.go | 319 +++++++++++ internal/channels/signal/signal_test.go | 123 ++++ internal/gateway/config/config.go | 20 + internal/gateway/server/server.go | 28 + internal/gateway/state/state.go | 33 ++ 9 files changed, 2056 insertions(+) create mode 100644 internal/channels/matrix/matrix.go create mode 100644 internal/channels/matrix/matrix_test.go create mode 100644 internal/channels/mattermost/mattermost.go create mode 100644 internal/channels/mattermost/mattermost_test.go create mode 100644 internal/channels/signal/signal.go create mode 100644 internal/channels/signal/signal_test.go 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/signal/signal.go b/internal/channels/signal/signal.go new file mode 100644 index 0000000..c29e14f --- /dev/null +++ b/internal/channels/signal/signal.go @@ -0,0 +1,319 @@ +// 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" + "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 +} + +// 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{}, + } +} + +// 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 and forwards a usable message. +func (c *Channel) handleEvent(ctx context.Context, sink channels.Sink, raw []byte) { + inb, refs, ok := parseEnvelope(raw, c.account) + if !ok { + return + } + 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 +} + +// parseEnvelope normalizes a signal-cli receive envelope, or ok=false for +// receipts/typing/own messages/empty events. account is our own number. +func parseEnvelope(raw []byte, account string) (channels.Inbound, []attachmentRef, bool) { + var ev envelope + if err := json.Unmarshal(raw, &ev); err != nil { + return channels.Inbound{}, nil, false + } + env := ev.Envelope + dm := env.DataMessage + if dm == nil { + return channels.Inbound{}, nil, false // receipt/typing/sync — not a message + } + // Principal: the E.164 number when known (the identity users recognize and + // allow-list), the uuid otherwise. Our own messages are skipped (loops). + principal := env.SourceNumber + if principal == "" { + principal = env.SourceUUID + } + if principal == "" || principal == account { + return channels.Inbound{}, 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{}, nil, false + } + isDirect := dm.GroupInfo == nil || dm.GroupInfo.GroupID == "" + conversation := principal + if !isDirect { + conversation = groupPrefix + 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 + } + } + return channels.Inbound{ + Channel: "signal", + Conversation: conversation, + Principal: principal, + Text: dm.Message, + // Signal's message identity is (sender, timestamp) — that pair is what + // receipts and quotes reference, so it's the stable dedup key. + MessageID: fmt.Sprintf("%s:%d", principal, env.Timestamp), + IsDirect: isDirect, + Mentioned: mentioned, + }, 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..849c93d --- /dev/null +++ b/internal/channels/signal/signal_test.go @@ -0,0 +1,123 @@ +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 TestParseEnvelopeDM(t *testing.T) { + inb, refs, ok := parseEnvelope([]byte(dmEnvelope), "+15550009999") + if !ok { + t.Fatal("parse failed") + } + if inb.Channel != "signal" || inb.Principal != "+15551230000" || inb.Conversation != "+15551230000" { + t.Errorf("inbound = %+v", inb) + } + if !inb.IsDirect || inb.Mentioned { + t.Errorf("gating = %+v", inb) + } + if inb.MessageID != "+15551230000:1700000000001" { + t.Errorf("MessageID = %q (dedup key is sender:timestamp)", inb.MessageID) + } + if len(refs) != 0 { + t.Errorf("refs = %v", refs) + } +} + +func TestParseEnvelopeGroupAndMentions(t *testing.T) { + raw := `{"envelope":{"sourceNumber":"+15551230000","timestamp":2, + "dataMessage":{"message":"@bot do it","groupInfo":{"groupId":"g99"}, + "mentions":[{"number":"+15550009999"}]}}}` + inb, _, ok := parseEnvelope([]byte(raw), "+15550009999") + if !ok { + t.Fatal("parse failed") + } + 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, _, _ = parseEnvelope([]byte(raw), "+15550009999") + 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/gateway/config/config.go b/internal/gateway/config/config.go index 933fe7e..8f8d7f8 100644 --- a/internal/gateway/config/config.go +++ b/internal/gateway/config/config.go @@ -40,6 +40,17 @@ const ( 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" ) // Settings is the NON-secret gateway configuration (gateway.yaml). A channel's @@ -360,5 +371,14 @@ func EnabledChannels() []string { 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") + } return names } diff --git a/internal/gateway/server/server.go b/internal/gateway/server/server.go index a185249..fed4af6 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" @@ -26,6 +27,9 @@ import ( "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" + 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" @@ -564,9 +568,33 @@ func channelsFrom(settings gwconfig.Settings, gw *state.Store, mediaDir string, } 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. diff --git a/internal/gateway/state/state.go b/internal/gateway/state/state.go index ed83550..bf0b162 100644 --- a/internal/gateway/state/state.go +++ b/internal/gateway/state/state.go @@ -46,6 +46,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. @@ -327,6 +335,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) { From 4c78f3ca06935031466186ff6baaef4197b886e7 Mon Sep 17 00:00:00 2001 From: Tim Erwin Date: Sat, 15 Aug 2026 01:50:03 +0700 Subject: [PATCH 05/13] gateway: SMS channel (Twilio) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tactical lane — alerts, approvals, short tasks from a phone. Inbound webhook with HMAC-SHA1 signature verification over the exact configured public URL (channels.sms.webhook_url; fail closed without it), empty TwiML ack only after the durable record, 503 so Twilio retries when the record fails. MMS media downloads at receipt (URLs expire fast) with basic auth into the spool. Outbound via the Messages API, chunked at 1500. Principal/conversation = the sender's E.164 number. Also lands the env keys for the Teams and Google Chat adapters that follow. --- internal/gateway/config/config.go | 28 ++++ internal/gateway/server/server.go | 19 +++ internal/triggers/sms/sms.go | 215 ++++++++++++++++++++++++++++++ internal/triggers/sms/sms_test.go | 139 +++++++++++++++++++ 4 files changed, 401 insertions(+) create mode 100644 internal/triggers/sms/sms.go create mode 100644 internal/triggers/sms/sms_test.go diff --git a/internal/gateway/config/config.go b/internal/gateway/config/config.go index 8f8d7f8..3988f93 100644 --- a/internal/gateway/config/config.go +++ b/internal/gateway/config/config.go @@ -51,6 +51,18 @@ const ( // 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 @@ -208,6 +220,13 @@ type Channel struct { // 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"` } // Get returns the settings for a channel (a zero Channel if unset), so callers @@ -380,5 +399,14 @@ func EnabledChannels() []string { 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/server.go b/internal/gateway/server/server.go index fed4af6..169b84d 100644 --- a/internal/gateway/server/server.go +++ b/internal/gateway/server/server.go @@ -38,6 +38,7 @@ 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/sms" "github.com/memcode-ai/memcode/internal/triggers/whatsapp" ) @@ -624,6 +625,24 @@ func startWebhooks(ctx context.Context, settings gwconfig.Settings, rt *runtime, } } + // 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 { diff --git a/internal/triggers/sms/sms.go b/internal/triggers/sms/sms.go new file mode 100644 index 0000000..698b2a7 --- /dev/null +++ b/internal/triggers/sms/sms.go @@ -0,0 +1,215 @@ +// 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 + 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}, + 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 { + 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.client.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) + } +} From 6aa594be75a9963f28813312891e34a44e002c10 Mon Sep 17 00:00:00 2001 From: Tim Erwin Date: Sat, 15 Aug 2026 01:53:00 +0700 Subject: [PATCH 06/13] gateway: Microsoft Teams and Google Chat channels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workplace bets, both riding the existing webhook mux. Teams (Bot Framework): inbound activities validated against the Bot Framework JWKS (RS256, issuer/audience/expiry; cached keys, bounded refresh on unknown kid) — 401 before anything is parsed. Replies go to the activity's serviceUrl with a cached Azure AD client-credentials token; the conversation string encodes id|serviceUrl so a durable reply survives a restart. DM via conversationType, mentions via entities, tags stripped structurally. 200 after durable record, 503 retries. Google Chat: inbound events carry a Google-signed JWT verified against Google's JWKS with a stdlib RSA verify (aud = project number, channels.googlechat.audience). Replies via spaces.messages.create as the service account (GOOGLE_CHAT_SA_KEY). argumentText preferred (mention- stripped), DM/space detection, BOT senders skipped, async replies with {} synchronous ack. Deps guard-homed: golang-jwt→msteams, x/oauth2→googlechat. --- go.mod | 5 +- internal/channels/msteams/msteams.go | 468 ++++++++++++++++++ internal/channels/msteams/msteams_test.go | 321 ++++++++++++ internal/gateway/server/server.go | 30 ++ internal/triggers/googlechat/googlechat.go | 425 ++++++++++++++++ .../triggers/googlechat/googlechat_test.go | 323 ++++++++++++ 6 files changed, 1570 insertions(+), 2 deletions(-) create mode 100644 internal/channels/msteams/msteams.go create mode 100644 internal/channels/msteams/msteams_test.go create mode 100644 internal/triggers/googlechat/googlechat.go create mode 100644 internal/triggers/googlechat/googlechat_test.go diff --git a/go.mod b/go.mod index 9ccea91..c10ce8a 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,8 @@ require ( 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 @@ -25,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 @@ -61,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 @@ -87,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/internal/channels/msteams/msteams.go b/internal/channels/msteams/msteams.go new file mode 100644 index 0000000..c1ed89f --- /dev/null +++ b/internal/channels/msteams/msteams.go @@ -0,0 +1,468 @@ +// 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" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "math/big" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/memcode-ai/memcode/internal/channels" +) + +// botFrameworkIssuer is the issuer every Bot Framework connector token carries. +const botFrameworkIssuer = "https://api.botframework.com" + +// 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 + metadataURL string // Bot Framework OpenID metadata; overridable in tests + tokenBase string // Azure AD token endpoint base; overridable in tests + client *http.Client + + // keysMu guards the JWKS cache. Keys are fetched lazily and refreshed at + // most once per request when an unknown kid arrives (Microsoft rotates + // signing keys), so a flood of bad tokens can't hammer the metadata host. + keysMu sync.Mutex + keys map[string]*rsa.PublicKey + + // 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 { + return &Channel{ + appID: appID, + appPassword: appPassword, + tenantID: tenantID, + mediaDir: mediaDir, + metadataURL: defaultMetadataURL, + tokenBase: defaultTokenBase, + client: &http.Client{Timeout: 30 * time.Second}, + } +} + +// 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.validateJWT(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 +} + +func (c *Channel) downloadOne(ctx context.Context, contentURL, mimeType, name string) (channels.Attachment, error) { + bearer, _ := c.token(ctx) // best-effort; an unauthenticated fetch may still work + 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.client.Do(req) +} + +// validateJWT verifies an inbound Bot Framework token: RS256 signature against +// the published JWKS, the Bot Framework issuer, our app id as audience, and an +// unexpired lifetime. An unknown kid triggers at most ONE JWKS refresh for +// this request — key rotation is handled, a forged-kid flood is not amplified. +func (c *Channel) validateJWT(ctx context.Context, raw string) error { + 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 := c.cachedKey(kid); k != nil { + return k, nil + } + if !refreshed { + refreshed = true + if err := c.refreshKeys(ctx); err != nil { + return nil, err + } + if k := c.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(botFrameworkIssuer), + jwt.WithAudience(c.appID), + jwt.WithExpirationRequired(), + ) + return err +} + +func (c *Channel) cachedKey(kid string) *rsa.PublicKey { + c.keysMu.Lock() + defer c.keysMu.Unlock() + return c.keys[kid] +} + +// refreshKeys fetches the OpenID metadata, follows jwks_uri, and replaces the +// key cache. Replacing (not merging) means revoked keys actually leave. +func (c *Channel) refreshKeys(ctx context.Context) error { + var meta struct { + JWKSURI string `json:"jwks_uri"` + } + if err := c.getJSON(ctx, c.metadataURL, &meta); err != nil { + return fmt.Errorf("openid metadata: %w", err) + } + if meta.JWKSURI == "" { + return errors.New("openid metadata has no jwks_uri") + } + 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 := c.getJSON(ctx, meta.JWKSURI, &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") + } + c.keysMu.Lock() + c.keys = keys + c.keysMu.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 (c *Channel) getJSON(ctx context.Context, u string, v any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return err + } + resp, err := c.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, maxBody)).Decode(v) +} + +// 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) + } + 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..3d7c4a7 --- /dev/null +++ b/internal/channels/msteams/msteams_test.go @@ -0,0 +1,321 @@ +package msteams + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "errors" + "io" + "math/big" + "net/http" + "net/http/httptest" + "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 Channel.metadataURL. +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.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 + + 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") + } +} diff --git a/internal/gateway/server/server.go b/internal/gateway/server/server.go index 169b84d..62e6286 100644 --- a/internal/gateway/server/server.go +++ b/internal/gateway/server/server.go @@ -29,6 +29,7 @@ import ( "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" @@ -38,6 +39,7 @@ 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" ) @@ -625,6 +627,34 @@ 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)) diff --git a/internal/triggers/googlechat/googlechat.go b/internal/triggers/googlechat/googlechat.go new file mode 100644 index 0000000..c450fa0 --- /dev/null +++ b/internal/triggers/googlechat/googlechat.go @@ -0,0 +1,425 @@ +// 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" + "crypto" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "math/big" + "net/http" + "strings" + "sync" + "time" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" + + "github.com/memcode-ai/memcode/internal/channels" +) + +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 + + apiBase string // Chat REST base; overridable in tests + jwksURL string // Google cert endpoint; overridable in tests + + // 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 + + // keys caches Google's JWKS by kid; refreshed at most once per request when + // an unknown kid appears (Google rotates keys). + keys map[string]*rsa.PublicKey + keysMu 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 { + return &Channel{ + saKeyJSON: saKeyJSON, + audience: audience, + mediaDir: mediaDir, + client: &http.Client{Timeout: 30 * time.Second}, + apiBase: defaultAPIBase, + jwksURL: defaultJWKSURL, + } +} + +// 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.verifyJWT(r.Context(), token) { + 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 + } + req.Header.Set("Authorization", "Bearer "+bearer) + resp, err := c.client.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 +} + +// --- inbound JWT verification (stdlib JWKS, no new module deps) --- + +// verifyJWT checks a Google-signed RS256 bearer: signature against Google's +// published JWKS, issuer chat@system.gserviceaccount.com, audience equal to +// the app's project number, and unexpired. +func (c *Channel) verifyJWT(ctx context.Context, token string) bool { + if c.audience == "" { + return false // no configured audience can never verify — reject, don't trust + } + parts := strings.Split(token, ".") + if len(parts) != 3 { + return false + } + headerRaw, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return false + } + var header struct { + Alg string `json:"alg"` + Kid string `json:"kid"` + } + if err := json.Unmarshal(headerRaw, &header); err != nil || header.Alg != "RS256" { + return false + } + claimsRaw, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return false + } + var claims struct { + Iss string `json:"iss"` + Aud string `json:"aud"` + Exp int64 `json:"exp"` + } + if err := json.Unmarshal(claimsRaw, &claims); err != nil { + return false + } + if claims.Iss != chatIssuer || claims.Aud != c.audience || time.Now().Unix() >= claims.Exp { + return false + } + sig, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil { + return false + } + pub, err := c.publicKey(ctx, header.Kid) + if err != nil { + return false + } + sum := sha256.Sum256([]byte(parts[0] + "." + parts[1])) + return rsa.VerifyPKCS1v15(pub, crypto.SHA256, sum[:], sig) == nil +} + +// publicKey resolves a kid from the JWKS cache, refreshing from Google at most +// once per lookup when the kid is unknown (key rotation). +func (c *Channel) publicKey(ctx context.Context, kid string) (*rsa.PublicKey, error) { + c.keysMu.Lock() + defer c.keysMu.Unlock() + if pub, ok := c.keys[kid]; ok { + return pub, nil + } + keys, err := c.fetchJWKS(ctx) + if err != nil { + return nil, err + } + c.keys = keys + if pub, ok := c.keys[kid]; ok { + return pub, nil + } + return nil, fmt.Errorf("googlechat jwt: unknown key id %q", kid) +} + +// fetchJWKS pulls Google's cert set and parses the RSA keys (n/e per RFC 7517). +func (c *Channel) fetchJWKS(ctx context.Context) (map[string]*rsa.PublicKey, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.jwksURL, nil) + if err != nil { + return nil, err + } + 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("googlechat jwks: status %d", resp.StatusCode) + } + var doc struct { + Keys []struct { + Kty string `json:"kty"` + Kid string `json:"kid"` + N string `json:"n"` + E string `json:"e"` + } `json:"keys"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, maxBody)).Decode(&doc); err != nil { + return nil, err + } + out := make(map[string]*rsa.PublicKey, len(doc.Keys)) + for _, k := range doc.Keys { + if k.Kty != "RSA" || k.Kid == "" { + continue + } + nb, err := base64.RawURLEncoding.DecodeString(k.N) + if err != nil { + continue + } + eb, err := base64.RawURLEncoding.DecodeString(k.E) + if err != nil || len(eb) == 0 || len(eb) > 8 { + continue + } + e := 0 + for _, b := range eb { + e = e<<8 | int(b) + } + if e <= 1 { + continue + } + out[k.Kid] = &rsa.PublicKey{N: new(big.Int).SetBytes(nb), E: e} + } + return out, nil +} diff --git a/internal/triggers/googlechat/googlechat_test.go b/internal/triggers/googlechat/googlechat_test.go new file mode 100644 index 0000000..fb665ad --- /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.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") + } +} From f49b32fd587a4587392e0faacb7af61c7800332d Mon Sep 17 00:00:00 2001 From: Tim Erwin Date: Sat, 15 Aug 2026 01:55:21 +0700 Subject: [PATCH 07/13] gateway: TTS voice replies (opt-in, default off) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit channels..voice_replies: off (default — voice output costs money and speaks replies aloud, so it's a deliberate opt-in) | in_kind (voice note in → voice reply out) | always. The Speak seam lives in the openai provider home (gpt-4o-mini-tts, opus output — no ffmpeg anywhere); no key → silently text-only. The spoken rendition drops code blocks and caps at ~600 runes; the full text reply is ALWAYS sent alongside, and any synthesis/upload failure degrades to text — a reply is never lost to TTS. Carriers: Telegram sendVoice (native voice bubble), WhatsApp media upload + audio message, Discord file upload, Signal attachment, Matrix m.audio (already in their adapters). --- internal/channels/discord/discord.go | 10 +++- internal/channels/telegram/telegram.go | 49 ++++++++++++++- internal/gateway/config/config.go | 6 ++ internal/gateway/server/media.go | 80 +++++++++++++++++++++++++ internal/gateway/server/media_test.go | 52 ++++++++++++++++ internal/gateway/server/server.go | 5 +- internal/triggers/whatsapp/whatsapp.go | 82 +++++++++++++++++++++++++- 7 files changed, 280 insertions(+), 4 deletions(-) diff --git a/internal/channels/discord/discord.go b/internal/channels/discord/discord.go index f526646..9c9991b 100644 --- a/internal/channels/discord/discord.go +++ b/internal/channels/discord/discord.go @@ -10,6 +10,7 @@ package discord import ( "context" "net/http" + "os" "strings" "time" @@ -148,8 +149,15 @@ func (c *Channel) download(ctx context.Context, atts []*discordgo.MessageAttachm } // 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/telegram/telegram.go b/internal/channels/telegram/telegram.go index 27450c3..1b1a1f2 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" @@ -402,8 +405,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 @@ -412,6 +420,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/gateway/config/config.go b/internal/gateway/config/config.go index 3988f93..9184e8a 100644 --- a/internal/gateway/config/config.go +++ b/internal/gateway/config/config.go @@ -227,6 +227,12 @@ type Channel struct { // 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 diff --git a/internal/gateway/server/media.go b/internal/gateway/server/media.go index b413f9f..9b4814d 100644 --- a/internal/gateway/server/media.go +++ b/internal/gateway/server/media.go @@ -1,6 +1,7 @@ package server import ( + "bytes" "context" "fmt" "os" @@ -9,6 +10,7 @@ import ( "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" ) @@ -33,6 +35,84 @@ func newTranscriber() transcriber { 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 path ("" = text +// only). 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.Path +} + +// 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 { diff --git a/internal/gateway/server/media_test.go b/internal/gateway/server/media_test.go index 3671f6c..49bc205 100644 --- a/internal/gateway/server/media_test.go +++ b/internal/gateway/server/media_test.go @@ -7,6 +7,9 @@ import ( "path/filepath" "strings" "testing" + + gwconfig "github.com/memcode-ai/memcode/internal/gateway/config" + "github.com/memcode-ai/memcode/internal/gateway/state" ) type fakeSTT struct{ text string } @@ -47,3 +50,52 @@ func TestTranscribeAudioComposesTask(t *testing.T) { 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) + } + if _, err := os.Stat(p); 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/server.go b/internal/gateway/server/server.go index 62e6286..5039d06 100644 --- a/internal/gateway/server/server.go +++ b/internal/gateway/server/server.go @@ -73,6 +73,7 @@ type runtime struct { 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 @@ -122,6 +123,7 @@ func Run(ctx context.Context, root string, mainStore store.Store, settings gwcon settings: settings, mediaDir: mediaDir, stt: newTranscriber(), + tts: newSpeaker(), byName: make(map[string]replySender, 4), disp: newDispatcher(), out: out, @@ -507,6 +509,7 @@ func (r *runtime) deliverReply(ctx context.Context, it state.Item, reply string) if strings.TrimSpace(reply) == "" { reply = "Done." } + out := channels.Outbound{Text: reply, VoicePath: r.maybeSpeak(ctx, it, reply)} var sendErr error for attempt := 0; attempt < 3; attempt++ { if attempt > 0 { @@ -516,7 +519,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 } } diff --git a/internal/triggers/whatsapp/whatsapp.go b/internal/triggers/whatsapp/whatsapp.go index 5c84c5f..d90c86f 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" @@ -279,8 +282,13 @@ func (c *Channel) downloadOne(ctx context.Context, m waMedia) (channels.Attachme // 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. +// 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 @@ -289,6 +297,78 @@ func (c *Channel) Send(ctx context.Context, conversation string, msg channels.Ou 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", From 95895fee5730e82c5a8745bbdd650ac669e12512 Mon Sep 17 00:00:00 2001 From: Tim Erwin Date: Sat, 15 Aug 2026 01:58:10 +0700 Subject: [PATCH 08/13] gateway: setup wizard + docs for the channel baseline One wizard case per new channel (email, signal, matrix, mattermost, msteams, googlechat, sms), merging into existing channel blocks as always. README: full channel table with transports and env keys, the webhook mount map, email dedup note, and a Media and voice section. --- cmd/gateway.go | 42 ++++++++++++++++++++++++++++++++-- docs/gateway/README.md | 51 ++++++++++++++++++++++++++++++++++-------- 2 files changed, 82 insertions(+), 11 deletions(-) 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/docs/gateway/README.md b/docs/gateway/README.md index d2b6c3f..788fd8b 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,28 @@ 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. 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 +152,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: From e08405d440518e8313350ec4702d1e6f12a0f34c Mon Sep 17 00:00:00 2001 From: Tim Erwin Date: Sat, 15 Aug 2026 02:10:52 +0700 Subject: [PATCH 09/13] =?UTF-8?q?gateway:=20review=20fixes=20=E2=80=94=20r?= =?UTF-8?q?efuse=20unresolvable=20projects,=20durable=20one-shot=20TTS,=20?= =?UTF-8?q?uuid=20Signal=20principals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1: a queued task whose snapshotted project was disabled or deleted before execution is now REFUSED with a durable reply — ResolveProject failure no longer falls back to the gateway default root, closing the contradiction with the 'never helpfully run elsewhere' claim. All refusal paths share one helper; regression test added. P2 (TTS): voice replies are synthesized exactly ONCE, at job completion, and the spool ID is persisted on the replied row — a delivery retry or a restart re-sends the same file instead of re-billing TTS ('always') or losing the in_kind decision (attachments aren't loaded by the replay). P2 (Signal): the principal is now the account UUID — Signal's stable identity — with the phone number only as fallback; numbers change hands. Pairing keeps uuids painless to allow-list. P2/P3 (email): the From-address identity caveat is now stated in the package doc and both docs (allow-list strength depends on the provider's SPF/DKIM/DMARC filtering; dedicated mainstream account advised); explicit Authentication-Results enforcement is a tracked follow-up. --- docs/gateway/README.md | 5 +- internal/channels/email/email.go | 8 +++ internal/channels/signal/signal.go | 12 ++-- internal/channels/signal/signal_test.go | 5 +- internal/gateway/server/media.go | 10 ++-- internal/gateway/server/media_test.go | 6 +- internal/gateway/server/reply_test.go | 2 +- internal/gateway/server/selection_test.go | 40 +++++++++++++ internal/gateway/server/server.go | 68 +++++++++++++---------- internal/gateway/state/state.go | 23 +++++--- internal/gateway/state/state_test.go | 4 +- 11 files changed, 128 insertions(+), 55 deletions(-) diff --git a/docs/gateway/README.md b/docs/gateway/README.md index 788fd8b..15bd3af 100644 --- a/docs/gateway/README.md +++ b/docs/gateway/README.md @@ -50,7 +50,10 @@ 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. Signal requires a signal-cli +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). diff --git a/internal/channels/email/email.go b/internal/channels/email/email.go index f4ffac0..6030515 100644 --- a/internal/channels/email/email.go +++ b/internal/channels/email/email.go @@ -9,6 +9,14 @@ // 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 ( diff --git a/internal/channels/signal/signal.go b/internal/channels/signal/signal.go index c29e14f..250b817 100644 --- a/internal/channels/signal/signal.go +++ b/internal/channels/signal/signal.go @@ -174,13 +174,15 @@ func parseEnvelope(raw []byte, account string) (channels.Inbound, []attachmentRe if dm == nil { return channels.Inbound{}, nil, false // receipt/typing/sync — not a message } - // Principal: the E.164 number when known (the identity users recognize and - // allow-list), the uuid otherwise. Our own messages are skipped (loops). - principal := env.SourceNumber + // Principal: the account UUID — Signal's STABLE identity. A phone number can + // change hands or be re-registered, so it is only the fallback when the + // daemon didn't surface a uuid. The pairing flow makes uuids painless to + // allow-list (nobody has to type one). Our own messages are skipped (loops). + principal := env.SourceUUID if principal == "" { - principal = env.SourceUUID + principal = env.SourceNumber } - if principal == "" || principal == account { + if principal == "" || env.SourceNumber == account || principal == account { return channels.Inbound{}, nil, false } var refs []attachmentRef diff --git a/internal/channels/signal/signal_test.go b/internal/channels/signal/signal_test.go index 849c93d..9fe577f 100644 --- a/internal/channels/signal/signal_test.go +++ b/internal/channels/signal/signal_test.go @@ -19,13 +19,14 @@ func TestParseEnvelopeDM(t *testing.T) { if !ok { t.Fatal("parse failed") } - if inb.Channel != "signal" || inb.Principal != "+15551230000" || inb.Conversation != "+15551230000" { + // 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 != "+15551230000:1700000000001" { + if inb.MessageID != "uuid-1:1700000000001" { t.Errorf("MessageID = %q (dedup key is sender:timestamp)", inb.MessageID) } if len(refs) != 0 { diff --git a/internal/gateway/server/media.go b/internal/gateway/server/media.go index 9b4814d..9286d53 100644 --- a/internal/gateway/server/media.go +++ b/internal/gateway/server/media.go @@ -52,9 +52,11 @@ func newSpeaker() speaker { } // maybeSpeak synthesizes a voice rendition of a reply when the channel's -// voice_replies policy asks for one, returning the spool path ("" = text -// only). 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. +// 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 "" @@ -87,7 +89,7 @@ func (r *runtime) maybeSpeak(ctx context.Context, it state.Item, reply string) s if err != nil { return "" } - return att.Path + return att.ID() } // spokenSummary renders a reply as speakable text: code blocks dropped (nobody diff --git a/internal/gateway/server/media_test.go b/internal/gateway/server/media_test.go index 49bc205..7247a41 100644 --- a/internal/gateway/server/media_test.go +++ b/internal/gateway/server/media_test.go @@ -8,6 +8,7 @@ import ( "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" ) @@ -79,7 +80,10 @@ func TestMaybeSpeakPolicy(t *testing.T) { if p == "" || tts.called != 1 { t.Fatalf("in_kind with voice note: %q %d", p, tts.called) } - if _, err := os.Stat(p); err != nil { + // 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"}}} 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 5039d06..35320b0 100644 --- a/internal/gateway/server/server.go +++ b/internal/gateway/server/server.go @@ -419,28 +419,22 @@ 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 @@ -449,12 +443,7 @@ func (r *runtime) runJob(ctx context.Context, it state.Item) { // an empty task. task, rest, sttMissing := r.transcribeAudio(ctx, it.Text, it.Attachments) if strings.TrimSpace(task) == "" && sttMissing { - msg := "Voice note received, but no transcription provider is configured. Set OPENAI_API_KEY or GEMINI_API_KEY on the gateway machine, or send text." - if serr := r.gw.SetReplied(ctx, it.Channel, it.MessageID, msg); serr != nil { - fmt.Fprintf(r.out, "gateway: recording voice-note refusal for %s: %v\n", it.Channel, serr) - return - } - r.deliverReply(ctx, it, msg) + 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 } it.Text = task @@ -471,12 +460,7 @@ func (r *runtime) runJob(ctx context.Context, it state.Item) { 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}) @@ -486,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 @@ -509,7 +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, VoicePath: r.maybeSpeak(ctx, it, reply)} + 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 { diff --git a/internal/gateway/state/state.go b/internal/gateway/state/state.go index bf0b162..7c33b56 100644 --- a/internal/gateway/state/state.go +++ b/internal/gateway/state/state.go @@ -36,6 +36,7 @@ CREATE TABLE IF NOT EXISTS inbox ( 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) ); @@ -111,6 +112,7 @@ type Item struct { 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. @@ -162,6 +164,7 @@ func Open(ctx context.Context, dir string) (*Store, error) { `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() @@ -258,14 +261,16 @@ func (s *Store) Pending(ctx context.Context) ([]Item, error) { 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 } @@ -274,7 +279,7 @@ 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 + `SELECT channel, message_id, conversation, principal, text, trusted, reply, voice FROM inbox WHERE status = 'replied' ORDER BY received_at`) if err != nil { return nil, fmt.Errorf("pending replies: %w", err) @@ -284,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 diff --git a/internal/gateway/state/state_test.go b/internal/gateway/state/state_test.go index 8135bc3..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) From e21b796e1981db8c53780fe27001256c6d21b35d Mon Sep 17 00:00:00 2001 From: Tim Erwin Date: Sat, 15 Aug 2026 02:22:07 +0700 Subject: [PATCH 10/13] gateway: one JWT verifier, guard the new deps, email hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - internal/webjwt is now the ONE verifier for platform-signed inbound webhook tokens: RS256 via golang-jwt against a JWKS (direct URL or resolved through OpenID metadata), iss/aud/exp enforced, cached keys with one bounded refresh per unknown kid. Teams and Google Chat both use it — the hand-rolled Google Chat verifier is gone, and two divergent JWT implementations can't drift apart. Both adapters' JWT accept/reject test matrices now exercise the shared code from both configurations (metadata path and direct-JWKS path). - Guard map actually gains the new deps this time (the previous edit silently missed its anchor): golang-jwt→webjwt, gorilla/websocket→mattermost, x/oauth2→googlechat. - Email: attachment spooling uses bytes.NewReader (no 25 MiB string copy); reply threading headers and To are CRLF-stripped as defense in depth on top of mail.ReadMessage's parsing (injection test added). - SMS: comment documents that duplicate form keys would fail closed (signature mismatch), never bypass. --- internal/channels/email/compose.go | 23 ++- internal/channels/email/email.go | 3 +- internal/channels/email/email_test.go | 15 ++ internal/channels/msteams/msteams.go | 141 ++------------ internal/channels/msteams/msteams_test.go | 4 +- internal/guard/guard_test.go | 3 + internal/triggers/googlechat/googlechat.go | 147 ++------------- .../triggers/googlechat/googlechat_test.go | 2 +- internal/triggers/sms/sms.go | 3 + internal/webjwt/webjwt.go | 174 ++++++++++++++++++ 10 files changed, 242 insertions(+), 273 deletions(-) create mode 100644 internal/webjwt/webjwt.go diff --git a/internal/channels/email/compose.go b/internal/channels/email/compose.go index af751a0..328cd6f 100644 --- a/internal/channels/email/compose.go +++ b/internal/channels/email/compose.go @@ -22,14 +22,17 @@ func composeReply(from, to string, th threadInfo, body string) []byte { } var b strings.Builder fmt.Fprintf(&b, "From: %s\r\n", from) - fmt.Fprintf(&b, "To: %s\r\n", to) + 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)) - if th.last != "" { - fmt.Fprintf(&b, "In-Reply-To: %s\r\n", th.last) + // 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 := threadReferences(th); refs != "" { + if refs := stripCRLF(threadReferences(th)); refs != "" { fmt.Fprintf(&b, "References: %s\r\n", refs) } b.WriteString("MIME-Version: 1.0\r\n") @@ -42,6 +45,18 @@ func composeReply(from, to string, th threadInfo, body string) []byte { 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 == "": diff --git a/internal/channels/email/email.go b/internal/channels/email/email.go index 6030515..28b3e5f 100644 --- a/internal/channels/email/email.go +++ b/internal/channels/email/email.go @@ -20,6 +20,7 @@ package email import ( + "bytes" "context" "fmt" "strings" @@ -200,7 +201,7 @@ func (c *Channel) toInbound(msg parsedMessage, uidValidity uint32, uid imap.UID) func (c *Channel) spoolAttachments(msg parsedMessage) []channels.Attachment { var out []channels.Attachment for _, a := range msg.attachments { - att, err := channels.SaveToSpool(c.mediaDir, strings.NewReader(string(a.data)), a.mime, a.name) + att, err := channels.SaveToSpool(c.mediaDir, bytes.NewReader(a.data), a.mime, a.name) if err != nil { continue } diff --git a/internal/channels/email/email_test.go b/internal/channels/email/email_test.go index 1ae4fae..ba942c3 100644 --- a/internal/channels/email/email_test.go +++ b/internal/channels/email/email_test.go @@ -195,3 +195,18 @@ func TestSendUsesThread(t *testing.T) { 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/msteams/msteams.go b/internal/channels/msteams/msteams.go index c1ed89f..e551035 100644 --- a/internal/channels/msteams/msteams.go +++ b/internal/channels/msteams/msteams.go @@ -11,21 +11,18 @@ package msteams import ( "bytes" "context" - "crypto/rsa" - "encoding/base64" "encoding/json" "errors" "fmt" "io" - "math/big" "net/http" "net/url" "strings" "sync" "time" - "github.com/golang-jwt/jwt/v5" "github.com/memcode-ai/memcode/internal/channels" + "github.com/memcode-ai/memcode/internal/webjwt" ) // botFrameworkIssuer is the issuer every Bot Framework connector token carries. @@ -54,15 +51,9 @@ type Channel struct { appPassword string tenantID string mediaDir string // media spool; "" disables inbound media downloads - metadataURL string // Bot Framework OpenID metadata; overridable in tests tokenBase string // Azure AD token endpoint base; overridable in tests client *http.Client - - // keysMu guards the JWKS cache. Keys are fetched lazily and refreshed at - // most once per request when an unknown kid arrives (Microsoft rotates - // signing keys), so a flood of bad tokens can't hammer the metadata host. - keysMu sync.Mutex - keys map[string]*rsa.PublicKey + 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. @@ -75,14 +66,20 @@ type Channel struct { // 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{ appID: appID, appPassword: appPassword, tenantID: tenantID, mediaDir: mediaDir, - metadataURL: defaultMetadataURL, tokenBase: defaultTokenBase, - client: &http.Client{Timeout: 30 * time.Second}, + client: client, + verify: &webjwt.Verifier{ + MetadataURL: defaultMetadataURL, + Issuer: botFrameworkIssuer, + Audience: appID, + Client: client, + }, } } @@ -129,7 +126,7 @@ func (c *Channel) Handler(sink channels.Sink) http.Handler { return } raw, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") - if !ok || c.validateJWT(r.Context(), raw) != nil { + 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) @@ -264,122 +261,6 @@ func (c *Channel) fetch(ctx context.Context, u, bearer string) (*http.Response, return c.client.Do(req) } -// validateJWT verifies an inbound Bot Framework token: RS256 signature against -// the published JWKS, the Bot Framework issuer, our app id as audience, and an -// unexpired lifetime. An unknown kid triggers at most ONE JWKS refresh for -// this request — key rotation is handled, a forged-kid flood is not amplified. -func (c *Channel) validateJWT(ctx context.Context, raw string) error { - 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 := c.cachedKey(kid); k != nil { - return k, nil - } - if !refreshed { - refreshed = true - if err := c.refreshKeys(ctx); err != nil { - return nil, err - } - if k := c.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(botFrameworkIssuer), - jwt.WithAudience(c.appID), - jwt.WithExpirationRequired(), - ) - return err -} - -func (c *Channel) cachedKey(kid string) *rsa.PublicKey { - c.keysMu.Lock() - defer c.keysMu.Unlock() - return c.keys[kid] -} - -// refreshKeys fetches the OpenID metadata, follows jwks_uri, and replaces the -// key cache. Replacing (not merging) means revoked keys actually leave. -func (c *Channel) refreshKeys(ctx context.Context) error { - var meta struct { - JWKSURI string `json:"jwks_uri"` - } - if err := c.getJSON(ctx, c.metadataURL, &meta); err != nil { - return fmt.Errorf("openid metadata: %w", err) - } - if meta.JWKSURI == "" { - return errors.New("openid metadata has no jwks_uri") - } - 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 := c.getJSON(ctx, meta.JWKSURI, &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") - } - c.keysMu.Lock() - c.keys = keys - c.keysMu.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 (c *Channel) getJSON(ctx context.Context, u string, v any) error { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) - if err != nil { - return err - } - resp, err := c.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, maxBody)).Decode(v) -} - // 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). diff --git a/internal/channels/msteams/msteams_test.go b/internal/channels/msteams/msteams_test.go index 3d7c4a7..3fcbede 100644 --- a/internal/channels/msteams/msteams_test.go +++ b/internal/channels/msteams/msteams_test.go @@ -37,7 +37,7 @@ func (s *recordingSink) Deliver(_ context.Context, inb channels.Inbound) error { } // jwksServer serves a fake Bot Framework OpenID config + JWKS for key. Returns -// the metadata URL to plug into Channel.metadataURL. +// the metadata URL to plug into the verifier. func jwksServer(t *testing.T, kid string, key *rsa.PrivateKey) string { t.Helper() mux := http.NewServeMux() @@ -80,7 +80,7 @@ func signToken(t *testing.T, key *rsa.PrivateKey, kid, iss, aud string) string { func newTestChannel(t *testing.T, metadataURL string) *Channel { t.Helper() c := New(testAppID, "secret", "tenant", "") - c.metadataURL = metadataURL + c.verify.MetadataURL = metadataURL return c } diff --git a/internal/guard/guard_test.go b/internal/guard/guard_test.go index 6e635aa..d4c0c21 100644 --- a/internal/guard/guard_test.go +++ b/internal/guard/guard_test.go @@ -89,6 +89,9 @@ var vendorSDKs = map[string]string{ "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/triggers/googlechat/googlechat.go b/internal/triggers/googlechat/googlechat.go index c450fa0..21ac374 100644 --- a/internal/triggers/googlechat/googlechat.go +++ b/internal/triggers/googlechat/googlechat.go @@ -12,14 +12,9 @@ package googlechat import ( "bytes" "context" - "crypto" - "crypto/rsa" - "crypto/sha256" - "encoding/base64" "encoding/json" "fmt" "io" - "math/big" "net/http" "strings" "sync" @@ -29,6 +24,7 @@ import ( "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" @@ -55,32 +51,33 @@ type Channel struct { mediaDir string // media spool; "" disables inbound attachment downloads client *http.Client - apiBase string // Chat REST base; overridable in tests - jwksURL string // Google cert endpoint; overridable in tests + 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 - - // keys caches Google's JWKS by kid; refreshed at most once per request when - // an unknown kid appears (Google rotates keys). - keys map[string]*rsa.PublicKey - keysMu 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: &http.Client{Timeout: 30 * time.Second}, + client: client, apiBase: defaultAPIBase, - jwksURL: defaultJWKSURL, + verify: &webjwt.Verifier{ + JWKSURL: defaultJWKSURL, + Issuer: chatIssuer, + Audience: audience, + Client: client, + }, } } @@ -101,7 +98,7 @@ func (c *Channel) Handler(sink channels.Sink) http.Handler { // 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.verifyJWT(r.Context(), token) { + if !ok || c.verify.Verify(r.Context(), token) != nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } @@ -303,123 +300,3 @@ func (c *Channel) source(ctx context.Context) (oauth2.TokenSource, error) { c.tokenSource = cfg.TokenSource(context.Background()) return c.tokenSource, nil } - -// --- inbound JWT verification (stdlib JWKS, no new module deps) --- - -// verifyJWT checks a Google-signed RS256 bearer: signature against Google's -// published JWKS, issuer chat@system.gserviceaccount.com, audience equal to -// the app's project number, and unexpired. -func (c *Channel) verifyJWT(ctx context.Context, token string) bool { - if c.audience == "" { - return false // no configured audience can never verify — reject, don't trust - } - parts := strings.Split(token, ".") - if len(parts) != 3 { - return false - } - headerRaw, err := base64.RawURLEncoding.DecodeString(parts[0]) - if err != nil { - return false - } - var header struct { - Alg string `json:"alg"` - Kid string `json:"kid"` - } - if err := json.Unmarshal(headerRaw, &header); err != nil || header.Alg != "RS256" { - return false - } - claimsRaw, err := base64.RawURLEncoding.DecodeString(parts[1]) - if err != nil { - return false - } - var claims struct { - Iss string `json:"iss"` - Aud string `json:"aud"` - Exp int64 `json:"exp"` - } - if err := json.Unmarshal(claimsRaw, &claims); err != nil { - return false - } - if claims.Iss != chatIssuer || claims.Aud != c.audience || time.Now().Unix() >= claims.Exp { - return false - } - sig, err := base64.RawURLEncoding.DecodeString(parts[2]) - if err != nil { - return false - } - pub, err := c.publicKey(ctx, header.Kid) - if err != nil { - return false - } - sum := sha256.Sum256([]byte(parts[0] + "." + parts[1])) - return rsa.VerifyPKCS1v15(pub, crypto.SHA256, sum[:], sig) == nil -} - -// publicKey resolves a kid from the JWKS cache, refreshing from Google at most -// once per lookup when the kid is unknown (key rotation). -func (c *Channel) publicKey(ctx context.Context, kid string) (*rsa.PublicKey, error) { - c.keysMu.Lock() - defer c.keysMu.Unlock() - if pub, ok := c.keys[kid]; ok { - return pub, nil - } - keys, err := c.fetchJWKS(ctx) - if err != nil { - return nil, err - } - c.keys = keys - if pub, ok := c.keys[kid]; ok { - return pub, nil - } - return nil, fmt.Errorf("googlechat jwt: unknown key id %q", kid) -} - -// fetchJWKS pulls Google's cert set and parses the RSA keys (n/e per RFC 7517). -func (c *Channel) fetchJWKS(ctx context.Context) (map[string]*rsa.PublicKey, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.jwksURL, nil) - if err != nil { - return nil, err - } - 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("googlechat jwks: status %d", resp.StatusCode) - } - var doc struct { - Keys []struct { - Kty string `json:"kty"` - Kid string `json:"kid"` - N string `json:"n"` - E string `json:"e"` - } `json:"keys"` - } - if err := json.NewDecoder(io.LimitReader(resp.Body, maxBody)).Decode(&doc); err != nil { - return nil, err - } - out := make(map[string]*rsa.PublicKey, len(doc.Keys)) - for _, k := range doc.Keys { - if k.Kty != "RSA" || k.Kid == "" { - continue - } - nb, err := base64.RawURLEncoding.DecodeString(k.N) - if err != nil { - continue - } - eb, err := base64.RawURLEncoding.DecodeString(k.E) - if err != nil || len(eb) == 0 || len(eb) > 8 { - continue - } - e := 0 - for _, b := range eb { - e = e<<8 | int(b) - } - if e <= 1 { - continue - } - out[k.Kid] = &rsa.PublicKey{N: new(big.Int).SetBytes(nb), E: e} - } - return out, nil -} diff --git a/internal/triggers/googlechat/googlechat_test.go b/internal/triggers/googlechat/googlechat_test.go index fb665ad..8fb7233 100644 --- a/internal/triggers/googlechat/googlechat_test.go +++ b/internal/triggers/googlechat/googlechat_test.go @@ -72,7 +72,7 @@ func jwksServer(t *testing.T, key *rsa.PrivateKey, kid string) *httptest.Server func testChannel(t *testing.T, jwks *httptest.Server) *Channel { t.Helper() c := New(nil, testAudience, "") - c.jwksURL = jwks.URL + c.verify.JWKSURL = jwks.URL return c } diff --git a/internal/triggers/sms/sms.go b/internal/triggers/sms/sms.go index 698b2a7..751b697 100644 --- a/internal/triggers/sms/sms.go +++ b/internal/triggers/sms/sms.go @@ -107,6 +107,9 @@ func (c *Channel) verifySignature(header string, form url.Values) bool { 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)) } diff --git a/internal/webjwt/webjwt.go b/internal/webjwt/webjwt.go new file mode 100644 index 0000000..8194b4a --- /dev/null +++ b/internal/webjwt/webjwt.go @@ -0,0 +1,174 @@ +// 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 — refreshed at +// most once per unknown kid, so key rotation works but a forged-kid flood +// isn't 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 +} + +// 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. +func (v *Verifier) refreshKeys(ctx context.Context) error { + 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) +} From 0876c23ab4bd1992c09c2d64b28e0b4d5a311987 Mon Sep 17 00:00:00 2001 From: Tim Erwin Date: Sat, 15 Aug 2026 02:42:28 +0700 Subject: [PATCH 11/13] gateway: security-audit fixes (SSRF, JWKS throttle, email/signal hardening) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial sweep cross-checked against Hermes/OpenClaw CVE history. No auth-bypass or RCE class reproduced (crypto core, stable-id principals, durable dedup, spool traversal all verified sound). Fixes: HIGH — media-download SSRF + credential leak. New channels.SafeHTTPClient blocks any fetch that resolves to loopback/private/link-local/CGNAT/ metadata (checked at dial on the resolved IP, so DNS names and redirects are caught too). Every message-derived download (Teams contentUrl, Google Chat downloadUri, WhatsApp media, Twilio MMS) routes through it. Teams additionally: the Azure connector bearer is attached ONLY to first-party Microsoft hosts, and an outbound reply is refused unless the serviceUrl is a Bot Framework host over https — so a forged serviceUrl/ contentUrl can't redirect the agent's output or harvest the token. This is exactly the class OpenClaw shipped dangerouslyAllowPrivateNetwork for. MED-HIGH — webjwt unknown-kid JWKS refetch is now throttled to one fetch per 30s (the package doc claimed this protection but the code lacked it); an unauthenticated forged-kid flood can no longer amplify into JWKS hammering. MED — email: cap parts (50), aggregate decoded bytes (50 MiB), and the raw IMAP fetch (60 MiB) so a crafted multipart can't exhaust memory; outbound replies now carry Auto-Submitted: auto-replied (loop prevention, matching the inbound filter). Signal: a learned number->uuid map keeps the dedup key and principal from flipping between uuid and number for the same account across deliveries (double-run guard). LOW — dispatcher retires idle per-conversation workers (goroutine/map leak over a long-lived daemon talking to many rooms); Telegram bot_command mention now matches @botusername on a token boundary, not a substring. --- internal/channels/email/compose.go | 3 + internal/channels/email/parse.go | 23 ++++-- internal/channels/email/transport.go | 12 ++- internal/channels/msteams/msteams.go | 52 +++++++++++- internal/channels/msteams/msteams_test.go | 37 +++++++++ internal/channels/safedial.go | 88 ++++++++++++++++++++ internal/channels/safedial_test.go | 67 +++++++++++++++ internal/channels/signal/signal.go | 96 +++++++++++++++------- internal/channels/signal/signal_test.go | 51 ++++++++++-- internal/channels/telegram/telegram.go | 6 +- internal/gateway/server/dispatch.go | 51 +++++++++--- internal/gateway/server/dispatch_test.go | 2 +- internal/triggers/googlechat/googlechat.go | 16 +++- internal/triggers/sms/sms.go | 6 +- internal/triggers/whatsapp/whatsapp.go | 6 +- internal/webjwt/webjwt.go | 28 +++++-- 16 files changed, 467 insertions(+), 77 deletions(-) create mode 100644 internal/channels/safedial.go create mode 100644 internal/channels/safedial_test.go diff --git a/internal/channels/email/compose.go b/internal/channels/email/compose.go index 328cd6f..2ae03ec 100644 --- a/internal/channels/email/compose.go +++ b/internal/channels/email/compose.go @@ -35,6 +35,9 @@ func composeReply(from, to string, th threadInfo, body string) []byte { 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") diff --git a/internal/channels/email/parse.go b/internal/channels/email/parse.go index 4afbf1c..abe75ed 100644 --- a/internal/channels/email/parse.go +++ b/internal/channels/email/parse.go @@ -29,8 +29,13 @@ type rawAttachment struct { data []byte } -// maxParsedAttachment caps a single decoded email attachment. -const maxParsedAttachment = 25 << 20 +// 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 @@ -61,7 +66,7 @@ func parseMessage(raw []byte) (parsedMessage, bool) { } } p.autoSubmit = strings.ToLower(strings.TrimSpace(m.Header.Get("Auto-Submitted"))) - walkPart(mailHeader(m.Header), m.Body, &p, 0) + walkPart(mailHeader(m.Header), m.Body, &p, 0, new(int64)) p.text = strings.TrimSpace(p.text) return p, true } @@ -88,8 +93,8 @@ func partHeader(p *multipart.Part) header { return func(k string) string { retur // 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) { - if depth > 8 { +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") @@ -107,11 +112,14 @@ func walkPart(h header, body io.Reader, p *parsedMessage, depth int) { } 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) + walkPart(partHeader(part), part, p, depth+1, agg) } } @@ -139,7 +147,8 @@ func walkPart(h header, body io.Reader, p *parsedMessage, depth int) { if dec, err := (&mime.WordDecoder{}).DecodeHeader(name); err == nil { name = dec } - if len(data) > 0 { + 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}) } } diff --git a/internal/channels/email/transport.go b/internal/channels/email/transport.go index 903a6c4..1967ea9 100644 --- a/internal/channels/email/transport.go +++ b/internal/channels/email/transport.go @@ -9,6 +9,10 @@ import ( "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) @@ -63,9 +67,13 @@ func (s *liveSession) FetchRaw(uid imap.UID) ([]byte, error) { } for _, m := range msgs { for _, bs := range m.BodySection { - if len(bs.Bytes) > 0 { - return bs.Bytes, nil + 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) diff --git a/internal/channels/msteams/msteams.go b/internal/channels/msteams/msteams.go index e551035..c0ea5cf 100644 --- a/internal/channels/msteams/msteams.go +++ b/internal/channels/msteams/msteams.go @@ -28,6 +28,17 @@ import ( // 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" @@ -53,7 +64,9 @@ type Channel struct { mediaDir string // media spool; "" disables inbound media downloads tokenBase string // Azure AD token endpoint base; overridable in tests client *http.Client - verify *webjwt.Verifier // the shared inbound-JWT verifier; tests point its MetadataURL at a fake + 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. @@ -68,6 +81,7 @@ type Channel struct { 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, @@ -228,8 +242,32 @@ func (c *Channel) download(ctx context.Context, act activity) []channels.Attachm 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) { - bearer, _ := c.token(ctx) // best-effort; an unauthenticated fetch may still work + // 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 @@ -258,7 +296,7 @@ func (c *Channel) fetch(ctx context.Context, u, bearer string) (*http.Response, if bearer != "" { req.Header.Set("Authorization", "Bearer "+bearer) } - return c.client.Do(req) + return c.dl.Do(req) } // token returns a valid outbound connector bearer, minting one via the Azure @@ -313,6 +351,14 @@ func (c *Channel) Send(ctx context.Context, conversation string, msg channels.Ou 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 diff --git a/internal/channels/msteams/msteams_test.go b/internal/channels/msteams/msteams_test.go index 3fcbede..9d46b02 100644 --- a/internal/channels/msteams/msteams_test.go +++ b/internal/channels/msteams/msteams_test.go @@ -11,6 +11,7 @@ import ( "math/big" "net/http" "net/http/httptest" + "net/url" "strings" "sync/atomic" "testing" @@ -279,6 +280,7 @@ func TestSendChunksAndCachesToken(t *testing.T) { 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 @@ -319,3 +321,38 @@ func TestSendMalformedConversation(t *testing.T) { 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 index 250b817..2df483f 100644 --- a/internal/channels/signal/signal.go +++ b/internal/channels/signal/signal.go @@ -21,6 +21,7 @@ import ( "os" "path/filepath" "strings" + "sync" "time" "github.com/memcode-ai/memcode/internal/channels" @@ -43,6 +44,14 @@ type Channel struct { 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. @@ -57,7 +66,23 @@ func New(baseURL, account, attachmentsDir, mediaDir string) *Channel { 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. @@ -145,12 +170,25 @@ func (c *Channel) stream(ctx context.Context, sink channels.Sink) error { return sc.Err() } -// handleEvent parses one SSE payload and forwards a usable message. +// 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, refs, ok := parseEnvelope(raw, c.account) + 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 } @@ -162,28 +200,31 @@ type attachmentRef struct { 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. -func parseEnvelope(raw []byte, account string) (channels.Inbound, []attachmentRef, bool) { +// 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{}, nil, false + return channels.Inbound{}, identity{}, nil, false } env := ev.Envelope dm := env.DataMessage if dm == nil { - return channels.Inbound{}, nil, false // receipt/typing/sync — not a message - } - // Principal: the account UUID — Signal's STABLE identity. A phone number can - // change hands or be re-registered, so it is only the fallback when the - // daemon didn't surface a uuid. The pairing flow makes uuids painless to - // allow-list (nobody has to type one). Our own messages are skipped (loops). - principal := env.SourceUUID - if principal == "" { - principal = env.SourceNumber + return channels.Inbound{}, identity{}, nil, false // receipt/typing/sync — not a message } - if principal == "" || env.SourceNumber == account || principal == account { - return channels.Inbound{}, nil, false + // 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 { @@ -193,13 +234,9 @@ func parseEnvelope(raw []byte, account string) (channels.Inbound, []attachmentRe refs = append(refs, attachmentRef{id: a.ID, mime: a.ContentType, name: a.Filename}) } if strings.TrimSpace(dm.Message) == "" && len(refs) == 0 { - return channels.Inbound{}, nil, false + return channels.Inbound{}, identity{}, nil, false } isDirect := dm.GroupInfo == nil || dm.GroupInfo.GroupID == "" - conversation := principal - if !isDirect { - conversation = groupPrefix + dm.GroupInfo.GroupID - } // Mentioned: an explicit @mention of our number, or a quote-reply to us. mentioned := false if account != "" { @@ -212,17 +249,16 @@ func parseEnvelope(raw []byte, account string) (channels.Inbound, []attachmentRe mentioned = true } } - return channels.Inbound{ - Channel: "signal", - Conversation: conversation, - Principal: principal, - Text: dm.Message, - // Signal's message identity is (sender, timestamp) — that pair is what - // receipts and quotes reference, so it's the stable dedup key. - MessageID: fmt.Sprintf("%s:%d", principal, env.Timestamp), + inb := channels.Inbound{ + Channel: "signal", + Text: dm.Message, IsDirect: isDirect, Mentioned: mentioned, - }, refs, true + } + 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 diff --git a/internal/channels/signal/signal_test.go b/internal/channels/signal/signal_test.go index 9fe577f..e80b195 100644 --- a/internal/channels/signal/signal_test.go +++ b/internal/channels/signal/signal_test.go @@ -14,10 +14,22 @@ import ( 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) { - inb, refs, ok := parseEnvelope([]byte(dmEnvelope), "+15550009999") + c := New("", "+15550009999", "", "") + inb, ok := deliverOne(t, c, dmEnvelope) if !ok { - t.Fatal("parse failed") + 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" { @@ -29,8 +41,30 @@ func TestParseEnvelopeDM(t *testing.T) { if inb.MessageID != "uuid-1:1700000000001" { t.Errorf("MessageID = %q (dedup key is sender:timestamp)", inb.MessageID) } - if len(refs) != 0 { - t.Errorf("refs = %v", refs) + 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) } } @@ -38,9 +72,10 @@ func TestParseEnvelopeGroupAndMentions(t *testing.T) { raw := `{"envelope":{"sourceNumber":"+15551230000","timestamp":2, "dataMessage":{"message":"@bot do it","groupInfo":{"groupId":"g99"}, "mentions":[{"number":"+15550009999"}]}}}` - inb, _, ok := parseEnvelope([]byte(raw), "+15550009999") + c := New("", "+15550009999", "", "") + inb, ok := deliverOne(t, c, raw) if !ok { - t.Fatal("parse failed") + t.Fatal("not delivered") } if inb.IsDirect || inb.Conversation != "group:g99" || !inb.Mentioned { t.Errorf("group inbound = %+v", inb) @@ -50,7 +85,7 @@ func TestParseEnvelopeGroupAndMentions(t *testing.T) { raw = `{"envelope":{"sourceNumber":"+15551230000","timestamp":3, "dataMessage":{"message":"and this?","groupInfo":{"groupId":"g99"}, "quote":{"author":"+15550009999"}}}}` - inb, _, _ = parseEnvelope([]byte(raw), "+15550009999") + inb, _ = deliverOne(t, c, raw) if !inb.Mentioned { t.Error("quote-reply must count as mentioned") } @@ -63,7 +98,7 @@ func TestParseEnvelopeSkips(t *testing.T) { "empty": `{"envelope":{"sourceNumber":"+1555","timestamp":1,"dataMessage":{"message":""}}}`, "malformed": `not json`, } { - if _, _, ok := parseEnvelope([]byte(raw), "+15550009999"); ok { + if _, _, _, ok := parseEnvelope([]byte(raw), "+15550009999"); ok { t.Errorf("%s must be skipped", name) } } diff --git a/internal/channels/telegram/telegram.go b/internal/channels/telegram/telegram.go index 1b1a1f2..da13c8e 100644 --- a/internal/channels/telegram/telegram.go +++ b/internal/channels/telegram/telegram.go @@ -329,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 } } 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/triggers/googlechat/googlechat.go b/internal/triggers/googlechat/googlechat.go index 21ac374..166df88 100644 --- a/internal/triggers/googlechat/googlechat.go +++ b/internal/triggers/googlechat/googlechat.go @@ -16,6 +16,7 @@ import ( "fmt" "io" "net/http" + "net/url" "strings" "sync" "time" @@ -50,6 +51,7 @@ type Channel struct { 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 @@ -71,6 +73,7 @@ func New(saKeyJSON []byte, audience, mediaDir string) *Channel { audience: audience, mediaDir: mediaDir, client: client, + dl: channels.SafeHTTPClient(30 * time.Second), // attachment fetches: SSRF-guarded apiBase: defaultAPIBase, verify: &webjwt.Verifier{ JWKSURL: defaultJWKSURL, @@ -226,8 +229,17 @@ func (c *Channel) downloadOne(ctx context.Context, bearer string, a chatAttachme if err != nil { return channels.Attachment{}, err } - req.Header.Set("Authorization", "Bearer "+bearer) - resp, err := c.client.Do(req) + // 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 } diff --git a/internal/triggers/sms/sms.go b/internal/triggers/sms/sms.go index 751b697..22708af 100644 --- a/internal/triggers/sms/sms.go +++ b/internal/triggers/sms/sms.go @@ -37,7 +37,8 @@ type Channel struct { webhookURL string // the exact public URL Twilio posts to (signature input) base string // API base; overridable in tests client *http.Client - mediaDir string // media spool; "" disables MMS media download + 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 @@ -51,6 +52,7 @@ func New(accountSID, authToken, from, webhookURL, mediaDir string) *Channel { 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, } } @@ -166,7 +168,7 @@ func (c *Channel) download(ctx context.Context, refs []mediaRef) []channels.Atta continue } req.SetBasicAuth(c.accountSID, c.authToken) - resp, err := c.client.Do(req) + resp, err := c.dl.Do(req) if err != nil { continue } diff --git a/internal/triggers/whatsapp/whatsapp.go b/internal/triggers/whatsapp/whatsapp.go index d90c86f..cd274ff 100644 --- a/internal/triggers/whatsapp/whatsapp.go +++ b/internal/triggers/whatsapp/whatsapp.go @@ -46,7 +46,8 @@ type Channel struct { appSecret string // Meta app secret; verifies inbound POST signatures base string // Graph API base; overridable in tests client *http.Client - mediaDir string // media spool; "" disables inbound media downloads + 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 @@ -62,6 +63,7 @@ func New(phoneNumberID, accessToken, verifyToken, appSecret, mediaDir string) *C appSecret: appSecret, base: defaultBase, client: &http.Client{Timeout: 30 * time.Second}, + dl: channels.SafeHTTPClient(30 * time.Second), // media fetches: SSRF-guarded mediaDir: mediaDir, } } @@ -265,7 +267,7 @@ func (c *Channel) downloadOne(ctx context.Context, m waMedia) (channels.Attachme return channels.Attachment{}, err } dreq.Header.Set("Authorization", "Bearer "+c.accessToken) - dresp, err := c.client.Do(dreq) + dresp, err := c.dl.Do(dreq) // SSRF-guarded: the media URL is Graph-returned but fetched defensively if err != nil { return channels.Attachment{}, err } diff --git a/internal/webjwt/webjwt.go b/internal/webjwt/webjwt.go index 8194b4a..8ce8773 100644 --- a/internal/webjwt/webjwt.go +++ b/internal/webjwt/webjwt.go @@ -1,8 +1,8 @@ // 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 — refreshed at -// most once per unknown kid, so key rotation works but a forged-kid flood -// isn't amplified into JWKS hammering. Two adapters verifying JWTs two +// 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 @@ -40,10 +40,16 @@ type Verifier struct { Audience string // required — an empty audience never verifies Client *http.Client - mu sync.Mutex - keys map[string]*rsa.PublicKey + 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. @@ -87,8 +93,18 @@ func (v *Verifier) cachedKey(kid string) *rsa.PublicKey { } // refreshKeys resolves the JWKS location and replaces the key cache. Replacing -// (not merging) means revoked keys actually leave. +// (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 { From b57ad2b8abaea5d3dca93b44c7f202d0b6ef4e25 Mon Sep 17 00:00:00 2001 From: Tim Erwin Date: Sat, 15 Aug 2026 02:49:20 +0700 Subject: [PATCH 12/13] runtime: 'tell me what to do differently' redirects instead of terminating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picking 'No, and tell me what to do differently' and typing feedback set Interrupt, which stopped the whole turn — the model never saw the typed feedback, so the turn just terminated with 'stopped this turn at your request'. That conflated two things: skipping the rejected action's sibling tool calls (right) and ending the turn (wrong for a redirect). Split them: a new ApprovalDecision.Redirect (and turn.redirected) denies the action, skips its siblings this batch, but lets the loop CONTINUE so the next model call reads the feedback (framed as 'The user declined that action and said: …') and responds — says something, re-plans, or asks. Esc / 'No, stop' still hard-interrupt. Regression test asserts redirect denies + marks redirected without interrupting. --- internal/agent/runtime/approval.go | 3 ++- internal/agent/runtime/approval_test.go | 21 ++++++++++++++++++++- internal/agent/runtime/exec.go | 9 ++++++--- internal/agent/runtime/loop.go | 7 +++++++ internal/agent/runtime/runtime.go | 7 +++++++ internal/agent/runtime/turnstate.go | 3 ++- internal/vxui/approval.go | 24 ++++++++++++++---------- 7 files changed, 58 insertions(+), 16 deletions(-) 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/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 a55f0cc..351ba25 100644 --- a/internal/agent/runtime/runtime.go +++ b/internal/agent/runtime/runtime.go @@ -588,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/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) } } From 521f1b73107847bf38cff1d0b8b71a3ee0b6e2a5 Mon Sep 17 00:00:00 2001 From: Tim Erwin Date: Sat, 15 Aug 2026 02:53:34 +0700 Subject: [PATCH 13/13] gateway: hardening from the independent channel audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Image decompression-bomb guard (Medium): Downscale now reads only the header (image.DecodeConfig) and refuses an image over 50MP before the full decode, so a crafted PNG declaring huge dimensions can't balloon to gigabytes of RGBA and OOM the job process. Regression test with a dimension-forged PNG. - Webhook http.Server gets ReadHeaderTimeout/ReadTimeout/IdleTimeout (slowloris backstop). - waitForJob backstops at 30m so a hung agent child can't block its conversation's serial queue forever. - Media spool refreshes mtime on a dedup hit, so a task pending across the 30-day prune window doesn't lose its attachment. - Slack MessageID is now channel-qualified (channel:ts); a bare ts is unique only within a channel, so this removes a cross-channel INSERT-OR-IGNORE collision (fail-closed either way). - Pending/PendingReplies scans are LIMITed so a backlog flood can't turn each 2s worker tick into a full-table load. Residual noted (accepted): HostAllowed trusts any first-party suffix, so a content URL on an attacker-controlled *.googleusercontent.com blob still receives the SA bearer — low risk, the SSRF dial-guard still blocks any internal target. --- internal/agent/input/image.go | 15 ++++++++++++++ internal/agent/input/image_test.go | 29 +++++++++++++++++++++++++++ internal/channels/media.go | 6 ++++++ internal/channels/slack/slack.go | 2 +- internal/channels/slack/slack_test.go | 2 +- internal/gateway/server/server.go | 21 ++++++++++++++++++- internal/gateway/state/state.go | 4 ++-- 7 files changed, 74 insertions(+), 5 deletions(-) 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/channels/media.go b/internal/channels/media.go index 02fdb20..b8fc76e 100644 --- a/internal/channels/media.go +++ b/internal/channels/media.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "strings" + "time" ) // MaxAttachmentBytes caps a single downloaded attachment. Keeps a hostile or @@ -70,6 +71,11 @@ func SaveToSpool(dir string, r io.Reader, mimeType, name string) (Attachment, er 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 { diff --git a/internal/channels/slack/slack.go b/internal/channels/slack/slack.go index 620348c..ba2608a 100644 --- a/internal/channels/slack/slack.go +++ b/internal/channels/slack/slack.go @@ -113,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 c41ce43..9111025 100644 --- a/internal/channels/slack/slack_test.go +++ b/internal/channels/slack/slack_test.go @@ -34,7 +34,7 @@ func TestToInbound(t *testing.T) { if !ok { return } - want := channels.Inbound{Channel: "slack", Conversation: tt.wantConvo, Principal: tt.wantWho, Text: tt.wantText, MessageID: "ts1"} + 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/gateway/server/server.go b/internal/gateway/server/server.go index 35320b0..7cd5905 100644 --- a/internal/gateway/server/server.go +++ b/internal/gateway/server/server.go @@ -709,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) @@ -738,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 7c33b56..21e0fa0 100644 --- a/internal/gateway/state/state.go +++ b/internal/gateway/state/state.go @@ -241,7 +241,7 @@ func (s *Store) Accept(ctx context.Context, it Item, now time.Time) (bool, error 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, attachments - FROM inbox WHERE status = 'pending' ORDER BY received_at`) + FROM inbox WHERE status = 'pending' ORDER BY received_at LIMIT 500`) if err != nil { return nil, fmt.Errorf("pending inbox: %w", err) } @@ -280,7 +280,7 @@ func (s *Store) SetReplied(ctx context.Context, channel, messageID, reply, voice func (s *Store) PendingReplies(ctx context.Context) ([]Item, error) { rows, err := s.db.QueryContext(ctx, `SELECT channel, message_id, conversation, principal, text, trusted, reply, voice - FROM inbox WHERE status = 'replied' ORDER BY received_at`) + FROM inbox WHERE status = 'replied' ORDER BY received_at LIMIT 500`) if err != nil { return nil, fmt.Errorf("pending replies: %w", err) }