diff --git a/CLAUDE.md b/CLAUDE.md index d206856..57f5cd8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,7 +91,7 @@ mode this prevents and the `~/.mcp.json` recipe. ### MCP tools -24 tools registered (see internal/tools/tools.go Register for the authoritative list): +26 tools registered (see internal/tools/tools.go Register for the authoritative list): - `get_messages`, `get_conversation`, `search_messages` — cross-platform by default - `list_conversations` — optional `source_platform` filter (sms, gchat, imessage, whatsapp) - `get_person_messages` — all messages with a person across all platforms @@ -104,6 +104,27 @@ mode this prevents and the `~/.mcp.json` recipe. - `generate_viz` — self-contained HTML visualization combining data dashboards + narrative (see below) - `render_story` — render a pre-built Story JSON into HTML viz; supports `photo_paths` (curated list) or `photos_dir` - `send_message`, `draft_message`, `download_media`, `list_contacts`, `get_status` +- `list_outbox`, `cancel_outbox` — durable-send custody: see what is still queued/retrying, stop a stale send before it transmits + +### Send truthfulness (2026-08-05 incident) + +A send result's `transport_state` is `queued` (has NOT left the machine), +`transmitted` (transport accepted it — NOT proof of delivery), `delivered` +(delivery receipt observed), `uncertain`, `failed`, or `canceled`; +`settled`/`transmitted` are true only on transport acknowledgment, and the +result names the `platform` used and `conversation_id` written to. Sends +default to a 10-minute send window (`ttl_seconds`, env +`OPENMESSAGES_SEND_TTL_SECONDS`; 0 = never expire) — a message still queued +when the window closes cancels as expired instead of transmitting stale. +Near-identical resends to the same conversation within ~10 minutes are +blocked unless `force=true`. `wait_for_transmit=true` holds the call (up to +`wait_seconds`, max 120) until the transport acknowledges. The requested +platform is a hard contract: an unsendable platform fails with the reason +and queues nothing — there is never a silent fallback to another channel. +`get_status` and `/api/status` publish per-platform send capability +(`send.{sms,whatsapp,signal}`), which is what `resolve_contact_routes` +sendability and send-time enforcement both read; `connected`/`v2_send` alone +never imply a platform can send. ### HTTP API diff --git a/cmd/send_capability.go b/cmd/send_capability.go new file mode 100644 index 0000000..aea8a63 --- /dev/null +++ b/cmd/send_capability.go @@ -0,0 +1,40 @@ +package cmd + +import ( + "github.com/maxghenis/openmessage/internal/app" + "github.com/maxghenis/openmessage/internal/sendcap" + "github.com/maxghenis/openmessage/internal/web" +) + +// sendCapabilityProvider builds the /api/status "send" block from the live +// transport snapshots and, when the v2 send stack is active, the adapter +// registry. See internal/sendcap for the semantics. +func sendCapabilityProvider( + a *app.App, + stack *v2Stack, + transports bool, +) func() map[string]web.SendPlatformCapability { + accountForPlatform := map[string]string{ + sendcap.PlatformSMS: googleAccountID, + sendcap.PlatformWhatsApp: whatsappAccountID, + sendcap.PlatformSignal: signalAccountID, + } + return func() map[string]web.SendPlatformCapability { + inputs := sendcap.Inputs{ + TransportsEnabled: transports, + Google: a.GoogleStatus(), + WhatsApp: a.WhatsAppStatus(), + Signal: a.SignalStatus(), + } + if stack != nil { + inputs.AdapterTextSend = func(platform string) bool { + accountID, ok := accountForPlatform[platform] + if !ok { + return false + } + return stack.Registry.Capabilities(accountID).TextSend + } + } + return sendcap.Compute(inputs) + } +} diff --git a/cmd/send_outbox.go b/cmd/send_outbox.go index c5dae20..67a150f 100644 --- a/cmd/send_outbox.go +++ b/cmd/send_outbox.go @@ -193,7 +193,7 @@ func writeCLIDelivery(output io.Writer, delivery outboxDelivery, key string, sch fmt.Fprintf(output, "outbox_id=%s state=%s idempotency_key=%s\n", delivery.OutboxID, delivery.State, key) switch delivery.State { case "confirmed": - fmt.Fprintln(output, "message delivery confirmed") + fmt.Fprintln(output, "transmitted: the transport accepted the message. Transport acceptance is not delivery — verify in-thread before reporting it as sent.") case "not_dispatched": if !scheduled { fmt.Fprintln(output, "queued; app retries automatically. Do not resend.") diff --git a/cmd/serve.go b/cmd/serve.go index 8e189b1..170a858 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -662,6 +662,7 @@ func RunServe(logger zerolog.Logger, args ...string) error { v2Options := v2SendWebOptions(stack, v2Send) v2IngestCounters := v2IngestCountersProvider(stack) + sendCapability := sendCapabilityProvider(a, stack, transports) httpEnabled := opts.web || opts.mcpSSE if httpEnabled { @@ -675,6 +676,7 @@ func RunServe(logger zerolog.Logger, args ...string) error { Auth: controlAuth, V2: v2Options, V2IngestCounters: v2IngestCounters, + SendCapability: sendCapability, Reads: reads, V2Primary: v2Primary, Client: a.GetClient, diff --git a/docs/agent-runbook.md b/docs/agent-runbook.md index 778ca5b..0d27738 100644 --- a/docs/agent-runbook.md +++ b/docs/agent-runbook.md @@ -122,6 +122,53 @@ can migrate the schema under the older one. `serve ... --transports` alongside the app: those are daemon shapes and will fight the app for the WhatsApp/Signal sessions exactly as described above. +## Send states are transport truth (post 2026-08-05 incident) + +On 2026-08-05 a send reported as `{ok:true, settled:true, state:"confirmed"}` +did not reach the recipient until ~15 hours later, seconds behind a manual +day-of retry — a double-text. The send surface now reports transport truth +and gives agents custody of queued sends: + +- **`transport_state`** in every durable send result: `queued` (the message + has NOT left this machine), `transmitted` (the platform transport accepted + it — a remote message ID exists, but that is NOT proof the recipient got + it), `delivered` (a delivery/read receipt was observed in the store), + `uncertain`, `failed`, `canceled`. `settled` and `transmitted` are true + only on transport acknowledgment; `uncertain` is reported as + settled:false + uncertain:true. Results carry the `platform` actually used + and the `conversation_id` written to. +- **Send window (TTL).** MCP sends default to a 10-minute window + (`ttl_seconds` per call; installation default via + `OPENMESSAGES_SEND_TTL_SECONDS`; 0 disables). A send still queued when the + window closes is canceled (`expired: true`) instead of transmitting stale. + The daemon HTTP API takes `ttl_ms` on the outbox submit routes. +- **Near-duplicate guard.** A text nearly identical to one submitted to the + same conversation within ~10 minutes is refused (HTTP 409 / + `near_duplicate_blocked`) naming the prior outbox item; pass `force=true` + for a deliberate repeat. Same-key replays (the documented lost-response + retry) bypass the guard and hit idempotent dedup instead. +- **`wait_for_transmit: true`** holds the tool call (bounded by + `wait_seconds`, default 25, max 120) through auto-retrying states until + the transport acknowledges, so an agent can report truthfully in one call. +- **`list_outbox` / `cancel_outbox`** show and stop queued sends. Cancel + only works before the transport boundary (queued/not_dispatched). +- **Per-platform send capability** is published at `/api/status` under + `send.{sms,whatsapp,signal}` (`available` / `queueable` / `reason`) and + rendered by `get_status`. `connected: true` and `v2_send: true` do NOT + mean a platform can send — the WhatsApp connection can be up for receiving + while sends fail. Hard-down platforms (unpaired, adapter unregistered, + auth revoked) are refused at send time with the same reason + `resolve_contact_routes` shows; transient disconnects (`queueable`) still + queue, truthfully reported and bounded by the TTL. +- **No silent channel substitution.** The requested platform is a hard + contract; `send_to_conversation` accepts a `platform` argument that fails + on mismatch instead of sending. A 404 on a send now says the daemon could + not resolve the conversation in its serving store (the 2026-08-05 + WhatsApp shape) rather than a bare "not found". + +The old incident guidance — verify in-thread with a fresh timestamp before +reporting "sent" — still applies verbatim to anything beyond `delivered`. + ## Pairing & the "zombie session" **Symptom:** sends fail with `OUTGOING_FAILED:UNKNOWN`; `/api/status` shows diff --git a/internal/cutover/carry.go b/internal/cutover/carry.go index 4ad7f30..01f56f4 100644 --- a/internal/cutover/carry.go +++ b/internal/cutover/carry.go @@ -204,6 +204,12 @@ func CarryPendingOutbox( TransportRequestID: intent.TransportRequestID, ScheduledForMS: intent.ScheduledForMS, } + // A carried intent keeps its send window: if the window closed while + // the stores were cut over, the fresh store's expiry sweep cancels it + // instead of transmitting stale. + if intent.ExpiresAtMS != nil { + item.ExpiresAtMS = *intent.ExpiresAtMS + } message := sqlite.Message{ MessageID: *intent.LocalMessageID, ConversationID: conversation.ConversationID, diff --git a/internal/localapi/localapi.go b/internal/localapi/localapi.go index 06592a3..c03c54d 100644 --- a/internal/localapi/localapi.go +++ b/internal/localapi/localapi.go @@ -18,8 +18,10 @@ import ( "net" "net/http" "net/textproto" + "net/url" "os" "path/filepath" + "strconv" "strings" "time" ) @@ -70,16 +72,41 @@ func NewClient(baseURL, token string) *Client { } } +// PlatformSendCapability mirrors one entry of the daemon's /api/status "send" +// block: whether a send on that platform is expected to dispatch promptly, +// with the daemon's reason when it is not. Queueable marks a self-healing +// outage where a durable send is still accepted and waits. +type PlatformSendCapability struct { + Available bool `json:"available"` + Queueable bool `json:"queueable"` + Reason string `json:"reason,omitempty"` +} + // DaemonStatus is the subset of /api/status used for daemon-truth decisions. type DaemonStatus struct { Connected bool `json:"connected"` V2Send bool `json:"v2_send"` V2Primary bool `json:"v2_primary"` - Auth struct { + // Send is keyed by send platform ("sms", "whatsapp", "signal"). Nil on + // daemons older than the send-capability block; callers must treat a + // missing map as "unknown", not as "available". + Send map[string]PlatformSendCapability `json:"send"` + Auth struct { DataDir string `json:"data_dir"` } `json:"auth"` } +// SendCapabilityFor reports the daemon's send capability for a platform. The +// second result is false when the daemon did not publish a send block (older +// daemon) or does not know the platform — unknown, not unavailable. +func (s DaemonStatus) SendCapabilityFor(platform string) (PlatformSendCapability, bool) { + if s.Send == nil { + return PlatformSendCapability{}, false + } + capability, ok := s.Send[platform] + return capability, ok +} + // SendsViaOutbox reports whether the daemon expects sends on the durable // /api/v1/outbox surface rather than the legacy /api/send route. func (s DaemonStatus) SendsViaOutbox() bool { @@ -113,6 +140,11 @@ type TextSubmission struct { ReplyToID string `json:"reply_to_id,omitempty"` IdempotencyKey string `json:"idempotency_key"` NotBeforeMS *int64 `json:"not_before_ms,omitempty"` + // TTLMS bounds how long the daemon may hold the send before canceling it + // as expired instead of transmitting stale. Nil means no expiry. + TTLMS *int64 `json:"ttl_ms,omitempty"` + // Force bypasses the daemon's near-duplicate guard for a deliberate resend. + Force bool `json:"force,omitempty"` } // MediaSubmission is a durable media send routed at POST /api/v1/outbox/media. @@ -125,6 +157,7 @@ type MediaSubmission struct { ReplyToID string IdempotencyKey string NotBeforeMS *int64 + TTLMS *int64 Content io.Reader } @@ -134,18 +167,43 @@ type Submission struct { LocalMessageID string `json:"local_message_id"` State string `json:"state"` ScheduledForMS int64 `json:"scheduled_for_ms"` + ExpiresAtMS int64 `json:"expires_at_ms,omitempty"` Deduplicated bool `json:"deduplicated"` } -// Delivery mirrors the daemon's v1 delivery response. +// Delivery mirrors the daemon's v1 delivery response. AccountID, +// ConversationID, Platform, ExpiresAtMS, and Expired are empty against +// daemons older than the truthful-send-states change. type Delivery struct { OutboxID string `json:"outbox_id"` + AccountID string `json:"account_id"` + ConversationID string `json:"conversation_id"` + Platform string `json:"platform"` State string `json:"state"` LocalMessageID string `json:"local_message_id"` RemoteMessageID string `json:"remote_message_id"` ErrorClass string `json:"error_class"` ErrorCode string `json:"error_code"` Warning string `json:"warning"` + ExpiresAtMS int64 `json:"expires_at_ms"` + Expired bool `json:"expired"` +} + +// PendingDelivery mirrors one row of the daemon's GET /api/v1/outbox response. +type PendingDelivery struct { + OutboxID string `json:"outbox_id"` + AccountID string `json:"account_id"` + ConversationID string `json:"conversation_id"` + Kind string `json:"kind"` + State string `json:"state"` + ScheduledForMS int64 `json:"scheduled_for_ms"` + NextAttemptMS *int64 `json:"next_attempt_at_ms"` + ExpiresAtMS int64 `json:"expires_at_ms"` + AttemptCount int64 `json:"attempt_count"` + CreatedAtMS int64 `json:"created_at_ms"` + Summary string `json:"summary"` + ErrorClass string `json:"error_class"` + ErrorCode string `json:"error_code"` } // Settled reports whether the delivery reached a state the dispatcher will @@ -229,6 +287,9 @@ func multipartMediaBody(submission MediaSubmission) (io.ReadCloser, string) { if submission.NotBeforeMS != nil { fields["not_before_ms"] = fmt.Sprintf("%d", *submission.NotBeforeMS) } + if submission.TTLMS != nil { + fields["ttl_ms"] = fmt.Sprintf("%d", *submission.TTLMS) + } for name, value := range fields { if value == "" { continue @@ -274,6 +335,38 @@ func (c *Client) Delivery(ctx context.Context, outboxID string) (Delivery, error return delivery, nil } +// ListPending fetches the daemon's outbox tray, optionally scoped to one +// conversation. +func (c *Client) ListPending(ctx context.Context, conversationID string, limit int) ([]PendingDelivery, error) { + query := url.Values{} + if strings.TrimSpace(conversationID) != "" { + query.Set("conversation_id", strings.TrimSpace(conversationID)) + } + if limit > 0 { + query.Set("limit", strconv.Itoa(limit)) + } + path := "/api/v1/outbox" + if encoded := query.Encode(); encoded != "" { + path += "?" + encoded + } + var pending []PendingDelivery + if _, err := c.getJSON(ctx, path, &pending); err != nil { + return nil, err + } + return pending, nil +} + +// CancelDelivery cancels one queued or retrying outbox item on the daemon. +// The daemon refuses (HTTP 409) once the intent crossed the transport +// boundary; the returned delivery reflects the post-cancel state. +func (c *Client) CancelDelivery(ctx context.Context, outboxID string) (Delivery, error) { + var delivery Delivery + if err := c.postJSON(ctx, "/api/v1/outbox/"+url.PathEscape(outboxID)+"/cancel", struct{}{}, &delivery); err != nil { + return Delivery{}, err + } + return delivery, nil +} + // WaitDelivery polls the outbox item until it settles, ctx ends, or // settleTimeout elapses. It returns the last observed delivery; the bool // reports whether that delivery settled. A non-nil error means the state diff --git a/internal/localapi/send_truth_test.go b/internal/localapi/send_truth_test.go new file mode 100644 index 0000000..a6ae16c --- /dev/null +++ b/internal/localapi/send_truth_test.go @@ -0,0 +1,126 @@ +package localapi + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestSubmitTextSendsTTLAndForce(t *testing.T) { + var received map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/outbox/messages" { + t.Fatalf("path = %q", r.URL.Path) + } + if err := json.NewDecoder(r.Body).Decode(&received); err != nil { + t.Fatalf("decode request: %v", err) + } + json.NewEncoder(w).Encode(map[string]any{"outbox_id": "outbox-1", "state": "queued"}) + })) + t.Cleanup(server.Close) + + client := NewClient(server.URL, "") + ttlMS := int64(600_000) + if _, err := client.SubmitText(context.Background(), TextSubmission{ + ConversationID: "conversation-1", + Body: "windowed", + IdempotencyKey: "key-1", + TTLMS: &ttlMS, + Force: true, + }); err != nil { + t.Fatalf("SubmitText(): %v", err) + } + if got, _ := received["ttl_ms"].(float64); int64(got) != ttlMS { + t.Fatalf("ttl_ms = %v, want %d", received["ttl_ms"], ttlMS) + } + if got, _ := received["force"].(bool); !got { + t.Fatalf("force = %v, want true", received["force"]) + } +} + +func TestDeliveryDecodesTransportFields(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{ + "outbox_id": "outbox-2", + "account_id": "google-primary", + "conversation_id": "conversation-2", + "platform": "sms", + "state": "canceled", + "error_class": "ttl", + "expires_at_ms": 1_700_000_000_000, + "expired": true, + }) + })) + t.Cleanup(server.Close) + + delivery, err := NewClient(server.URL, "").Delivery(context.Background(), "outbox-2") + if err != nil { + t.Fatalf("Delivery(): %v", err) + } + if delivery.Platform != "sms" || delivery.ConversationID != "conversation-2" || + delivery.ExpiresAtMS != 1_700_000_000_000 || !delivery.Expired { + t.Fatalf("delivery = %+v", delivery) + } +} + +func TestListPendingAndCancelDelivery(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/outbox": + if got := r.URL.Query().Get("conversation_id"); got != "conversation-3" { + t.Fatalf("conversation_id query = %q", got) + } + if got := r.URL.Query().Get("limit"); got != "25" { + t.Fatalf("limit query = %q", got) + } + json.NewEncoder(w).Encode([]map[string]any{{ + "outbox_id": "outbox-3", + "conversation_id": "conversation-3", + "kind": "text", + "state": "queued", + "expires_at_ms": 1_700_000_000_000, + }}) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/outbox/outbox-3/cancel": + json.NewEncoder(w).Encode(map[string]any{"outbox_id": "outbox-3", "state": "canceled"}) + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + })) + t.Cleanup(server.Close) + + client := NewClient(server.URL, "") + pending, err := client.ListPending(context.Background(), "conversation-3", 25) + if err != nil { + t.Fatalf("ListPending(): %v", err) + } + if len(pending) != 1 || pending[0].OutboxID != "outbox-3" || pending[0].ExpiresAtMS != 1_700_000_000_000 { + t.Fatalf("pending = %+v", pending) + } + + delivery, err := client.CancelDelivery(context.Background(), "outbox-3") + if err != nil { + t.Fatalf("CancelDelivery(): %v", err) + } + if delivery.State != "canceled" { + t.Fatalf("state = %q, want canceled", delivery.State) + } +} + +func TestSendCapabilityForDistinguishesUnknownFromUnavailable(t *testing.T) { + old := DaemonStatus{} + if _, known := old.SendCapabilityFor("whatsapp"); known { + t.Fatal("daemon without a send block must report unknown, not unavailable") + } + status := DaemonStatus{Send: map[string]PlatformSendCapability{ + "whatsapp": {Available: false, Reason: "not paired"}, + }} + capability, known := status.SendCapabilityFor("whatsapp") + if !known || capability.Available { + t.Fatalf("capability = %+v known=%v", capability, known) + } + if _, known := status.SendCapabilityFor("sms"); known { + t.Fatal("platform missing from the send block must report unknown") + } +} diff --git a/internal/messaging/dispatch.go b/internal/messaging/dispatch.go index afa9979..251e6c0 100644 --- a/internal/messaging/dispatch.go +++ b/internal/messaging/dispatch.go @@ -37,6 +37,12 @@ func (s *MessageService) Run(ctx context.Context) error { if err := ctx.Err(); err != nil { return err } + if err := s.cancelExpiredDue(ctx); err != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr + } + return fmt.Errorf("run message service: cancel expired intents: %w", err) + } if _, err := s.reconcileStoreFailedDue(ctx, s.batchLimit); err != nil { if ctxErr := ctx.Err(); ctxErr != nil { return ctxErr @@ -875,6 +881,21 @@ func (s *MessageService) storageMutationContext(ctx context.Context) (context.Co return context.WithTimeout(context.WithoutCancel(ctx), defaultFinalizeTime) } +// cancelExpiredDue sweeps intents whose send window closed before dispatch. +// Running ahead of DispatchDue in every loop iteration means an expired +// intent can never be leased first: LeaseDue independently excludes expired +// rows, so the sweep and the lease agree even under races. +func (s *MessageService) cancelExpiredDue(ctx context.Context) error { + canceled, err := s.outbox.CancelExpired(ctx, s.clock.Now()) + if err != nil { + return err + } + if len(canceled) > 0 { + s.signalChange() + } + return nil +} + func (s *MessageService) recordSendError( ctx context.Context, item sqlite.OutboxItem, diff --git a/internal/messaging/send_again_test.go b/internal/messaging/send_again_test.go index b8b3bfd..63d0f85 100644 --- a/internal/messaging/send_again_test.go +++ b/internal/messaging/send_again_test.go @@ -250,12 +250,17 @@ func TestSendAgainSameKeyAcrossDifferentPredecessorsConflicts(t *testing.T) { service := newMessagingTestService(t, store, registry, clock) const identicalBody = "identical payload either way" + firstCommand := testCommonCommand("key-cross-predecessor-one") first := mustSendText(t, service, SendTextCommand{ - CommonCommand: testCommonCommand("key-cross-predecessor-one"), + CommonCommand: firstCommand, Body: identicalBody, }) + // The second identical submission is deliberate test setup; Force bypasses + // the near-duplicate guard exactly as a deliberate user resend would. + secondCommand := testCommonCommand("key-cross-predecessor-two") + secondCommand.Force = true second := mustSendText(t, service, SendTextCommand{ - CommonCommand: testCommonCommand("key-cross-predecessor-two"), + CommonCommand: secondCommand, Body: identicalBody, }) if processed, err := service.DispatchDue(context.Background(), 2); err != nil || processed != 2 { diff --git a/internal/messaging/service.go b/internal/messaging/service.go index 409c315..b981ddf 100644 --- a/internal/messaging/service.go +++ b/internal/messaging/service.go @@ -33,6 +33,16 @@ const ( maxListPending = 500 summaryMaxRunes = 120 workerOwner = "message-service" + + // Near-duplicate guard defaults: a text whose body is this similar to one + // submitted to the same conversation within the window is blocked unless + // the command carries Force. 0.75 catches the incident shape ("lunch + // tomorrow…" resent as "lunch today…") while leaving short conversational + // repeats ("ok" / "ok!") alone. + defaultDuplicateWindow = 10 * time.Minute + defaultDuplicateThreshold = 0.75 + duplicateCandidateLimit = 8 + duplicateCompareMaxRunes = 1000 ) // ListPendingQuery selects outbox-tray deliveries in deterministic due order. @@ -52,6 +62,7 @@ type PendingDelivery struct { State OutboxState ScheduledFor time.Time NextAttemptAt time.Time + ExpiresAt time.Time // zero means the intent never expires AttemptCount int64 CreatedAt time.Time Summary string @@ -77,6 +88,9 @@ type MessageService struct { maxMediaBytes int64 batchLimit int + duplicateWindow time.Duration + duplicateThreshold float64 + wake chan struct{} changeMu sync.Mutex @@ -113,21 +127,23 @@ func NewMessageService( return nil, fmt.Errorf("create message service messages: %w", err) } return &MessageService{ - store: store, - outbox: outbox, - messages: messages, - bridges: bridges, - blobs: blobs, - clock: clock, - ids: ids, - leaseTime: defaultLeaseTime, - retryDelay: defaultRetryDelay, - pollDelay: defaultPollDelay, - maxPollDelay: defaultMaxPollDelay, - maxMediaBytes: DefaultMaxMediaBytes, - batchLimit: defaultBatchLimit, - wake: make(chan struct{}, 1), - changed: make(chan struct{}), + store: store, + outbox: outbox, + messages: messages, + bridges: bridges, + blobs: blobs, + clock: clock, + ids: ids, + leaseTime: defaultLeaseTime, + retryDelay: defaultRetryDelay, + pollDelay: defaultPollDelay, + maxPollDelay: defaultMaxPollDelay, + maxMediaBytes: DefaultMaxMediaBytes, + batchLimit: defaultBatchLimit, + duplicateWindow: defaultDuplicateWindow, + duplicateThreshold: defaultDuplicateThreshold, + wake: make(chan struct{}, 1), + changed: make(chan struct{}), }, nil } @@ -185,6 +201,13 @@ func (s *MessageService) SendText( if scheduledFor.IsZero() { scheduledFor = now } + expiresAtMS, err := expiryMilliseconds(cmd.CommonCommand, scheduledFor) + if err != nil { + return Submission{}, err + } + if err := s.guardNearDuplicateText(ctx, cmd, now); err != nil { + return Submission{}, err + } payloadHash, err := textPayloadHash(cmd.Body, cmd.ReplyToMessageID) if err != nil { return Submission{}, fmt.Errorf("send text: hash payload: %w", err) @@ -201,6 +224,7 @@ func (s *MessageService) SendText( LocalMessageID: localMessageID, TransportRequestID: requestID, ScheduledFor: scheduledFor, + ExpiresAtMS: expiresAtMS, }, sqlite.Message{ MessageID: localMessageID, ConversationID: cmd.ConversationID, @@ -305,6 +329,10 @@ func (s *MessageService) SendMedia( if scheduledFor.IsZero() { scheduledFor = now } + expiresAtMS, err := expiryMilliseconds(cmd.CommonCommand, scheduledFor) + if err != nil { + return Submission{}, err + } payloadHash, err := mediaPayloadHash( ref.Hash, ref.Size, @@ -328,6 +356,7 @@ func (s *MessageService) SendMedia( LocalMessageID: localMessageID, TransportRequestID: requestID, ScheduledFor: scheduledFor, + ExpiresAtMS: expiresAtMS, }, sqlite.Message{ MessageID: localMessageID, ConversationID: cmd.ConversationID, @@ -618,6 +647,9 @@ func (s *MessageService) ListPending( if row.NextAttemptAtMS != nil { delivery.NextAttemptAt = time.UnixMilli(*row.NextAttemptAtMS) } + if row.ExpiresAtMS != nil { + delivery.ExpiresAt = time.UnixMilli(*row.ExpiresAtMS) + } deliveries = append(deliveries, delivery) } return deliveries, nil @@ -1008,6 +1040,133 @@ func (s *MessageService) reconcileStoreFailedDue(ctx context.Context, limit int) return reconciled, nil } +// expiryMilliseconds resolves a command's TTL against its effective schedule. +// The window opens at the later of "now" and NotBefore so a scheduled send is +// never born expired. +func expiryMilliseconds(cmd CommonCommand, scheduledFor time.Time) (int64, error) { + if cmd.TTL < 0 { + return 0, fmt.Errorf("%w: TTL is negative", ErrInvalidCommand) + } + if cmd.TTL == 0 { + return 0, nil + } + return scheduledFor.Add(cmd.TTL).UnixMilli(), nil +} + +// guardNearDuplicateText blocks a text whose body is nearly identical to one +// submitted to the same conversation inside the duplicate window, unless the +// command carries Force. Same-key candidates are skipped: replaying the exact +// send with its original idempotency key is the documented safe retry and is +// resolved by enqueue-level deduplication, not the guard. +func (s *MessageService) guardNearDuplicateText( + ctx context.Context, + cmd SendTextCommand, + now time.Time, +) error { + if cmd.Force || s.duplicateWindow <= 0 { + return nil + } + sinceMS := now.Add(-s.duplicateWindow).UnixMilli() + if sinceMS < 1 { + sinceMS = 1 + } + recent, err := s.outbox.ListRecentTextIntents( + ctx, + cmd.AccountID, + cmd.ConversationID, + sinceMS, + duplicateCandidateLimit, + ) + if err != nil { + return fmt.Errorf("send text: check for near-duplicates: %w", err) + } + for _, intent := range recent { + if intent.IdempotencyKey == cmd.IdempotencyKey { + continue + } + if !textsNearDuplicate(cmd.Body, intent.Body, s.duplicateThreshold) { + continue + } + return &DuplicateSendError{ + PriorOutboxID: intent.OutboxID, + PriorState: intent.State, + PriorIdempotencyKey: intent.IdempotencyKey, + PriorAgeMS: now.UnixMilli() - intent.CreatedAtMS, + } + } + return nil +} + +// textsNearDuplicate reports whether two message bodies are the same message +// for guard purposes: equal after whitespace/case normalization, or within +// the similarity threshold by normalized Levenshtein distance. +func textsNearDuplicate(a, b string, threshold float64) bool { + na, nb := normalizeGuardText(a), normalizeGuardText(b) + if na == "" || nb == "" { + return false + } + if na == nb { + return true + } + ra, rb := []rune(na), []rune(nb) + if len(ra) > duplicateCompareMaxRunes { + ra = ra[:duplicateCompareMaxRunes] + } + if len(rb) > duplicateCompareMaxRunes { + rb = rb[:duplicateCompareMaxRunes] + } + longest := len(ra) + if len(rb) > longest { + longest = len(rb) + } + if longest == 0 { + return false + } + distance := levenshtein(ra, rb) + similarity := 1 - float64(distance)/float64(longest) + return similarity >= threshold +} + +func normalizeGuardText(value string) string { + return strings.Join(strings.Fields(strings.ToLower(value)), " ") +} + +// levenshtein is the classic two-row edit distance over runes. +func levenshtein(a, b []rune) int { + if len(a) == 0 { + return len(b) + } + if len(b) == 0 { + return len(a) + } + previous := make([]int, len(b)+1) + current := make([]int, len(b)+1) + for j := range previous { + previous[j] = j + } + for i := 1; i <= len(a); i++ { + current[0] = i + for j := 1; j <= len(b); j++ { + substitution := previous[j-1] + if a[i-1] != b[j-1] { + substitution++ + } + insertion := current[j-1] + 1 + deletion := previous[j] + 1 + best := substitution + if insertion < best { + best = insertion + } + if deletion < best { + best = deletion + } + current[j] = best + } + previous, current = current, previous + } + return previous[len(b)] +} + func (s *MessageService) newSubmissionIDs() (string, string, string, error) { values := make([]string, 3) for i := range values { @@ -1219,27 +1378,39 @@ func readPayloadHash(deviceID, lastReadMessageID string) (string, error) { } func submissionFromItem(item sqlite.OutboxItem, disposition sqlite.EnqueueDisposition) Submission { - return Submission{ + submission := Submission{ OutboxID: item.OutboxID, LocalMessageID: stringValue(item.LocalMessageID), State: item.State, ScheduledFor: time.UnixMilli(item.ScheduledForMS), Deduplicated: disposition == sqlite.EnqueueExisting, } + if item.ExpiresAtMS != nil { + submission.ExpiresAt = time.UnixMilli(*item.ExpiresAtMS) + } + return submission } func deliveryFromItem(item sqlite.OutboxItem) Delivery { delivery := Delivery{ OutboxID: item.OutboxID, + AccountID: item.AccountID, + ConversationID: item.ConversationID, State: item.State, LocalMessageID: stringValue(item.LocalMessageID), RemoteMessageID: stringValue(item.ResultRemoteID), ErrorClass: stringValue(item.ErrorClass), ErrorCode: stringValue(item.ErrorCode), } + if item.ExpiresAtMS != nil { + delivery.ExpiresAt = time.UnixMilli(*item.ExpiresAtMS) + } if item.State == sqlite.OutboxUncertain { delivery.Warning = "delivery outcome is unknown" } + if delivery.Expired() { + delivery.Warning = "the send window expired before the message reached the transport; it was NOT sent" + } return delivery } diff --git a/internal/messaging/ttl_guard_test.go b/internal/messaging/ttl_guard_test.go new file mode 100644 index 0000000..98a2ed3 --- /dev/null +++ b/internal/messaging/ttl_guard_test.go @@ -0,0 +1,299 @@ +package messaging + +// TTL and near-duplicate guard behavior for durable sends, added after the +// 2026-08-05 incident: an overnight-queued send flushed ~15 hours later, +// seconds behind a manually retried near-duplicate, double-texting the +// recipient. The TTL bounds how stale a queued send may get; the guard blocks +// the accidental near-duplicate resubmission that completed the double-text. + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/maxghenis/openmessage/internal/bridge" + "github.com/maxghenis/openmessage/internal/storage/sqlite" +) + +func ttlCommand(key string, ttl time.Duration) SendTextCommand { + command := testCommonCommand(key) + command.TTL = ttl + return SendTextCommand{CommonCommand: command, Body: "time-sensitive lunch plan"} +} + +func TestSendTextTTLStampsExpiry(t *testing.T) { + clock := newManualClock(messagingTestTime) + store := openMessagingTestStore(t, clock.Now()) + registry := newScriptedRegistry("ttl-stamp", &scriptedTextSender{}) + service := newMessagingTestService(t, store, registry, clock) + + submission := mustSendText(t, service, ttlCommand("key-ttl-stamp", 10*time.Minute)) + wantExpiry := messagingTestTime.Add(10 * time.Minute) + if !submission.ExpiresAt.Equal(wantExpiry) { + t.Fatalf("submission expiry = %v, want %v", submission.ExpiresAt, wantExpiry) + } + delivery := mustDelivery(t, service, submission.OutboxID) + if !delivery.ExpiresAt.Equal(wantExpiry) { + t.Fatalf("delivery expiry = %v, want %v", delivery.ExpiresAt, wantExpiry) + } + + // No TTL means no expiry. + command := testCommonCommand("key-ttl-none") + unbounded := mustSendText(t, service, SendTextCommand{CommonCommand: command, Body: "no window"}) + if !unbounded.ExpiresAt.IsZero() { + t.Fatalf("unbounded submission expiry = %v, want zero", unbounded.ExpiresAt) + } +} + +func TestScheduledSendTTLCountsFromSchedule(t *testing.T) { + clock := newManualClock(messagingTestTime) + store := openMessagingTestStore(t, clock.Now()) + registry := newScriptedRegistry("ttl-scheduled", &scriptedTextSender{}) + service := newMessagingTestService(t, store, registry, clock) + + command := ttlCommand("key-ttl-scheduled", 10*time.Minute) + command.NotBefore = messagingTestTime.Add(24 * time.Hour) + submission := mustSendText(t, service, command) + + // The window opens at the scheduled time, so a tomorrow-send with a + // 10-minute TTL is not born expired. + wantExpiry := command.NotBefore.Add(10 * time.Minute) + if !submission.ExpiresAt.Equal(wantExpiry) { + t.Fatalf("scheduled expiry = %v, want %v", submission.ExpiresAt, wantExpiry) + } +} + +func TestExpiredQueuedSendIsCanceledAndNeverDispatched(t *testing.T) { + clock := newManualClock(messagingTestTime) + store := openMessagingTestStore(t, clock.Now()) + sender := &scriptedTextSender{steps: []sendStep{{result: bridge.SendResult{RemoteMessageID: "remote-late"}}}} + registry := newScriptedRegistry("ttl-expire", sender) + // The transport stays unavailable while the message waits — the incident + // shape: nothing dispatches overnight. + registry.setAvailable(false) + service := newMessagingTestService(t, store, registry, clock) + + submission := mustSendText(t, service, ttlCommand("key-ttl-expire", 10*time.Minute)) + // The unavailable transport releases the lease untouched: the item is + // processed (lease handled) but the transport is never called and the + // intent stays queued. + if _, err := service.DispatchDue(context.Background(), 8); err != nil { + t.Fatalf("DispatchDue(unavailable): %v", err) + } + if got := sender.requestCount(); got != 0 { + t.Fatalf("transport called %d times while unavailable, want 0", got) + } + if got := mustDelivery(t, service, submission.OutboxID).State; got != OutboxQueued { + t.Fatalf("state before expiry = %q, want queued", got) + } + + // 15 hours later the transport comes back — the incident's overnight gap. + clock.Advance(15 * time.Hour) + registry.setAvailable(true) + + if err := service.cancelExpiredDue(context.Background()); err != nil { + t.Fatalf("cancelExpiredDue(): %v", err) + } + delivery := mustDelivery(t, service, submission.OutboxID) + if delivery.State != OutboxCanceled { + t.Fatalf("state after expiry sweep = %q, want canceled", delivery.State) + } + if !delivery.Expired() { + t.Fatalf("delivery.Expired() = false, want true; delivery=%+v", delivery) + } + + // Even without the sweep having run first, the dispatcher must never + // lease an expired intent: the transport is called zero times. + if processed, err := service.DispatchDue(context.Background(), 8); err != nil || processed != 0 { + t.Fatalf("DispatchDue(after expiry) = %d, %v; want 0, nil", processed, err) + } + if got := sender.requestCount(); got != 0 { + t.Fatalf("transport called %d times for an expired send, want 0", got) + } + + // Wait reports the canceled outcome instead of blocking. + waited, err := service.Wait(context.Background(), submission.OutboxID) + if err != nil { + t.Fatalf("Wait(expired): %v", err) + } + if waited.State != OutboxCanceled { + t.Fatalf("Wait state = %q, want canceled", waited.State) + } +} + +func TestLeaseDueSkipsExpiredEvenWithoutSweep(t *testing.T) { + clock := newManualClock(messagingTestTime) + store := openMessagingTestStore(t, clock.Now()) + sender := &scriptedTextSender{steps: []sendStep{{result: bridge.SendResult{RemoteMessageID: "remote-race"}}}} + registry := newScriptedRegistry("ttl-lease-race", sender) + registry.setAvailable(true) + service := newMessagingTestService(t, store, registry, clock) + + submission := mustSendText(t, service, ttlCommand("key-ttl-lease-race", time.Minute)) + clock.Advance(2 * time.Minute) + + // DispatchDue without a prior expiry sweep: the lease query itself must + // exclude the expired row, so a scheduling race can never transmit stale. + if processed, err := service.DispatchDue(context.Background(), 8); err != nil || processed != 0 { + t.Fatalf("DispatchDue(expired, no sweep) = %d, %v; want 0, nil", processed, err) + } + if got := sender.requestCount(); got != 0 { + t.Fatalf("transport called %d times, want 0", got) + } + if got := mustDelivery(t, service, submission.OutboxID).State; got != OutboxQueued { + t.Fatalf("state = %q, want still queued until the sweep cancels it", got) + } +} + +func TestNearDuplicateSendBlockedThenForced(t *testing.T) { + clock := newManualClock(messagingTestTime) + store := openMessagingTestStore(t, clock.Now()) + registry := newScriptedRegistry("dup-guard", &scriptedTextSender{steps: []sendStep{ + {result: bridge.SendResult{RemoteMessageID: "remote-first"}}, + {result: bridge.SendResult{RemoteMessageID: "remote-forced"}}, + }}) + registry.setAvailable(true) + service := newMessagingTestService(t, store, registry, clock) + + first := mustSendText(t, service, SendTextCommand{ + CommonCommand: testCommonCommand("key-dup-first"), + Body: "Lunch tomorrow at noon at Sfoglina?", + }) + + // The incident retry: near-identical body, new idempotency key, minutes + // later. Must be blocked with the prior intent named. + clock.Advance(2 * time.Minute) + _, err := service.SendText(context.Background(), SendTextCommand{ + CommonCommand: testCommonCommand("key-dup-second"), + Body: "Lunch today at noon at Sfoglina?", + }) + if !errors.Is(err, ErrDuplicateSend) { + t.Fatalf("near-duplicate error = %v, want ErrDuplicateSend", err) + } + var duplicate *DuplicateSendError + if !errors.As(err, &duplicate) { + t.Fatalf("error %v does not unwrap to *DuplicateSendError", err) + } + if duplicate.PriorOutboxID != first.OutboxID { + t.Fatalf("prior outbox = %q, want %q", duplicate.PriorOutboxID, first.OutboxID) + } + if duplicate.PriorState != OutboxQueued { + t.Fatalf("prior state = %q, want queued", duplicate.PriorState) + } + + // Force is the explicit override for a deliberate repeat. + forced := testCommonCommand("key-dup-forced") + forced.Force = true + mustSendText(t, service, SendTextCommand{ + CommonCommand: forced, + Body: "Lunch today at noon at Sfoglina?", + }) +} + +func TestNearDuplicateGuardScope(t *testing.T) { + clock := newManualClock(messagingTestTime) + store := openMessagingTestStore(t, clock.Now()) + registry := newScriptedRegistry("dup-scope", &scriptedTextSender{}) + service := newMessagingTestService(t, store, registry, clock) + + seedConversation(t, store, "account-1", "conversation-2", clock.Now()) + + mustSendText(t, service, SendTextCommand{ + CommonCommand: testCommonCommand("key-scope-first"), + Body: "Lunch tomorrow at noon at Sfoglina?", + }) + + // A different message to the same conversation passes. + mustSendText(t, service, SendTextCommand{ + CommonCommand: testCommonCommand("key-scope-different"), + Body: "Completely unrelated: did you see the game?", + }) + + // The same message to a DIFFERENT conversation passes. + other := testCommonCommand("key-scope-other-conversation") + other.ConversationID = "conversation-2" + mustSendText(t, service, SendTextCommand{ + CommonCommand: other, + Body: "Lunch tomorrow at noon at Sfoglina?", + }) + + // Short conversational repeats pass ("ok" / "ok!"). + mustSendText(t, service, SendTextCommand{ + CommonCommand: testCommonCommand("key-scope-ok-1"), + Body: "ok", + }) + mustSendText(t, service, SendTextCommand{ + CommonCommand: testCommonCommand("key-scope-ok-2"), + Body: "ok!", + }) + + // Outside the window, the same body passes again. + clock.Advance(11 * time.Minute) + mustSendText(t, service, SendTextCommand{ + CommonCommand: testCommonCommand("key-scope-after-window"), + Body: "Lunch tomorrow at noon at Sfoglina?", + }) +} + +func TestSameKeyReplayBypassesDuplicateGuard(t *testing.T) { + clock := newManualClock(messagingTestTime) + store := openMessagingTestStore(t, clock.Now()) + registry := newScriptedRegistry("dup-replay", &scriptedTextSender{}) + service := newMessagingTestService(t, store, registry, clock) + + first := mustSendText(t, service, SendTextCommand{ + CommonCommand: testCommonCommand("key-replay"), + Body: "exact same send, lost response", + }) + // Replaying with the SAME idempotency key is the documented safe retry + // and must reach enqueue-level deduplication, not the guard. + replay := mustSendText(t, service, SendTextCommand{ + CommonCommand: testCommonCommand("key-replay"), + Body: "exact same send, lost response", + }) + if replay.OutboxID != first.OutboxID || !replay.Deduplicated { + t.Fatalf("replay = %+v, want deduplicated original %q", replay, first.OutboxID) + } +} + +func TestTextsNearDuplicateThreshold(t *testing.T) { + tests := []struct { + name string + a, b string + want bool + }{ + {name: "identical", a: "see you soon", b: "see you soon", want: true}, + {name: "case and whitespace", a: "See you soon", b: "see you SOON", want: true}, + {name: "one word swapped", a: "Lunch tomorrow at noon at Sfoglina?", b: "Lunch today at noon at Sfoglina?", want: true}, + {name: "different messages", a: "Lunch tomorrow?", b: "Did you see the game last night?", want: false}, + {name: "short repeats differ", a: "ok", b: "ok!", want: false}, + {name: "empty never matches", a: "", b: "", want: false}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := textsNearDuplicate(test.a, test.b, defaultDuplicateThreshold); got != test.want { + t.Fatalf("textsNearDuplicate(%q, %q) = %v, want %v", test.a, test.b, got, test.want) + } + }) + } +} + +// seedConversation adds a second conversation for cross-conversation guard +// scope checks, mirroring seedMessagingStore's shape. +func seedConversation(t *testing.T, store *sqlite.Store, accountID, conversationID string, now time.Time) { + t.Helper() + if err := store.UpsertConversation(sqlite.Conversation{ + ConversationID: conversationID, + AccountID: accountID, + RemoteConversationID: "remote-" + conversationID, + Kind: sqlite.ConversationKindDirect, + Title: "Second conversation", + NotificationMode: sqlite.NotificationModeAll, + MetadataJSON: `{}`, + CreatedAtMS: now.UnixMilli(), + UpdatedAtMS: now.UnixMilli(), + }); err != nil { + t.Fatalf("seed conversation %q: %v", conversationID, err) + } +} diff --git a/internal/messaging/types.go b/internal/messaging/types.go index a4b81ae..57a9b4c 100644 --- a/internal/messaging/types.go +++ b/internal/messaging/types.go @@ -59,13 +59,48 @@ var ( // ErrNotImplemented marks API seams reserved for later rebuild items. ErrNotImplemented = errors.New("messaging: not implemented") + + // ErrDuplicateSend means a near-identical text was already submitted to + // the same conversation moments ago and the new submission did not carry + // Force. The wrapped DuplicateSendError names the prior intent. + ErrDuplicateSend = errors.New("messaging: near-duplicate send blocked") ) +// DuplicateSendError reports the prior intent that triggered the +// near-duplicate guard so callers can decide between waiting, canceling the +// prior send, or forcing this one. +type DuplicateSendError struct { + PriorOutboxID string + PriorState OutboxState + PriorIdempotencyKey string + PriorAgeMS int64 +} + +func (e *DuplicateSendError) Error() string { + return fmt.Sprintf( + "messaging: near-duplicate send blocked: a very similar message was submitted to this conversation %s ago (outbox %s, state %s); if this is intentional, resubmit with force", + (time.Duration(e.PriorAgeMS) * time.Millisecond).Round(time.Second), + e.PriorOutboxID, + e.PriorState, + ) +} + +func (e *DuplicateSendError) Unwrap() error { return ErrDuplicateSend } + type CommonCommand struct { AccountID string ConversationID string IdempotencyKey string NotBefore time.Time // zero means now + + // TTL bounds how long the intent may wait to cross the transport + // boundary, measured from the later of submission and NotBefore. An + // intent still queued when the window closes is canceled instead of + // transmitted stale. Zero means the intent never expires. + TTL time.Duration + + // Force bypasses the near-duplicate guard for a deliberate resend. + Force bool } type SendTextCommand struct { @@ -102,17 +137,27 @@ type Submission struct { LocalMessageID string State OutboxState ScheduledFor time.Time + ExpiresAt time.Time // zero means the intent never expires Deduplicated bool } type Delivery struct { OutboxID string + AccountID string + ConversationID string State OutboxState LocalMessageID string RemoteMessageID string ErrorClass string ErrorCode string Warning string + ExpiresAt time.Time // zero means the intent never expires +} + +// Expired reports whether the delivery was canceled by its send window +// closing rather than by an explicit cancel. +func (d Delivery) Expired() bool { + return d.State == OutboxCanceled && d.ErrorClass == sqlite.TTLErrorClass } // TransportEcho is the transport-neutral correlation shape reserved for M5. diff --git a/internal/migration/transform_test.go b/internal/migration/transform_test.go index de61a87..e6e7c80 100644 --- a/internal/migration/transform_test.go +++ b/internal/migration/transform_test.go @@ -522,8 +522,8 @@ func assertFixtureReport(t *testing.T, report Report, sourceHash string) { t.Errorf("source file evidence did not reconcile: %+v", file) } } - if report.Target.SchemaVersion != 10 || len(report.Target.MigrationChecksums) != 10 { - t.Fatalf("target schema = version %d with %d checksums, want version 10 with 10 checksums", report.Target.SchemaVersion, len(report.Target.MigrationChecksums)) + if report.Target.SchemaVersion != 11 || len(report.Target.MigrationChecksums) != 11 { + t.Fatalf("target schema = version %d with %d checksums, want version 11 with 11 checksums", report.Target.SchemaVersion, len(report.Target.MigrationChecksums)) } wantTargetCounts := map[string]int64{ "accounts": 5, "devices": 5, "people": 1, "person_identities": 2, diff --git a/internal/migration/validate.go b/internal/migration/validate.go index 4f7465e..406103d 100644 --- a/internal/migration/validate.go +++ b/internal/migration/validate.go @@ -23,7 +23,10 @@ var targetCountTables = []string{ "read_cursors", } -const migration0010Checksum = "dfab4551335d92045cb895e5b2c781f4f57216bbc428a705fc26bbdd474db0b1" +const ( + migration0010Checksum = "dfab4551335d92045cb895e5b2c781f4f57216bbc428a705fc26bbdd474db0b1" + migration0011Checksum = "2426daed0648042953493ebc2292cc578ed588c87656fcc8f80541f13ffcee0e" +) func checkpointAndSyncSQLite(ctx context.Context, path string) error { database, err := sql.Open("sqlite", path) @@ -151,8 +154,9 @@ func validateTarget( countsMatched := countsMatch(dataset, state, report, actualHistory, actualScheduled, actualHistoryByPlatform) report.Validation.CountsMatched = countsMatched report.Validation.Passed = quick == "ok" && - report.Target.SchemaVersion == 10 && len(report.Target.MigrationChecksums) == 10 && + report.Target.SchemaVersion == 11 && len(report.Target.MigrationChecksums) == 11 && report.Target.MigrationChecksums[9] == migration0010Checksum && + report.Target.MigrationChecksums[10] == migration0011Checksum && len(fkViolations) == 0 && orphanTotal(orphans) == 0 && countsMatched && report.Validation.SampledHashesMatched && report.Validation.BlobReferencesValid && report.Validation.SourceUnchanged && diff --git a/internal/sendcap/sendcap.go b/internal/sendcap/sendcap.go new file mode 100644 index 0000000..49d3141 --- /dev/null +++ b/internal/sendcap/sendcap.go @@ -0,0 +1,127 @@ +// Package sendcap computes per-platform SEND capability: whether a send +// submitted right now is expected to reach the transport promptly, with the +// reason when it is not. It is deliberately stricter than "connected" — a +// paired-but-dark platform still accepts sends into the durable outbox, +// where they wait, which is exactly what a caller must know before +// submitting a time-sensitive message (2026-08-05: a send reported as +// accepted flushed ~15 hours later and double-texted the recipient). +// +// The daemon publishes this as the /api/status "send" block, and the +// transportless MCP client enforces it before submitting; keeping the +// computation here keeps the two surfaces answering identically. +package sendcap + +import ( + "github.com/maxghenis/openmessage/internal/app" + "github.com/maxghenis/openmessage/internal/signallive" + "github.com/maxghenis/openmessage/internal/whatsapplive" +) + +// Platform keys of the capability map. "sms" covers Google Messages +// (SMS/RCS); RCS-vs-SMS is not distinguishable at this layer and is +// deliberately not guessed. +const ( + PlatformSMS = "sms" + PlatformWhatsApp = "whatsapp" + PlatformSignal = "signal" +) + +// Capability reports one platform's send path. +// +// Available=false splits into two tiers: +// - Queueable=true: a self-healing outage (transport briefly disconnected, +// phone not responding). A durable send submitted now is accepted, +// reported truthfully as queued/not-transmitted, and transmits when the +// platform recovers — or cancels at its TTL. Send paths allow these. +// - Queueable=false: the platform cannot send and will not recover on its +// own (not paired, adapter unregistered, auth revoked). Send paths +// refuse these outright rather than queueing into a black hole. +type Capability struct { + Available bool `json:"available"` + Queueable bool `json:"queueable,omitempty"` + Reason string `json:"reason,omitempty"` +} + +// Inputs are the live transport snapshots plus the optional v2 send-stack +// adapter view. +type Inputs struct { + // TransportsEnabled is false in daemon shapes that hold no live platform + // connections; nothing can send from such a process. + TransportsEnabled bool + + Google app.GoogleStatusSnapshot + WhatsApp whatsapplive.StatusSnapshot + Signal signallive.StatusSnapshot + + // AdapterTextSend reports whether the v2 send stack has a registered + // adapter with text-send capability for the platform key. Nil means no + // v2 send stack is active (legacy direct-transport sends). + AdapterTextSend func(platform string) bool +} + +// Compute builds the capability map for the three send platforms. +func Compute(in Inputs) map[string]Capability { + capabilities := make(map[string]Capability, 3) + if !in.TransportsEnabled { + off := Capability{ + Reason: "this process holds no live platform connections and cannot send on any platform", + } + capabilities[PlatformSMS] = off + capabilities[PlatformWhatsApp] = off + capabilities[PlatformSignal] = off + return capabilities + } + + adapterSendable := func(platform string) bool { + if in.AdapterTextSend == nil { + return true + } + return in.AdapterTextSend(platform) + } + adapterMissing := Capability{ + Reason: "the platform adapter is not registered with the v2 send stack in this run (receive-only); sends on this platform fail rather than queue", + } + + switch { + case !adapterSendable(PlatformSMS): + capabilities[PlatformSMS] = adapterMissing + case !in.Google.Paired: + capabilities[PlatformSMS] = Capability{Reason: "google messages is not paired"} + case in.Google.AuthExpired: + capabilities[PlatformSMS] = Capability{Reason: "google messages session cookies were rejected; re-pair or wait for automatic repair"} + case in.Google.NeedsRepair: + capabilities[PlatformSMS] = Capability{Reason: "google messages reports connected but consecutive sends have failed; the phone has likely unlinked this device"} + case !in.Google.Connected: + capabilities[PlatformSMS] = Capability{Queueable: true, Reason: "google messages is disconnected; a send submitted now would wait in the outbox until it reconnects"} + case !in.Google.PhoneResponding: + capabilities[PlatformSMS] = Capability{Queueable: true, Reason: "the paired phone is not responding; google may accept a send and hold it until the phone comes back"} + default: + capabilities[PlatformSMS] = Capability{Available: true} + } + + switch { + case !adapterSendable(PlatformWhatsApp): + capabilities[PlatformWhatsApp] = adapterMissing + case !in.WhatsApp.Paired: + capabilities[PlatformWhatsApp] = Capability{Reason: "whatsapp is not paired"} + case !in.WhatsApp.Connected: + capabilities[PlatformWhatsApp] = Capability{Queueable: true, Reason: "whatsapp is disconnected; a send submitted now would wait in the outbox until it reconnects"} + default: + capabilities[PlatformWhatsApp] = Capability{Available: true} + } + + switch { + case !adapterSendable(PlatformSignal): + capabilities[PlatformSignal] = adapterMissing + case !in.Signal.Paired: + capabilities[PlatformSignal] = Capability{Reason: "signal is not paired"} + case in.Signal.NeedsReauth: + capabilities[PlatformSignal] = Capability{Reason: "signal reports the linked account is no longer authorized; re-pair from Platforms"} + case !in.Signal.Connected: + capabilities[PlatformSignal] = Capability{Queueable: true, Reason: "signal is disconnected; a send submitted now would wait in the outbox until it reconnects"} + default: + capabilities[PlatformSignal] = Capability{Available: true} + } + + return capabilities +} diff --git a/internal/sendcap/sendcap_test.go b/internal/sendcap/sendcap_test.go new file mode 100644 index 0000000..a47ee2a --- /dev/null +++ b/internal/sendcap/sendcap_test.go @@ -0,0 +1,111 @@ +package sendcap + +import ( + "testing" + + "github.com/maxghenis/openmessage/internal/app" + "github.com/maxghenis/openmessage/internal/signallive" + "github.com/maxghenis/openmessage/internal/whatsapplive" +) + +func healthyInputs() Inputs { + return Inputs{ + TransportsEnabled: true, + Google: app.GoogleStatusSnapshot{Connected: true, Paired: true, PhoneResponding: true}, + WhatsApp: whatsapplive.StatusSnapshot{Connected: true, Paired: true}, + Signal: signallive.StatusSnapshot{Connected: true, Paired: true}, + } +} + +func TestComputeAllHealthy(t *testing.T) { + capabilities := Compute(healthyInputs()) + for _, platform := range []string{PlatformSMS, PlatformWhatsApp, PlatformSignal} { + capability := capabilities[platform] + if !capability.Available || capability.Reason != "" { + t.Fatalf("%s = %+v, want available with no reason", platform, capability) + } + } +} + +func TestComputeTransportsDisabledBlocksEverything(t *testing.T) { + inputs := healthyInputs() + inputs.TransportsEnabled = false + for platform, capability := range Compute(inputs) { + if capability.Available || capability.Queueable || capability.Reason == "" { + t.Fatalf("%s = %+v, want hard-unavailable with reason", platform, capability) + } + } +} + +func TestComputeTiersSelfHealingVersusHardOutages(t *testing.T) { + // Disconnected transports are queueable: the durable outbox exists for + // exactly this, and TTL bounds the staleness. + inputs := healthyInputs() + inputs.Google.Connected = false + inputs.WhatsApp.Connected = false + inputs.Signal.Connected = false + capabilities := Compute(inputs) + for _, platform := range []string{PlatformSMS, PlatformWhatsApp, PlatformSignal} { + capability := capabilities[platform] + if capability.Available || !capability.Queueable { + t.Fatalf("%s disconnected = %+v, want unavailable but queueable", platform, capability) + } + } + + // Unpaired platforms are hard-unavailable: nothing will self-heal. + inputs = healthyInputs() + inputs.Google.Paired = false + inputs.WhatsApp.Paired = false + inputs.Signal.Paired = false + capabilities = Compute(inputs) + for _, platform := range []string{PlatformSMS, PlatformWhatsApp, PlatformSignal} { + capability := capabilities[platform] + if capability.Available || capability.Queueable { + t.Fatalf("%s unpaired = %+v, want hard-unavailable", platform, capability) + } + } +} + +func TestComputeGoogleDegradedStates(t *testing.T) { + inputs := healthyInputs() + inputs.Google.NeedsRepair = true + if capability := Compute(inputs)[PlatformSMS]; capability.Available || capability.Queueable { + t.Fatalf("needs_repair = %+v, want hard-unavailable (sends keep failing)", capability) + } + + inputs = healthyInputs() + inputs.Google.AuthExpired = true + if capability := Compute(inputs)[PlatformSMS]; capability.Available || capability.Queueable { + t.Fatalf("auth_expired = %+v, want hard-unavailable", capability) + } + + // PhoneResponding=false is the incident mechanism: Google can accept a + // send and hold it until the phone comes back. Queueable, with the + // warning surfaced. + inputs = healthyInputs() + inputs.Google.PhoneResponding = false + capability := Compute(inputs)[PlatformSMS] + if capability.Available || !capability.Queueable || capability.Reason == "" { + t.Fatalf("phone_not_responding = %+v, want queueable with reason", capability) + } +} + +func TestComputeAdapterMissingIsHardUnavailable(t *testing.T) { + inputs := healthyInputs() + inputs.AdapterTextSend = func(platform string) bool { return platform != PlatformWhatsApp } + capabilities := Compute(inputs) + if capability := capabilities[PlatformWhatsApp]; capability.Available || capability.Queueable { + t.Fatalf("adapter-missing whatsapp = %+v, want hard-unavailable (receive-only)", capability) + } + if !capabilities[PlatformSMS].Available || !capabilities[PlatformSignal].Available { + t.Fatalf("other platforms affected: %+v", capabilities) + } +} + +func TestComputeSignalNeedsReauthIsHardUnavailable(t *testing.T) { + inputs := healthyInputs() + inputs.Signal.NeedsReauth = true + if capability := Compute(inputs)[PlatformSignal]; capability.Available || capability.Queueable { + t.Fatalf("needs_reauth = %+v, want hard-unavailable", capability) + } +} diff --git a/internal/storage/sqlite/attachments_test.go b/internal/storage/sqlite/attachments_test.go index eb49522..165f253 100644 --- a/internal/storage/sqlite/attachments_test.go +++ b/internal/storage/sqlite/attachments_test.go @@ -322,8 +322,8 @@ func openAttachmentTestRepository(t *testing.T) (*Store, *AttachmentRepository) } }) - if len(embeddedMigrations) != 10 { - t.Fatalf("embedded migrations = %d, want 10", len(embeddedMigrations)) + if len(embeddedMigrations) != 11 { + t.Fatalf("embedded migrations = %d, want 11", len(embeddedMigrations)) } assertPragmaInt(t, store.db, "user_version", len(embeddedMigrations)) ledger := readLedgerRow(t, store.db, 3) diff --git a/internal/storage/sqlite/identity_graph_test.go b/internal/storage/sqlite/identity_graph_test.go index 1f8bb81..37afcec 100644 --- a/internal/storage/sqlite/identity_graph_test.go +++ b/internal/storage/sqlite/identity_graph_test.go @@ -162,8 +162,8 @@ func openIdentityGraphTestStore(t *testing.T) *Store { } }) - if len(embeddedMigrations) != 10 { - t.Fatalf("embedded migrations = %d, want 10", len(embeddedMigrations)) + if len(embeddedMigrations) != 11 { + t.Fatalf("embedded migrations = %d, want 11", len(embeddedMigrations)) } assertPragmaInt(t, store.db, "user_version", len(embeddedMigrations)) ledger := readLedgerRow(t, store.db, 2) diff --git a/internal/storage/sqlite/message_attachments_test.go b/internal/storage/sqlite/message_attachments_test.go index 5ccd12a..102fcf8 100644 --- a/internal/storage/sqlite/message_attachments_test.go +++ b/internal/storage/sqlite/message_attachments_test.go @@ -83,8 +83,8 @@ func TestMessageAttachmentsMigrationAppliesToBlankAndExistingV7AndReopens(t *tes } }) after := readLedgerRows(t, store.db) - if len(after) != 10 { - t.Fatalf("migrated ledger rows = %d, want 10", len(after)) + if len(after) != 11 { + t.Fatalf("migrated ledger rows = %d, want 11", len(after)) } if !slices.Equal(after[:7], before) { t.Fatalf("migrations 0001-0007 changed:\nbefore: %+v\nafter: %+v", before, after[:7]) @@ -252,10 +252,10 @@ func TestMessageAttachmentRowsCascadeWithMessageDeletion(t *testing.T) { func assertMessageAttachmentsMigration(t *testing.T, store *Store) { t.Helper() - if len(embeddedMigrations) != 10 { - t.Fatalf("embedded migrations = %d, want 10", len(embeddedMigrations)) + if len(embeddedMigrations) != 11 { + t.Fatalf("embedded migrations = %d, want 11", len(embeddedMigrations)) } - assertPragmaInt(t, store.db, "user_version", 10) + assertPragmaInt(t, store.db, "user_version", 11) ledger := readLedgerRow(t, store.db, 8) if ledger.name != "message_attachments" { t.Fatalf("migration 0008 name = %q, want message_attachments", ledger.name) diff --git a/internal/storage/sqlite/messages_test.go b/internal/storage/sqlite/messages_test.go index 70449ac..4841b51 100644 --- a/internal/storage/sqlite/messages_test.go +++ b/internal/storage/sqlite/messages_test.go @@ -1178,8 +1178,8 @@ func TestMessagesInboxMigrationIsChecksummedAndStrict(t *testing.T) { t, func() time.Time { return time.UnixMilli(messageTestTimeMS) }, ) - if len(embeddedMigrations) != 10 { - t.Fatalf("embedded migrations = %d, want 10", len(embeddedMigrations)) + if len(embeddedMigrations) != 11 { + t.Fatalf("embedded migrations = %d, want 11", len(embeddedMigrations)) } assertPragmaInt(t, store.db, "user_version", len(embeddedMigrations)) ledger := readLedgerRow(t, store.db, 4) diff --git a/internal/storage/sqlite/migrations.go b/internal/storage/sqlite/migrations.go index ba26da5..82ccb57 100644 --- a/internal/storage/sqlite/migrations.go +++ b/internal/storage/sqlite/migrations.go @@ -66,6 +66,9 @@ var migration0009SQL string //go:embed migrations/0010_reactions.sql var migration0010SQL string +//go:embed migrations/0011_outbox_expiry.sql +var migration0011SQL string + var embeddedMigrations = []migration{ newMigration(1, "storage_shell", migration0001SQL, newStorageShellArguments), newMigration(2, "identity_graph", migration0002SQL, nil), @@ -77,6 +80,7 @@ var embeddedMigrations = []migration{ newMigration(8, "message_attachments", migration0008SQL, nil), newMigration(9, "outbox_send_again", migration0009SQL, nil), newMigration(10, "reactions", migration0010SQL, nil), + newMigration(11, "outbox_expiry", migration0011SQL, nil), } func newMigration( diff --git a/internal/storage/sqlite/migrations/0011_outbox_expiry.sql b/internal/storage/sqlite/migrations/0011_outbox_expiry.sql new file mode 100644 index 0000000..80d0eec --- /dev/null +++ b/internal/storage/sqlite/migrations/0011_outbox_expiry.sql @@ -0,0 +1,15 @@ +-- Interactive sends may carry a hard expiry. An intent that has not crossed +-- the transport boundary by expires_at_ms is canceled instead of transmitted +-- stale (2026-08-05: an overnight-queued send flushed ~15 hours later, +-- seconds behind the day-of retry, double-texting the recipient). NULL means +-- the intent never expires, which preserves the behavior of every existing +-- row and of app-initiated sends. +ALTER TABLE outbox + ADD COLUMN expires_at_ms INTEGER CHECK ( + expires_at_ms IS NULL OR expires_at_ms > 0 + ); + +CREATE INDEX outbox_expiry_idx + ON outbox(expires_at_ms) + WHERE expires_at_ms IS NOT NULL + AND state IN ('queued', 'not_dispatched'); diff --git a/internal/storage/sqlite/outbox.go b/internal/storage/sqlite/outbox.go index b0b0046..9a0cb8d 100644 --- a/internal/storage/sqlite/outbox.go +++ b/internal/storage/sqlite/outbox.go @@ -64,6 +64,10 @@ type NewOutboxItem struct { SendAgainOfOutboxID string ScheduledFor time.Time ScheduledForMS int64 + // ExpiresAtMS is a hard send window: an intent that has not crossed the + // transport boundary by this wall-clock time is canceled instead of + // transmitted stale. Zero means the intent never expires. + ExpiresAtMS int64 } // OutboxItem mirrors one row in outbox. Nullable database fields are pointers. @@ -90,6 +94,7 @@ type OutboxItem struct { TransportCalledAtMS *int64 ScheduledForMS int64 NextAttemptAtMS *int64 + ExpiresAtMS *int64 CreatedAtMS int64 UpdatedAtMS int64 } @@ -458,9 +463,10 @@ func (r *OutboxRepository) enqueue( send_again_of_outbox_id, attempt_count, scheduled_for_ms, + expires_at_ms, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, 'queued', ?, ?, ?, 0, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, 'queued', ?, ?, ?, 0, ?, ?, ?, ?) ON CONFLICT(account_id, idempotency_key) DO NOTHING `, item.OutboxID, @@ -474,6 +480,7 @@ func (r *OutboxRepository) enqueue( item.TransportRequestID, nullableOutboxText(item.SendAgainOfOutboxID), scheduledForMS, + nullableOutboxMS(item.ExpiresAtMS), nowMS, nowMS, ) @@ -1172,9 +1179,10 @@ func (r *OutboxRepository) LeaseDue( AND state IN ('queued', 'not_dispatched') AND (next_attempt_at_ms IS NULL OR next_attempt_at_ms <= ?) AND (lease_expires_at_ms IS NULL OR lease_expires_at_ms <= ?) + AND (expires_at_ms IS NULL OR expires_at_ms > ?) ORDER BY COALESCE(next_attempt_at_ms, scheduled_for_ms), created_at_ms, outbox_id LIMIT ? - `, nowMS, nowMS, nowMS, req.Limit) + `, nowMS, nowMS, nowMS, nowMS, req.Limit) if err != nil { return nil, fmt.Errorf("lease due outbox items: select candidates: %w", err) } @@ -1210,7 +1218,8 @@ func (r *OutboxRepository) LeaseDue( AND state IN ('queued', 'not_dispatched') AND (next_attempt_at_ms IS NULL OR next_attempt_at_ms <= ?) AND (lease_expires_at_ms IS NULL OR lease_expires_at_ms <= ?) - `, req.Owner, token, expiresAtMS, nowMS, id, nowMS, nowMS, nowMS) + AND (expires_at_ms IS NULL OR expires_at_ms > ?) + `, req.Owner, token, expiresAtMS, nowMS, id, nowMS, nowMS, nowMS, nowMS) if err != nil { return nil, fmt.Errorf("lease outbox item %q: update: %w", id, err) } @@ -1400,6 +1409,136 @@ func (r *OutboxRepository) RetryNotDispatched( // Cancel transitions pending work to a terminal canceled state. Active or // already-terminal rows are rejected so a transport call cannot race a cancel. +// TTLErrorClass and TTLErrorCode mark an intent canceled by CancelExpired +// rather than by an explicit user action. Readers use them to report "expired +// unsent" instead of a bare cancellation. +const ( + TTLErrorClass = "ttl" + TTLErrorCode = "send_window_expired" +) + +// CancelExpired cancels every intent whose send window closed before it +// crossed the transport boundary. Only pre-transport states are eligible: +// dispatching, uncertain, and terminal rows are left untouched because the +// transport may already own them. Returns the canceled outbox IDs. +func (r *OutboxRepository) CancelExpired(ctx context.Context, now time.Time) ([]string, error) { + nowMS := now.UnixMilli() + if nowMS <= 0 { + return nil, fmt.Errorf("cancel expired outbox items: current Unix time is not positive") + } + + tx, err := r.store.db.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("cancel expired outbox items: begin transaction: %w", err) + } + defer tx.Rollback() + + rows, err := tx.QueryContext(ctx, ` + SELECT outbox_id + FROM outbox + WHERE state IN ('queued', 'not_dispatched') + AND expires_at_ms IS NOT NULL + AND expires_at_ms <= ? + ORDER BY expires_at_ms, outbox_id + `, nowMS) + if err != nil { + return nil, fmt.Errorf("cancel expired outbox items: select candidates: %w", err) + } + ids, err := collectRows(rows, func(row rowScanner) (string, error) { + var id string + err := row.Scan(&id) + return id, err + }) + if err != nil { + return nil, fmt.Errorf("cancel expired outbox items: scan candidates: %w", err) + } + if len(ids) == 0 { + return nil, tx.Commit() + } + + for _, id := range ids { + result, err := tx.ExecContext(ctx, ` + UPDATE outbox + SET state = 'canceled', + error_class = ?, + error_code = ?, + error_detail = 'send window expired before the message reached the transport; it was NOT sent', + next_attempt_at_ms = NULL, + updated_at_ms = ? + WHERE outbox_id = ? + AND state IN ('queued', 'not_dispatched') + AND expires_at_ms IS NOT NULL + AND expires_at_ms <= ? + `, TTLErrorClass, TTLErrorCode, nowMS, id, nowMS) + if err != nil { + return nil, fmt.Errorf("cancel expired outbox item %q: %w", id, err) + } + if _, err := result.RowsAffected(); err != nil { + return nil, fmt.Errorf("cancel expired outbox item %q: read rows affected: %w", id, err) + } + } + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("cancel expired outbox items: commit: %w", err) + } + return ids, nil +} + +// RecentTextIntent is one prior text send used by the near-duplicate guard. +type RecentTextIntent struct { + OutboxID string + IdempotencyKey string + State OutboxState + Body string + CreatedAtMS int64 +} + +// ListRecentTextIntents returns text intents created at or after sinceMS in +// one conversation, newest first, excluding states proven not to have sent +// (rejected, canceled). Everything else — queued, dispatching, retrying, +// uncertain, and confirmed — did or still may reach the recipient, so a +// near-duplicate submission against any of them deserves the guard. +func (r *OutboxRepository) ListRecentTextIntents( + ctx context.Context, + accountID string, + conversationID string, + sinceMS int64, + limit int, +) ([]RecentTextIntent, error) { + if limit <= 0 { + return nil, fmt.Errorf("list recent text intents: limit must be positive") + } + rows, err := r.store.db.QueryContext(ctx, ` + SELECT o.outbox_id, o.idempotency_key, o.state, COALESCE(m.body, ''), o.created_at_ms + FROM outbox o + LEFT JOIN messages m ON m.message_id = o.local_message_id + WHERE o.account_id = ? + AND o.conversation_id = ? + AND o.kind = 'text' + AND o.created_at_ms >= ? + AND o.state NOT IN ('rejected', 'canceled') + ORDER BY o.created_at_ms DESC, o.outbox_id DESC + LIMIT ? + `, accountID, conversationID, sinceMS, limit) + if err != nil { + return nil, fmt.Errorf("list recent text intents: query: %w", err) + } + intents, err := collectRows(rows, func(row rowScanner) (RecentTextIntent, error) { + var intent RecentTextIntent + err := row.Scan( + &intent.OutboxID, + &intent.IdempotencyKey, + &intent.State, + &intent.Body, + &intent.CreatedAtMS, + ) + return intent, err + }) + if err != nil { + return nil, fmt.Errorf("list recent text intents: scan: %w", err) + } + return intents, nil +} + func (r *OutboxRepository) Cancel(ctx context.Context, outboxID string) error { nowMS, err := r.nowMS("cancel outbox item") if err != nil { @@ -2097,6 +2236,7 @@ const outboxColumns = ` transport_called_at_ms, scheduled_for_ms, next_attempt_at_ms, + expires_at_ms, created_at_ms, updated_at_ms` @@ -2130,6 +2270,7 @@ func scanPendingRow(row rowScanner) (PendingRow, error) { &pending.TransportCalledAtMS, &pending.ScheduledForMS, &pending.NextAttemptAtMS, + &pending.ExpiresAtMS, &pending.CreatedAtMS, &pending.UpdatedAtMS, &pending.Body, @@ -2176,6 +2317,7 @@ func scanCarryableIntent(row rowScanner) (CarryableIntent, error) { &intent.TransportCalledAtMS, &intent.ScheduledForMS, &intent.NextAttemptAtMS, + &intent.ExpiresAtMS, &intent.CreatedAtMS, &intent.UpdatedAtMS, &remoteConversationID, @@ -2230,6 +2372,7 @@ func scanOutboxItem(row rowScanner) (OutboxItem, error) { &item.TransportCalledAtMS, &item.ScheduledForMS, &item.NextAttemptAtMS, + &item.ExpiresAtMS, &item.CreatedAtMS, &item.UpdatedAtMS, ) @@ -2268,6 +2411,9 @@ func validateNewOutboxItem(item NewOutboxItem) error { if !item.ScheduledFor.IsZero() && item.ScheduledForMS != 0 { return fmt.Errorf("ScheduledFor and ScheduledForMS are both set") } + if item.ExpiresAtMS < 0 { + return fmt.Errorf("expiry time is negative") + } return nil } @@ -2292,6 +2438,13 @@ func nullableOutboxText(value string) any { return value } +func nullableOutboxMS(value int64) any { + if value == 0 { + return nil + } + return value +} + // sameSendAgainLink compares a stored nullable link against an incoming // command's link, where the empty string means "no link" (stored as NULL). func sameSendAgainLink(stored *string, incoming string) bool { diff --git a/internal/storage/sqlite/outbox_expiry_test.go b/internal/storage/sqlite/outbox_expiry_test.go new file mode 100644 index 0000000..b5f7ad0 --- /dev/null +++ b/internal/storage/sqlite/outbox_expiry_test.go @@ -0,0 +1,156 @@ +package sqlite + +// Expiry-column behavior at the storage layer: CancelExpired's state scope +// and the guarantee that post-transport states are never touched by the +// sweep. The dispatcher-level behavior (lease exclusion, never transmitting +// an expired intent) is covered in internal/messaging. + +import ( + "context" + "testing" + "time" +) + +func outboxExpiryItem(id string, expiresAtMS int64) NewOutboxItem { + item := outboxTestItem(id) + item.ExpiresAtMS = expiresAtMS + return item +} + +func mustEnqueueExpiry(t *testing.T, repository *OutboxRepository, item NewOutboxItem) OutboxItem { + t.Helper() + row, disposition, err := repository.Enqueue(context.Background(), item) + if err != nil || disposition != EnqueueInserted { + t.Fatalf("Enqueue(%s) = %v, %v", item.OutboxID, disposition, err) + } + return row +} + +func TestExpiryColumnRoundTrip(t *testing.T) { + clock := newOutboxTestClock(outboxTestTimeMS) + _, repository := openOutboxTestRepository(t, clock.Now) + + expiry := outboxTestTimeMS + (10 * time.Minute).Milliseconds() + row := mustEnqueueExpiry(t, repository, outboxExpiryItem("expiry-roundtrip", expiry)) + if row.ExpiresAtMS == nil || *row.ExpiresAtMS != expiry { + t.Fatalf("expires_at_ms = %v, want %d", row.ExpiresAtMS, expiry) + } + + unbounded := mustEnqueueExpiry(t, repository, outboxExpiryItem("expiry-unbounded", 0)) + if unbounded.ExpiresAtMS != nil { + t.Fatalf("unbounded expires_at_ms = %v, want nil", *unbounded.ExpiresAtMS) + } +} + +func TestCancelExpiredScope(t *testing.T) { + clock := newOutboxTestClock(outboxTestTimeMS) + _, repository := openOutboxTestRepository(t, clock.Now) + ctx := context.Background() + now := time.UnixMilli(outboxTestTimeMS) + + pastExpiry := outboxTestTimeMS - time.Minute.Milliseconds() + futureExpiry := outboxTestTimeMS + time.Hour.Milliseconds() + + expired := mustEnqueueExpiry(t, repository, outboxExpiryItem("expired", pastExpiry)) + fresh := mustEnqueueExpiry(t, repository, outboxExpiryItem("fresh", futureExpiry)) + unbounded := mustEnqueueExpiry(t, repository, outboxExpiryItem("no-ttl", 0)) + + // An item that already crossed the transport boundary must be left alone + // even once its window closes: lease it, mark the transport called, and + // record uncertain — then shrink its window into the past. + crossed := mustEnqueueExpiry(t, repository, outboxExpiryItem("crossed", futureExpiry)) + leases, err := repository.LeaseDue(ctx, LeaseRequest{ + Owner: "expiry-test", + Now: now, + Duration: time.Minute, + Limit: 10, + }) + if err != nil { + t.Fatalf("LeaseDue(): %v", err) + } + var crossedLease *OutboxItem + for i := range leases { + if leases[i].OutboxID == crossed.OutboxID { + crossedLease = &leases[i].OutboxItem + } + if leases[i].OutboxID == expired.OutboxID { + t.Fatal("LeaseDue leased an expired item") + } + } + if crossedLease == nil { + t.Fatalf("crossed item was not leased; got %d leases", len(leases)) + } + if err := repository.MarkTransportCalled(ctx, Attempt{ + OutboxID: crossed.OutboxID, + LeaseToken: *crossedLease.LeaseToken, + AttemptToken: crossed.OutboxID + ":attempt", + ConnectionGeneration: 1, + StartedAt: now, + }); err != nil { + t.Fatalf("MarkTransportCalled(): %v", err) + } + if err := repository.MarkUncertain(ctx, crossed.OutboxID, *crossedLease.LeaseToken, "unknown", "timeout", "test"); err != nil { + t.Fatalf("MarkUncertain(): %v", err) + } + if _, err := repository.store.db.Exec( + `UPDATE outbox SET expires_at_ms = ? WHERE outbox_id = ?`, pastExpiry, crossed.OutboxID, + ); err != nil { + t.Fatalf("shrink window: %v", err) + } + + // Release the other leased rows back to queued so the sweep sees the + // realistic pre-dispatch states. + for i := range leases { + if leases[i].OutboxID == crossed.OutboxID { + continue + } + if err := repository.ReleaseUnavailable(ctx, leases[i].OutboxID, *leases[i].LeaseToken); err != nil { + t.Fatalf("ReleaseUnavailable(%s): %v", leases[i].OutboxID, err) + } + } + + canceledIDs, err := repository.CancelExpired(ctx, now) + if err != nil { + t.Fatalf("CancelExpired(): %v", err) + } + if len(canceledIDs) != 1 || canceledIDs[0] != expired.OutboxID { + t.Fatalf("canceled = %v, want exactly [%s]", canceledIDs, expired.OutboxID) + } + + assertState := func(id string, want OutboxState) { + t.Helper() + item, err := repository.FindByID(ctx, id) + if err != nil { + t.Fatalf("FindByID(%s): %v", id, err) + } + if item.State != want { + t.Fatalf("%s state = %q, want %q", id, item.State, want) + } + } + assertState(expired.OutboxID, OutboxCanceled) + assertState(fresh.OutboxID, OutboxQueued) + assertState(unbounded.OutboxID, OutboxQueued) + assertState(crossed.OutboxID, OutboxUncertain) + + // The swept row carries the TTL markers so readers report "expired + // unsent" rather than a bare cancellation. + swept, err := repository.FindByID(ctx, expired.OutboxID) + if err != nil { + t.Fatalf("FindByID(swept): %v", err) + } + if swept.ErrorClass == nil || *swept.ErrorClass != TTLErrorClass { + t.Fatalf("error_class = %v, want %q", swept.ErrorClass, TTLErrorClass) + } + if swept.ErrorCode == nil || *swept.ErrorCode != TTLErrorCode { + t.Fatalf("error_code = %v, want %q", swept.ErrorCode, TTLErrorCode) + } + + // Idempotent: a second sweep finds nothing. + again, err := repository.CancelExpired(ctx, now) + if err != nil { + t.Fatalf("CancelExpired(again): %v", err) + } + if len(again) != 0 { + t.Fatalf("second sweep canceled %v, want nothing", again) + } +} diff --git a/internal/storage/sqlite/outbox_test.go b/internal/storage/sqlite/outbox_test.go index a4dea9c..5f4a879 100644 --- a/internal/storage/sqlite/outbox_test.go +++ b/internal/storage/sqlite/outbox_test.go @@ -2473,10 +2473,10 @@ func TestOutboxMigrationIsChecksummedAndStrict(t *testing.T) { store, _ := openOutboxTestRepository(t, func() time.Time { return time.UnixMilli(outboxTestTimeMS) }) - if len(embeddedMigrations) != 10 { - t.Fatalf("embedded migrations = %d, want 10", len(embeddedMigrations)) + if len(embeddedMigrations) != 11 { + t.Fatalf("embedded migrations = %d, want 11", len(embeddedMigrations)) } - assertPragmaInt(t, store.db, "user_version", 10) + assertPragmaInt(t, store.db, "user_version", 11) ledger := readLedgerRow(t, store.db, 5) if ledger.name != "outbox" { t.Fatalf("migration 0005 name = %q, want outbox", ledger.name) @@ -2583,8 +2583,8 @@ func TestOutboxSendAgainMigrationAppliesToBlankAndExistingV8DatabaseWithRows(t * } }) after := readLedgerRows(t, store.db) - if len(after) != 10 { - t.Fatalf("migrated ledger rows = %d, want 10", len(after)) + if len(after) != 11 { + t.Fatalf("migrated ledger rows = %d, want 11", len(after)) } if !slices.Equal(after[:8], before) { t.Fatalf("migrations 0001-0008 changed:\nbefore: %+v\nafter: %+v", before, after[:8]) @@ -2613,7 +2613,7 @@ func TestOutboxSendAgainMigrationAppliesToBlankAndExistingV8DatabaseWithRows(t * func assertOutboxSendAgainMigration(t *testing.T, store *Store) { t.Helper() - assertPragmaInt(t, store.db, "user_version", 10) + assertPragmaInt(t, store.db, "user_version", 11) ledger := readLedgerRow(t, store.db, 9) if ledger.name != "outbox_send_again" { t.Fatalf("migration 0009 name = %q, want outbox_send_again", ledger.name) @@ -2715,8 +2715,8 @@ func TestOutboxAttachmentsMigrationAppliesToBlankAndExistingV5Database(t *testin } }) after := readLedgerRows(t, store.db) - if len(after) != 10 { - t.Fatalf("migrated ledger rows = %d, want 10", len(after)) + if len(after) != 11 { + t.Fatalf("migrated ledger rows = %d, want 11", len(after)) } if !slices.Equal(after[:5], before) { t.Fatalf("migrations 0001-0005 changed:\nbefore: %+v\nafter: %+v", before, after[:5]) @@ -2727,7 +2727,7 @@ func TestOutboxAttachmentsMigrationAppliesToBlankAndExistingV5Database(t *testin func assertOutboxAttachmentsMigration(t *testing.T, store *Store) { t.Helper() - assertPragmaInt(t, store.db, "user_version", 10) + assertPragmaInt(t, store.db, "user_version", 11) ledger := readLedgerRow(t, store.db, 6) if ledger.name != "outbox_attachments" { t.Fatalf("migration 0006 name = %q, want outbox_attachments", ledger.name) @@ -2782,7 +2782,7 @@ func TestReactionsReadMigrationAppliesToBlankAndReopens(t *testing.T) { func assertReactionsReadMigration(t *testing.T, store *Store) { t.Helper() - assertPragmaInt(t, store.db, "user_version", 10) + assertPragmaInt(t, store.db, "user_version", 11) ledger := readLedgerRow(t, store.db, 7) if ledger.name != "reactions_read" { t.Fatalf("migration 0007 name = %q, want reactions_read", ledger.name) diff --git a/internal/storage/sqlite/reactions_migration_test.go b/internal/storage/sqlite/reactions_migration_test.go index 6f92530..af6eb84 100644 --- a/internal/storage/sqlite/reactions_migration_test.go +++ b/internal/storage/sqlite/reactions_migration_test.go @@ -65,8 +65,8 @@ func TestReactionsMigrationAppliesToBlankAndExistingV9Database(t *testing.T) { } }) after := readLedgerRows(t, store.db) - if len(after) != 10 { - t.Fatalf("migrated ledger rows = %d, want 10", len(after)) + if len(after) != 11 { + t.Fatalf("migrated ledger rows = %d, want 11", len(after)) } if !slices.Equal(after[:9], before) { t.Fatalf("migrations 0001-0009 changed:\nbefore: %+v\nafter: %+v", before, after[:9]) @@ -77,10 +77,10 @@ func TestReactionsMigrationAppliesToBlankAndExistingV9Database(t *testing.T) { func assertReactionsMigration(t *testing.T, store *Store) { t.Helper() - if len(embeddedMigrations) != 10 { - t.Fatalf("embedded migrations = %d, want 10", len(embeddedMigrations)) + if len(embeddedMigrations) != 11 { + t.Fatalf("embedded migrations = %d, want 11", len(embeddedMigrations)) } - assertPragmaInt(t, store.db, "user_version", 10) + assertPragmaInt(t, store.db, "user_version", 11) ledger := readLedgerRow(t, store.db, 10) if ledger.name != "reactions" { t.Fatalf("migration 0010 name = %q, want reactions", ledger.name) diff --git a/internal/tools/daemon.go b/internal/tools/daemon.go index 39c0ff4..d0129c0 100644 --- a/internal/tools/daemon.go +++ b/internal/tools/daemon.go @@ -27,10 +27,6 @@ import ( const daemonDownText = "the OpenMessage app isn't running, and this MCP server runs in transportless client mode (it never opens its own WhatsApp/Signal/Google connections — a second connection would log the app out). Start the OpenMessage app, then retry. Local reads (search, conversations, history) keep working without the app." -// daemonSettleTimeout bounds how long a daemon-routed send waits for the -// outbox item to settle before reporting the durable queued state. -const daemonSettleTimeout = 25 * time.Second - func daemonDownResult(err error) *mcp.CallToolResult { if err != nil { return errorResult(fmt.Sprintf("%s (probe error: %v)", daemonDownText, err)) @@ -61,8 +57,10 @@ func daemonStatusOrResult(ctx context.Context, daemon *localapi.Client) (localap } func deliveryFromLocalAPI(delivery localapi.Delivery) messaging.Delivery { - return messaging.Delivery{ + converted := messaging.Delivery{ OutboxID: delivery.OutboxID, + AccountID: delivery.AccountID, + ConversationID: delivery.ConversationID, State: messaging.OutboxState(delivery.State), LocalMessageID: delivery.LocalMessageID, RemoteMessageID: delivery.RemoteMessageID, @@ -70,6 +68,68 @@ func deliveryFromLocalAPI(delivery localapi.Delivery) messaging.Delivery { ErrorCode: delivery.ErrorCode, Warning: delivery.Warning, } + if delivery.ExpiresAtMS > 0 { + converted.ExpiresAt = time.UnixMilli(delivery.ExpiresAtMS) + } + return converted +} + +// daemonSendPlatform resolves the send platform for a conversation routed at +// the daemon. Prefixed IDs are authoritative; otherwise the local read +// source's conversation row decides. +func daemonSendPlatform(reads readsource.ReadSource, conversationID string) string { + switch { + case strings.HasPrefix(conversationID, "whatsapp:"): + return "whatsapp" + case strings.HasPrefix(conversationID, "signal:"), strings.HasPrefix(conversationID, "signal-group:"): + return "signal" + } + if reads != nil { + if conversation, err := reads.GetConversation(conversationID); err == nil && conversation != nil { + return normalizedPlatform(conversation.SourcePlatform) + } + } + return "" +} + +// daemonCheckPlatformSendable enforces the daemon's per-platform send +// capability before submitting. A daemon that predates the capability block +// (no "send" map) cannot be checked and passes through, as does a queueable +// outage (transient disconnect) — the durable outbox plus TTL handles those. +func daemonCheckPlatformSendable(status localapi.DaemonStatus, platform string) *mcp.CallToolResult { + if platform == "" { + return nil + } + capability, known := status.SendCapabilityFor(platform) + if !known || capability.Available || capability.Queueable { + return nil + } + return platformUnavailableResult(platform, capability.Reason) +} + +// daemonRejectionResult renders a deterministic daemon refusal. A 404 on the +// outbox submit route means the daemon's serving store could not resolve the +// conversation — spelled out because a bare "HTTP 404: not found" reads like +// a transport bug and has sent agents down the wrong path (2026-08-05: +// WhatsApp sends 404ing while status showed the platform connected). +func daemonRejectionResult(err error, conversationID, platform string) *mcp.CallToolResult { + if responseErr, ok := isDaemonDuplicateRejection(err); ok { + return daemonDuplicateBlockedResult(responseErr) + } + if responseErr, ok := localapi.AsResponseError(err); ok && responseErr.StatusCode == 404 { + platformNote := "" + if platform != "" { + platformNote = fmt.Sprintf(" The %s connection can be up for receiving while this send path has no usable conversation record.", platform) + } + return errorResult(fmt.Sprintf( + "send rejected: the app could not resolve conversation %q in its serving store (HTTP 404). The message was NOT queued.%s Use resolve_contact_routes to find a sendable route for this contact, or send the first message from the app.", + conversationID, platformNote, + )) + } + if responseErr, ok := localapi.AsResponseError(err); ok && responseErr.StatusCode == 501 { + return platformUnavailableResult(firstNonEmpty(platform, "the requested platform"), responseErr.Body) + } + return errorResult(fmt.Sprintf("send rejected by the app: %v", err)) } // daemonAmbiguousResult reports a send whose outcome the daemon may or may @@ -92,96 +152,154 @@ func daemonAmbiguousResult(idempotencyKey string, cause error) *mcp.CallToolResu } // daemonSubmitTextAndWait submits one durable text send to the daemon outbox -// and waits (bounded) for it to settle, mirroring the in-process v2 result +// and waits (bounded) for its outcome, mirroring the in-process v2 result // contract so agents see identical semantics in both serve modes. func daemonSubmitTextAndWait( ctx context.Context, - daemon *localapi.Client, + options Options, args map[string]any, conversationID string, body string, + platform string, ) *mcp.CallToolResult { + daemon := options.Daemon key, err := v2IdempotencyKey(args) if err != nil { return errorResult(err.Error()) } - submission, err := daemon.SubmitText(ctx, localapi.TextSubmission{ + ttl, err := parseSendTTL(args) + if err != nil { + return errorResult(err.Error()) + } + force, err := parseSendForce(args) + if err != nil { + return errorResult(err.Error()) + } + wait, err := parseSendWaitOptions(args) + if err != nil { + return errorResult(err.Error()) + } + submission := localapi.TextSubmission{ ConversationID: conversationID, Body: body, IdempotencyKey: key, - }) + Force: force, + } + if ttl > 0 { + ttlMS := ttl.Milliseconds() + submission.TTLMS = &ttlMS + } + accepted, err := daemon.SubmitText(ctx, submission) if err != nil { if localapi.IsDeterministicRejection(err) { - return errorResult(fmt.Sprintf("send rejected by the app: %v", err)) + return daemonRejectionResult(err, conversationID, platform) } return daemonAmbiguousResult(key, err) } - return daemonWaitForDelivery(ctx, daemon, submission, key) + return daemonWaitForDelivery(ctx, options, accepted, key, platform, conversationID, wait) } -func daemonWaitForDelivery( +// daemonAwaitOutcome polls the daemon until the send reaches a reportable +// outcome or the wait window closes. With WaitForTransmit it holds through +// auto-retrying not_dispatched states; otherwise those return immediately. +// The bool reports whether any state was ever observed. +func daemonAwaitOutcome( ctx context.Context, daemon *localapi.Client, + outboxID string, + wait sendWaitOptions, +) (localapi.Delivery, bool, error) { + deadline := time.Now().Add(wait.Wait) + var last localapi.Delivery + var lastErr error + observed := false + for { + delivery, err := daemon.Delivery(ctx, outboxID) + if err == nil { + observed = true + last = delivery + lastErr = nil + state := messaging.OutboxState(delivery.State) + if sendSettled(state) || state == messaging.OutboxUncertain { + return last, true, nil + } + if state == messaging.OutboxNotDispatched && !wait.WaitForTransmit { + return last, true, nil + } + } else { + lastErr = err + } + if ctx.Err() != nil || !time.Now().Before(deadline) { + if observed { + return last, true, nil + } + if lastErr == nil { + lastErr = ctx.Err() + } + return localapi.Delivery{}, false, lastErr + } + select { + case <-ctx.Done(): + if observed { + return last, true, nil + } + return localapi.Delivery{}, false, ctx.Err() + case <-time.After(250 * time.Millisecond): + } + } +} + +func daemonWaitForDelivery( + ctx context.Context, + options Options, submission localapi.Submission, idempotencyKey string, + platform string, + conversationID string, + wait sendWaitOptions, ) *mcp.CallToolResult { - delivery, settled, err := daemon.WaitDelivery(ctx, submission.OutboxID, daemonSettleTimeout) - if err != nil { + delivery, observed, err := daemonAwaitOutcome(ctx, options.Daemon, submission.OutboxID, wait) + if !observed { // The intent is durably queued on the daemon; only our view failed. - return v2InterruptedResult( - messaging.Submission{OutboxID: submission.OutboxID, Deduplicated: submission.Deduplicated}, - idempotencyKey, - messaging.Delivery{}, - err, - ) - } - converted := deliveryFromLocalAPI(delivery) - if !settled { - payload := map[string]any{ - "ok": false, - "settled": false, - "auto_retry": true, - "outbox_id": delivery.OutboxID, - "state": delivery.State, - "deduplicated": submission.Deduplicated, - "idempotency_key": idempotencyKey, - } - if delivery.LocalMessageID != "" { - payload["local_message_id"] = delivery.LocalMessageID + lastKnown := messaging.Delivery{ + OutboxID: submission.OutboxID, + ConversationID: conversationID, + State: messaging.OutboxQueued, + LocalMessageID: submission.LocalMessageID, + } + if submission.ExpiresAtMS > 0 { + lastKnown.ExpiresAt = time.UnixMilli(submission.ExpiresAtMS) + } + outcome := sendOutcome{ + Delivery: lastKnown, + IdempotencyKey: idempotencyKey, + Deduplicated: submission.Deduplicated, + Platform: platform, + ConversationID: conversationID, } + payload := buildSendPayload(outcome) + payload["wait_error"] = err.Error() text := fmt.Sprintf( - "The send is durably queued on the app (outbox %s, state %s) and the app finishes sending it in the background. Do NOT send this message again. To repeat the exact same send deliberately, reuse idempotency_key %s.", - delivery.OutboxID, delivery.State, idempotencyKey, + "The send is durably queued on the app (outbox %s, state %s) and has NOT been confirmed as transmitted; this wait was interrupted (%v). The app keeps sending it in the background. Do NOT send this message again — check progress with list_outbox, or repeat the exact same send deliberately by reusing idempotency_key %s.", + lastKnown.OutboxID, lastKnown.State, err, idempotencyKey, ) return structuredResult(payload, text) } - settledState := converted.State != messaging.OutboxNotDispatched - payload := map[string]any{ - "ok": v2DeliveryOK(converted.State), - "settled": settledState, - "outbox_id": delivery.OutboxID, - "state": delivery.State, - "deduplicated": submission.Deduplicated, - "local_message_id": delivery.LocalMessageID, - "idempotency_key": idempotencyKey, - } - if !settledState { - payload["auto_retry"] = true - } - if delivery.RemoteMessageID != "" { - payload["remote_message_id"] = delivery.RemoteMessageID - } - if delivery.ErrorClass != "" { - payload["error_class"] = delivery.ErrorClass - } - if delivery.ErrorCode != "" { - payload["error_code"] = delivery.ErrorCode + converted := deliveryFromLocalAPI(delivery) + outcome := sendOutcome{ + Delivery: converted, + IdempotencyKey: idempotencyKey, + Deduplicated: submission.Deduplicated, + Platform: firstNonEmpty(delivery.Platform, platform), + ConversationID: conversationID, + WaitedForTransmit: wait.WaitForTransmit, + WaitExpired: wait.WaitForTransmit && !sendTransmitted(converted.State), } - if delivery.Warning != "" { - payload["warning"] = delivery.Warning + if sendTransmitted(converted.State) { + outcome.Delivered = deliveryReceiptObserved(options.Reads, converted.RemoteMessageID) } - return structuredResult(payload, v2DeliveryText(converted)) + return sendOutcomeResult(outcome) } // daemonLegacySendText routes a text send through a legacy-mode daemon's @@ -228,12 +346,24 @@ func daemonSendToConversationHandler(options Options) server.ToolHandlerFunc { if message == "" { return errorResult("message is required"), nil } + platform := daemonSendPlatform(options.Reads, conversationID) + if requested := normalizeDirectSendPlatform(strArg(args, "platform")); strArg(args, "platform") != "" { + if platform != "" && requested != platform { + return platformMismatchResult(requested, platform, conversationID), nil + } + if platform == "" { + platform = requested + } + } status, failure := daemonStatusOrResult(ctx, daemon) if failure != nil { return failure, nil } + if failure := daemonCheckPlatformSendable(status, platform); failure != nil { + return failure, nil + } if status.SendsViaOutbox() { - return daemonSubmitTextAndWait(ctx, daemon, args, conversationID, message), nil + return daemonSubmitTextAndWait(ctx, options, args, conversationID, message, platform), nil } return daemonLegacySendText(ctx, daemon, args, conversationID, message), nil } @@ -282,15 +412,18 @@ func daemonSendMessageHandler(options Options) server.ToolHandlerFunc { } conversationID = conversation.ConversationID default: - return errorResult(fmt.Sprintf("unsupported platform %q (supported: sms, whatsapp, signal)", platform)), nil + return unsupportedSendPlatformResult(platform), nil } status, failure := daemonStatusOrResult(ctx, daemon) if failure != nil { return failure, nil } + if failure := daemonCheckPlatformSendable(status, platform); failure != nil { + return failure, nil + } if status.SendsViaOutbox() { - return daemonSubmitTextAndWait(ctx, daemon, args, conversationID, message), nil + return daemonSubmitTextAndWait(ctx, options, args, conversationID, message, platform), nil } return daemonLegacySendText(ctx, daemon, args, conversationID, message), nil } @@ -323,14 +456,26 @@ func daemonSendMediaToConversationHandler(options Options) server.ToolHandlerFun return errorResult("file_path must point to a file"), nil } + platform := daemonSendPlatform(options.Reads, conversationID) status, failure := daemonStatusOrResult(ctx, daemon) if failure != nil { return failure, nil } + if failure := daemonCheckPlatformSendable(status, platform); failure != nil { + return failure, nil + } key, err := v2IdempotencyKey(args) if err != nil { return errorResult(err.Error()), nil } + ttl, err := parseSendTTL(args) + if err != nil { + return errorResult(err.Error()), nil + } + wait, err := parseSendWaitOptions(args) + if err != nil { + return errorResult(err.Error()), nil + } file, err := os.Open(filePath) if err != nil { return errorResult(fmt.Sprintf("read file: %v", err)), nil @@ -350,15 +495,19 @@ func daemonSendMediaToConversationHandler(options Options) server.ToolHandlerFun IdempotencyKey: key, Content: file, } + if ttl > 0 { + ttlMS := ttl.Milliseconds() + submission.TTLMS = &ttlMS + } if status.SendsViaOutbox() { outboxSubmission, err := daemon.SubmitMedia(ctx, submission) if err != nil { if localapi.IsDeterministicRejection(err) { - return errorResult(fmt.Sprintf("media send rejected by the app: %v", err)), nil + return daemonRejectionResult(err, conversationID, platform), nil } return daemonAmbiguousResult(key, err), nil } - return daemonWaitForDelivery(ctx, daemon, outboxSubmission, key), nil + return daemonWaitForDelivery(ctx, options, outboxSubmission, key, platform, conversationID, wait), nil } result, err := daemon.LegacySendMedia(ctx, submission) if err != nil { @@ -543,8 +692,28 @@ func daemonGetStatusHandler(a *app.App, options Options) server.ToolHandlerFunc appendPlatform("Google Messages", "google") appendPlatform("WhatsApp", "whatsapp") appendPlatform("Signal", "signal") + if send, ok := raw["send"].(map[string]any); ok { + sb.WriteString("\nSend capability (daemon truth; \"connected\" above does NOT imply a platform can send):\n") + for _, platform := range []string{"sms", "whatsapp", "signal"} { + entry, ok := send[platform].(map[string]any) + if !ok { + continue + } + available, _ := entry["available"].(bool) + queueable, _ := entry["queueable"].(bool) + reason, _ := entry["reason"].(string) + switch { + case available: + fmt.Fprintf(&sb, " %s: available\n", platform) + case queueable: + fmt.Fprintf(&sb, " %s: DEGRADED (sends queue, not transmit) — %s\n", platform, firstNonEmpty(reason, "reason unknown")) + default: + fmt.Fprintf(&sb, " %s: UNAVAILABLE — %s\n", platform, firstNonEmpty(reason, "reason unknown")) + } + } + } if v2Primary, ok := raw["v2_primary"].(bool); ok { - fmt.Fprintf(&sb, "App v2 mode: primary=%v send=%v\n", v2Primary, raw["v2_send"]) + fmt.Fprintf(&sb, "App v2 mode: primary=%v send=%v (v2_send is the send STACK flag, not per-platform capability — see the send capability block)\n", v2Primary, raw["v2_send"]) } fmt.Fprintf(&sb, "Client data dir: %s\n", a.DataDir) return structuredResult(map[string]any{ diff --git a/internal/tools/daemon_test.go b/internal/tools/daemon_test.go index 43b72b4..317c9a5 100644 --- a/internal/tools/daemon_test.go +++ b/internal/tools/daemon_test.go @@ -125,8 +125,13 @@ func TestDaemonSendToConversationDeterministicRejection(t *testing.T) { } payload := structuredMap(t, result) message, _ := payload["error"].(string) - if !strings.Contains(message, "send rejected by the app") { - t.Fatalf("rejection message = %q", message) + // A 404 must explain that the daemon could not resolve the conversation + // and that nothing was queued — a bare "HTTP 404" reads like a transport + // bug (2026-08-05: WhatsApp sends 404ing while status showed connected). + for _, fragment := range []string{"send rejected", "could not resolve conversation", "NOT queued", "resolve_contact_routes"} { + if !strings.Contains(message, fragment) { + t.Fatalf("rejection message missing %q: %q", fragment, message) + } } } diff --git a/internal/tools/get_status.go b/internal/tools/get_status.go index 81933de..02cf257 100644 --- a/internal/tools/get_status.go +++ b/internal/tools/get_status.go @@ -9,6 +9,7 @@ import ( "github.com/mark3labs/mcp-go/server" "github.com/maxghenis/openmessage/internal/app" + "github.com/maxghenis/openmessage/internal/sendcap" "github.com/maxghenis/openmessage/internal/signallive" "github.com/maxghenis/openmessage/internal/whatsapplive" ) @@ -34,12 +35,35 @@ var ( func getStatusTool() mcp.Tool { return mcp.NewTool("get_status", - mcp.WithDescription("Get connection and pairing status for Google Messages, WhatsApp, and Signal"), + mcp.WithDescription("Get connection, pairing, and per-platform SEND capability for Google Messages (SMS/RCS), WhatsApp, and Signal. \"Connected\" alone does not mean a platform can send — check the send capability block before submitting a time-sensitive message; a platform can receive while its send path is unavailable."), mcp.WithReadOnlyHintAnnotation(true), mcp.WithDestructiveHintAnnotation(false), ) } +// appendSendCapabilityText renders the per-platform send block in a fixed +// platform order. +func appendSendCapabilityText(sb *strings.Builder, capabilities map[string]sendcap.Capability) { + if len(capabilities) == 0 { + return + } + sb.WriteString("\nSend capability (can a send submitted NOW dispatch promptly?):\n") + for _, platform := range []string{sendcap.PlatformSMS, sendcap.PlatformWhatsApp, sendcap.PlatformSignal} { + capability, ok := capabilities[platform] + if !ok { + continue + } + switch { + case capability.Available: + fmt.Fprintf(sb, " %s: available\n", platform) + case capability.Queueable: + fmt.Fprintf(sb, " %s: DEGRADED (sends queue, not transmit) — %s\n", platform, firstNonEmpty(capability.Reason, "reason unknown")) + default: + fmt.Fprintf(sb, " %s: UNAVAILABLE — %s\n", platform, firstNonEmpty(capability.Reason, "reason unknown")) + } + } +} + func getStatusHandler(a *app.App, configured ...Options) server.ToolHandlerFunc { options := resolvedOptions(a, configured) return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { @@ -114,6 +138,9 @@ func getStatusHandler(a *app.App, configured ...Options) server.ToolHandlerFunc fmt.Fprintf(&sb, " Last error: %s\n", signal.LastError) } + sendCapabilities := localSendCapability(a, options.V2) + appendSendCapabilityText(&sb, sendCapabilities) + if options.V2Primary { sb.WriteString("\nServing store (v2):\n") if len(storedPlatforms) == 0 { @@ -131,6 +158,7 @@ func getStatusHandler(a *app.App, configured ...Options) server.ToolHandlerFunc "google": google, "whatsapp": whatsApp, "signal": signal, + "send": sendCapabilities, "data_dir": a.DataDir, } if options.V2Primary { diff --git a/internal/tools/legacy_parity_test.go b/internal/tools/legacy_parity_test.go index 6a99bf4..8d3d8aa 100644 --- a/internal/tools/legacy_parity_test.go +++ b/internal/tools/legacy_parity_test.go @@ -26,12 +26,12 @@ func TestLegacySendToolDescriptorParity(t *testing.T) { { name: "send_message", tool: sendMessageTool(), - want: `{"annotations":{"readOnlyHint":false,"destructiveHint":false,"idempotentHint":false,"openWorldHint":true},"description":"Send a direct text message across supported platforms. Defaults to SMS/RCS when no platform is specified.","inputSchema":{"type":"object","properties":{"message":{"description":"Message text to send","type":"string"},"phone_number":{"description":"Legacy alias for recipient. For SMS/RCS use a phone number with country code (e.g., +15551234567).","type":"string"},"platform":{"description":"Target platform: sms, rcs, whatsapp, or signal. Defaults to sms.","type":"string"},"recipient":{"description":"Recipient identifier. Use a phone number for SMS/RCS or Signal, and a phone number or WhatsApp JID for WhatsApp.","type":"string"}},"required":["message"]},"name":"send_message"}`, + want: `{"annotations":{"readOnlyHint":false,"destructiveHint":false,"idempotentHint":false,"openWorldHint":true},"description":"Send a direct text message on ONE explicit platform. Defaults to SMS/RCS when no platform is specified. The requested platform is a hard contract: if it cannot send, the tool fails with the reason and queues nothing — it never falls back to a different platform.","inputSchema":{"type":"object","properties":{"message":{"description":"Message text to send","type":"string"},"phone_number":{"description":"Legacy alias for recipient. For SMS/RCS use a phone number with country code (e.g., +15551234567).","type":"string"},"platform":{"description":"Target platform: sms (covers RCS), whatsapp, or signal. Defaults to sms. imessage is import/read-only — OpenMessage cannot send iMessages.","type":"string"},"recipient":{"description":"Recipient identifier. Use a phone number for SMS/RCS or Signal, and a phone number or WhatsApp JID for WhatsApp.","type":"string"}},"required":["message"]},"name":"send_message"}`, }, { name: "send_to_conversation", tool: sendToConversationTool(), - want: `{"annotations":{"readOnlyHint":false,"destructiveHint":false,"idempotentHint":false,"openWorldHint":true},"description":"Send a text message to an existing conversation by conversation ID across supported platforms","inputSchema":{"type":"object","properties":{"conversation_id":{"description":"Existing conversation ID from list_conversations or get_conversation","type":"string"},"message":{"description":"Message text to send","type":"string"}},"required":["conversation_id","message"]},"name":"send_to_conversation"}`, + want: `{"annotations":{"readOnlyHint":false,"destructiveHint":false,"idempotentHint":false,"openWorldHint":true},"description":"Send a text message to an existing conversation by conversation ID. The message goes out on the conversation's own platform — there is never a fallback to a different platform; pass the optional platform argument to assert which platform you intend, and the tool fails on a mismatch instead of sending.","inputSchema":{"type":"object","properties":{"conversation_id":{"description":"Existing conversation ID from list_conversations or get_conversation","type":"string"},"message":{"description":"Message text to send","type":"string"},"platform":{"description":"Optional assertion of the platform this conversation must be on (sms, whatsapp, signal). Mismatch fails the send instead of routing to an unintended channel.","type":"string"}},"required":["conversation_id","message"]},"name":"send_to_conversation"}`, }, { name: "send_media_to_conversation", @@ -244,7 +244,7 @@ func TestLegacySendToConversationExchangeParity(t *testing.T) { if err != nil { t.Fatalf("sendToConversationHandler() error = %v", err) } - assertLegacyJSON(t, result, `{"content":[{"type":"text","text":"Message sent to Legacy Thread (signal:legacy-thread): hello thread"}],"structuredContent":{"conversation":{"conversation_id":"signal:legacy-thread","name":"Legacy Thread","source_platform":"signal","is_group":true},"message":{"message_id":"signal:legacy-parity-2","conversation_id":"signal:legacy-thread","body":"hello thread","timestamp_ms":1700000000456,"status":"sent","is_from_me":true,"source_platform":"signal","display_text":"hello thread"},"ok":true}}`) + assertLegacyJSON(t, result, `{"content":[{"type":"text","text":"Message sent to Legacy Thread (signal:legacy-thread): hello thread"}],"structuredContent":{"conversation":{"conversation_id":"signal:legacy-thread","name":"Legacy Thread","source_platform":"signal","is_group":true},"message":{"message_id":"signal:legacy-parity-2","conversation_id":"signal:legacy-thread","body":"hello thread","timestamp_ms":1700000000456,"status":"sent","is_from_me":true,"source_platform":"signal","display_text":"hello thread"},"ok":true,"platform":"signal"}}`) } func TestLegacySendMediaExchangeParity(t *testing.T) { diff --git a/internal/tools/outbox_tools.go b/internal/tools/outbox_tools.go new file mode 100644 index 0000000..6db7942 --- /dev/null +++ b/internal/tools/outbox_tools.go @@ -0,0 +1,258 @@ +package tools + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + + "github.com/maxghenis/openmessage/internal/localapi" + "github.com/maxghenis/openmessage/internal/messaging" +) + +// list_outbox and cancel_outbox give agents direct custody of durable sends: +// see what is still queued or retrying, and stop a stale send before it +// transmits (2026-08-05: an overnight-queued send flushed ~15 hours later +// with no way to see or stop it from the MCP surface). + +func listOutboxTool() mcp.Tool { + return mcp.NewTool("list_outbox", + mcp.WithDescription("List durable outbox sends that have not completed: queued, dispatching, auto-retrying, uncertain, or awaiting local repair. Use after any send that did not report transmitted, and before retrying anything — a queued predecessor here means do NOT resend."), + mcp.WithString("conversation_id", mcp.Description("Only list sends for this conversation")), + mcp.WithNumber("limit", mcp.Description("Maximum items to return (default 50, max 200)")), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithDestructiveHintAnnotation(false), + ) +} + +func cancelOutboxTool() mcp.Tool { + return mcp.NewTool("cancel_outbox", + mcp.WithDescription("Cancel one durable outbox send that has NOT crossed the transport boundary (state queued or not_dispatched). Once canceled it will never transmit. Sends already handed to the transport (dispatching/uncertain/confirmed) cannot be canceled."), + mcp.WithString("outbox_id", mcp.Required(), mcp.Description("Outbox ID from a send result or list_outbox")), + mcp.WithDestructiveHintAnnotation(false), + mcp.WithIdempotentHintAnnotation(true), + ) +} + +const outboxUnavailableText = "the durable outbox is not available in this serving mode: legacy direct sends transmit synchronously and leave nothing queued. This tool works with v2 sending enabled or when routing through the running OpenMessage app." + +func outboxUnavailableHandler() server.ToolHandlerFunc { + return func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return errorResult(outboxUnavailableText), nil + } +} + +type outboxRow struct { + OutboxID string `json:"outbox_id"` + ConversationID string `json:"conversation_id"` + Kind string `json:"kind"` + State string `json:"state"` + TransportState string `json:"transport_state"` + ScheduledForMS int64 `json:"scheduled_for_ms"` + NextAttemptMS int64 `json:"next_attempt_at_ms,omitempty"` + ExpiresAtMS int64 `json:"expires_at_ms,omitempty"` + AttemptCount int64 `json:"attempt_count"` + CreatedAtMS int64 `json:"created_at_ms"` + Summary string `json:"summary,omitempty"` + ErrorClass string `json:"error_class,omitempty"` + ErrorCode string `json:"error_code,omitempty"` +} + +func transportStateForOutboxState(state messaging.OutboxState) string { + return sendOutcome{Delivery: messaging.Delivery{State: state}}.transportState() +} + +func outboxListResult(rows []outboxRow, conversationID string) *mcp.CallToolResult { + scope := "" + if conversationID != "" { + scope = fmt.Sprintf(" for conversation %s", conversationID) + } + if len(rows) == 0 { + return structuredResult(map[string]any{ + "count": 0, + "items": []outboxRow{}, + }, fmt.Sprintf("The outbox has no incomplete sends%s: nothing is queued, retrying, or uncertain.", scope)) + } + var sb strings.Builder + fmt.Fprintf(&sb, "%d incomplete send(s)%s. Queued/retrying items have NOT been transmitted — do not resend them; cancel_outbox stops one before it transmits.\n", len(rows), scope) + for _, row := range rows { + fmt.Fprintf(&sb, "- %s [%s → %s] conversation=%s created=%s attempts=%d", + row.OutboxID, + row.State, + row.TransportState, + row.ConversationID, + time.UnixMilli(row.CreatedAtMS).UTC().Format(time.RFC3339), + row.AttemptCount, + ) + if row.ExpiresAtMS > 0 { + fmt.Fprintf(&sb, " expires=%s", time.UnixMilli(row.ExpiresAtMS).UTC().Format(time.RFC3339)) + } + if row.Summary != "" { + fmt.Fprintf(&sb, " %q", row.Summary) + } + sb.WriteByte('\n') + } + return structuredResult(map[string]any{ + "count": len(rows), + "items": rows, + }, sb.String()) +} + +func outboxListLimit(args map[string]any) int { + limit := intArg(args, "limit", 50) + if limit <= 0 { + limit = 50 + } + if limit > 200 { + limit = 200 + } + return limit +} + +func v2ListOutboxHandler(v2 *V2Dependencies) server.ToolHandlerFunc { + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if v2 == nil || v2.Service == nil { + return errorResult(outboxUnavailableText), nil + } + args := req.GetArguments() + conversationID := strings.TrimSpace(strArg(args, "conversation_id")) + pending, err := v2.Service.ListPending(ctx, messaging.ListPendingQuery{ + ConversationID: conversationID, + Limit: outboxListLimit(args), + }) + if err != nil { + return errorResult(fmt.Sprintf("list outbox: %v", err)), nil + } + rows := make([]outboxRow, 0, len(pending)) + for _, delivery := range pending { + row := outboxRow{ + OutboxID: delivery.OutboxID, + ConversationID: delivery.ConversationID, + Kind: string(delivery.Kind), + State: string(delivery.State), + TransportState: transportStateForOutboxState(delivery.State), + ScheduledForMS: delivery.ScheduledFor.UnixMilli(), + AttemptCount: delivery.AttemptCount, + CreatedAtMS: delivery.CreatedAt.UnixMilli(), + Summary: delivery.Summary, + ErrorClass: delivery.ErrorClass, + ErrorCode: delivery.ErrorCode, + } + if !delivery.NextAttemptAt.IsZero() { + row.NextAttemptMS = delivery.NextAttemptAt.UnixMilli() + } + if !delivery.ExpiresAt.IsZero() { + row.ExpiresAtMS = delivery.ExpiresAt.UnixMilli() + } + rows = append(rows, row) + } + return outboxListResult(rows, conversationID), nil + } +} + +func daemonListOutboxHandler(options Options) server.ToolHandlerFunc { + daemon := options.Daemon + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + conversationID := strings.TrimSpace(strArg(args, "conversation_id")) + pending, err := daemon.ListPending(ctx, conversationID, outboxListLimit(args)) + if err != nil { + if responseErr, ok := localapi.AsResponseError(err); ok && responseErr.StatusCode == 503 { + return errorResult("the running app does not have v2 sending enabled, so it keeps no durable outbox (legacy sends transmit synchronously)."), nil + } + return daemonDownResult(err), nil + } + rows := make([]outboxRow, 0, len(pending)) + for _, delivery := range pending { + row := outboxRow{ + OutboxID: delivery.OutboxID, + ConversationID: delivery.ConversationID, + Kind: delivery.Kind, + State: delivery.State, + TransportState: transportStateForOutboxState(messaging.OutboxState(delivery.State)), + ScheduledForMS: delivery.ScheduledForMS, + ExpiresAtMS: delivery.ExpiresAtMS, + AttemptCount: delivery.AttemptCount, + CreatedAtMS: delivery.CreatedAtMS, + Summary: delivery.Summary, + ErrorClass: delivery.ErrorClass, + ErrorCode: delivery.ErrorCode, + } + if delivery.NextAttemptMS != nil { + row.NextAttemptMS = *delivery.NextAttemptMS + } + rows = append(rows, row) + } + return outboxListResult(rows, conversationID), nil + } +} + +func outboxCancelSuccessResult(delivery messaging.Delivery) *mcp.CallToolResult { + text := fmt.Sprintf( + "Canceled: outbox %s will never transmit. The message was NOT sent.", + delivery.OutboxID, + ) + return structuredResult(map[string]any{ + "ok": true, + "outbox_id": delivery.OutboxID, + "state": string(delivery.State), + "transport_state": transportStateForOutboxState(delivery.State), + "canceled": true, + }, text) +} + +func outboxCancelInvalidStateText(outboxID string, state string) string { + return fmt.Sprintf( + "cannot cancel outbox %s from state %q: it already crossed (or is crossing) the transport boundary, so canceling could no longer prevent delivery. Check the conversation to see whether the message arrived.", + outboxID, state, + ) +} + +func v2CancelOutboxHandler(v2 *V2Dependencies) server.ToolHandlerFunc { + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if v2 == nil || v2.Service == nil { + return errorResult(outboxUnavailableText), nil + } + outboxID := strings.TrimSpace(strArg(req.GetArguments(), "outbox_id")) + if outboxID == "" { + return errorResult("outbox_id is required"), nil + } + delivery, err := v2.Service.Cancel(ctx, outboxID) + if err != nil { + if errors.Is(err, messaging.ErrInvalidState) { + return errorResult(outboxCancelInvalidStateText(outboxID, string(delivery.State))), nil + } + return errorResult(fmt.Sprintf("cancel outbox %s: %v", outboxID, err)), nil + } + return outboxCancelSuccessResult(delivery), nil + } +} + +func daemonCancelOutboxHandler(options Options) server.ToolHandlerFunc { + daemon := options.Daemon + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + outboxID := strings.TrimSpace(strArg(req.GetArguments(), "outbox_id")) + if outboxID == "" { + return errorResult("outbox_id is required"), nil + } + delivery, err := daemon.CancelDelivery(ctx, outboxID) + if err != nil { + if responseErr, ok := localapi.AsResponseError(err); ok { + switch responseErr.StatusCode { + case 409: + return errorResult(outboxCancelInvalidStateText(outboxID, "already dispatched or terminal")), nil + case 404: + return errorResult(fmt.Sprintf("outbox %s was not found on the app", outboxID)), nil + case 503: + return errorResult("the running app does not have v2 sending enabled, so it keeps no durable outbox."), nil + } + } + return daemonDownResult(err), nil + } + return outboxCancelSuccessResult(deliveryFromLocalAPI(delivery)), nil + } +} diff --git a/internal/tools/resolve_contact_routes.go b/internal/tools/resolve_contact_routes.go index e951f56..cd45c8d 100644 --- a/internal/tools/resolve_contact_routes.go +++ b/internal/tools/resolve_contact_routes.go @@ -12,11 +12,16 @@ import ( "github.com/maxghenis/openmessage/internal/app" "github.com/maxghenis/openmessage/internal/db" + "github.com/maxghenis/openmessage/internal/sendcap" ) type resolvedRoute struct { Conversation conversationSummary `json:"conversation"` Sendable bool `json:"sendable"` + // SendableReason explains a sendable:false route (platform down, + // adapter unregistered, read-only platform, daemon unreachable) so + // agents can distinguish "fix the platform" from "pick another route". + SendableReason string `json:"sendable_reason,omitempty"` } type resolvedRouteMatch struct { @@ -60,7 +65,8 @@ func resolveContactRoutesTool() mcp.Tool { ) } -func resolveContactRoutesHandler(a *app.App) server.ToolHandlerFunc { +func resolveContactRoutesHandler(a *app.App, configured ...Options) server.ToolHandlerFunc { + options := resolvedOptions(a, configured) return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := req.GetArguments() query := strings.TrimSpace(strArg(args, "query")) @@ -85,7 +91,7 @@ func resolveContactRoutesHandler(a *app.App) server.ToolHandlerFunc { return errorResult(fmt.Sprintf("resolve routes: %v", err)), nil } - matches := buildResolvedRouteMatches(a, convos, limit) + matches := buildResolvedRouteMatches(a, options, ctx, convos, limit) if len(matches) == 0 { return structuredResult(map[string]any{ "query": query, @@ -196,14 +202,13 @@ func findRouteConversations(a *app.App, query string, limit int) ([]*db.Conversa return results, nil } -func buildResolvedRouteMatches(a *app.App, convos []*db.Conversation, limit int) []resolvedRouteMatch { +func buildResolvedRouteMatches(a *app.App, options Options, ctx context.Context, convos []*db.Conversation, limit int) []resolvedRouteMatch { if len(convos) == 0 { return nil } identityIndex := loadRouteIdentityIndex(a.Store) - whatsAppConnected := whatsAppStatus(a).Connected - signalConnected := signalStatus(a).Connected + capabilities := routeSendCapabilities(ctx, a, options) type routeBucket struct { MatchID string @@ -240,9 +245,11 @@ func buildResolvedRouteMatches(a *app.App, convos []*db.Conversation, limit int) if bucket.ParticipantID == "" { bucket.ParticipantID = participantID } + sendable, reason := routeSupportsOutbound(conv, capabilities) bucket.Routes = append(bucket.Routes, resolvedRoute{ - Conversation: summarizeConversation(conv), - Sendable: routeSupportsOutbound(conv, whatsAppConnected, signalConnected), + Conversation: summarizeConversation(conv), + Sendable: sendable, + SendableReason: reason, }) } @@ -354,19 +361,70 @@ func platformOrderIndex(platform string) int { } } -func routeSupportsOutbound(conv *db.Conversation, whatsAppConnected, signalConnected bool) bool { +// routeSendCapabilities resolves the per-platform send capability the route +// list is judged against. Client mode asks the daemon — the process that +// actually sends — so this tool, get_status, and send-time enforcement all +// answer from the same source (the 2026-08-05 incident had three surfaces +// giving three different answers). Standalone mode computes locally. +func routeSendCapabilities(ctx context.Context, a *app.App, options Options) map[string]sendcap.Capability { + if options.Daemon == nil { + return localSendCapability(a, options.V2) + } + status, reachable, err := options.Daemon.Status(ctx) + if err != nil { + reason := "the OpenMessage app is not running; sends require it" + if reachable { + reason = fmt.Sprintf("the OpenMessage app answered but its status was unusable (%v)", err) + } + unavailable := sendcap.Capability{Reason: reason} + return map[string]sendcap.Capability{ + sendcap.PlatformSMS: unavailable, + sendcap.PlatformWhatsApp: unavailable, + sendcap.PlatformSignal: unavailable, + } + } + if status.Send == nil { + // Older daemon without the send block: keep the pre-capability + // behavior (sms assumed sendable, whatsapp/signal not) but say why. + return map[string]sendcap.Capability{ + sendcap.PlatformSMS: {Available: true}, + sendcap.PlatformWhatsApp: { + Reason: "the running app predates per-platform send capability reporting; whatsapp sendability is unknown", + }, + sendcap.PlatformSignal: { + Reason: "the running app predates per-platform send capability reporting; signal sendability is unknown", + }, + } + } + capabilities := make(map[string]sendcap.Capability, len(status.Send)) + for platform, capability := range status.Send { + capabilities[platform] = sendcap.Capability{ + Available: capability.Available, + Queueable: capability.Queueable, + Reason: capability.Reason, + } + } + return capabilities +} + +// routeSupportsOutbound reports whether one conversation's platform can send +// right now, with the reason when it cannot. +func routeSupportsOutbound(conv *db.Conversation, capabilities map[string]sendcap.Capability) (bool, string) { if conv == nil { - return false + return false, "" } - switch normalizedPlatform(conv.SourcePlatform) { - case "sms": - return true - case "whatsapp": - return whatsAppConnected - case "signal": - return signalConnected + platform := normalizedPlatform(conv.SourcePlatform) + switch platform { + case "sms", "whatsapp", "signal": + capability, known := capabilities[platform] + if !known { + return false, "send capability unknown for this platform" + } + return capability.Available, capability.Reason + case "imessage": + return false, "imessage is import/read-only; OpenMessage cannot send iMessages" default: - return false + return false, fmt.Sprintf("%s conversations are import/read-only in OpenMessage", platform) } } diff --git a/internal/tools/send_contract_test.go b/internal/tools/send_contract_test.go new file mode 100644 index 0000000..531559c --- /dev/null +++ b/internal/tools/send_contract_test.go @@ -0,0 +1,567 @@ +package tools + +// Incident-derived contract tests (2026-08-05→06): send_message reported +// {ok:true, settled:true, state:"confirmed"} for a message that sat ~15 hours +// before transmitting, a retry then double-texted the recipient, and a +// WhatsApp send 404ed while get_status showed the platform connected. These +// tests pin the corrected contract end to end at the MCP tool layer. + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/mark3labs/mcp-go/mcp" + + "github.com/maxghenis/openmessage/internal/bridge" + "github.com/maxghenis/openmessage/internal/localapi" + "github.com/maxghenis/openmessage/internal/messaging" +) + +// TestDaemonQueuedSendNeverReportsSettledOrConfirmed is the core incident +// invariant: while the daemon holds the message in its outbox, the tool +// result must say queued/untransmitted — never settled, never ok, never any +// "confirmed" language. +func TestDaemonQueuedSendNeverReportsSettledOrConfirmed(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/status", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{"v2_send": true}) + }) + mux.HandleFunc("/api/v1/outbox/messages", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{ + "outbox_id": "outbox-stuck", + "local_message_id": "local-stuck", + "state": "queued", + "scheduled_for_ms": time.Now().UnixMilli(), + }) + }) + // The outbox item never leaves queued — the overnight incident shape. + mux.HandleFunc("/api/v1/outbox/outbox-stuck", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{ + "outbox_id": "outbox-stuck", + "conversation_id": "conversation-stuck", + "platform": "sms", + "state": "queued", + "expires_at_ms": time.Now().Add(10 * time.Minute).UnixMilli(), + }) + }) + options := Options{Daemon: daemonClientFor(t, mux)} + handler := daemonSendToConversationHandler(options) + + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]any{ + "conversation_id": "conversation-stuck", + "message": "meet for lunch tomorrow?", + "idempotency_key": "queued-truth-key", + "wait_seconds": float64(1), + } + result, err := handler(context.Background(), req) + if err != nil { + t.Fatalf("handler: %v", err) + } + if result.IsError { + t.Fatalf("queued send returned a tool error, inviting a resend: %v", result.Content) + } + + payload := structuredMap(t, result) + if got, _ := payload["settled"].(bool); got { + t.Fatalf("settled = true for a queued-not-transmitted send; payload=%v", payload) + } + if got, _ := payload["ok"].(bool); got { + t.Fatalf("ok = true for a queued-not-transmitted send; payload=%v", payload) + } + if got, _ := payload["transmitted"].(bool); got { + t.Fatalf("transmitted = true for a queued send; payload=%v", payload) + } + if got, _ := payload["transport_state"].(string); got != "queued" { + t.Fatalf("transport_state = %q, want queued; payload=%v", got, payload) + } + if got, _ := payload["platform"].(string); got != "sms" { + t.Fatalf("platform = %q, want sms (transport echoed from the daemon)", got) + } + if got, _ := payload["conversation_id"].(string); got != "conversation-stuck" { + t.Fatalf("conversation_id = %q, want conversation-stuck", got) + } + if _, present := payload["expires_at_ms"]; !present { + t.Fatalf("expires_at_ms missing from queued result; payload=%v", payload) + } + + text := result.Content[0].(mcp.TextContent).Text + for _, fragment := range []string{"NOT YET TRANSMITTED", "durably queued", "do NOT send this message again"} { + if !strings.Contains(text, fragment) { + t.Fatalf("queued text missing %q: %q", fragment, text) + } + } + if strings.Contains(strings.ToLower(text), "delivery confirmed") { + t.Fatalf("queued text claims delivery: %q", text) + } +} + +// TestDaemonWaitForTransmitHoldsThroughRetries verifies wait_for_transmit +// keeps polling through auto-retrying states until the transport acknowledges. +func TestDaemonWaitForTransmitHoldsThroughRetries(t *testing.T) { + var polls atomic.Int64 + mux := http.NewServeMux() + mux.HandleFunc("/api/status", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{"v2_send": true}) + }) + mux.HandleFunc("/api/v1/outbox/messages", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{ + "outbox_id": "outbox-retrying", + "state": "queued", + }) + }) + mux.HandleFunc("/api/v1/outbox/outbox-retrying", func(w http.ResponseWriter, r *http.Request) { + // not_dispatched twice (would end a default wait), then confirmed. + if polls.Add(1) < 3 { + json.NewEncoder(w).Encode(map[string]any{ + "outbox_id": "outbox-retrying", + "state": "not_dispatched", + "error_class": "transient", + }) + return + } + json.NewEncoder(w).Encode(map[string]any{ + "outbox_id": "outbox-retrying", + "conversation_id": "conversation-retrying", + "platform": "sms", + "state": "confirmed", + "remote_message_id": "remote-finally", + }) + }) + options := Options{Daemon: daemonClientFor(t, mux)} + handler := daemonSendToConversationHandler(options) + + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]any{ + "conversation_id": "conversation-retrying", + "message": "hold for the ack", + "idempotency_key": "wait-transmit-key", + "wait_for_transmit": true, + "wait_seconds": float64(10), + } + result, err := handler(context.Background(), req) + if err != nil { + t.Fatalf("handler: %v", err) + } + payload := structuredMap(t, result) + if got, _ := payload["transmitted"].(bool); !got { + t.Fatalf("transmitted = false after transport ack; payload=%v", payload) + } + if got, _ := payload["transport_state"].(string); got != "transmitted" { + t.Fatalf("transport_state = %q, want transmitted", got) + } + if got := polls.Load(); got < 3 { + t.Fatalf("delivery polled %d times, want ≥3 (held through not_dispatched)", got) + } +} + +// TestDaemonSendBlockedWhenPlatformCannotSend pins send-time enforcement of +// the daemon's per-platform capability: a hard-down platform (adapter +// unregistered) refuses without queuing, while a merely-disconnected +// (queueable) platform still queues. +func TestDaemonSendBlockedWhenPlatformCannotSend(t *testing.T) { + var submits atomic.Int64 + mux := http.NewServeMux() + mux.HandleFunc("/api/status", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{ + "v2_send": true, + "send": map[string]any{ + "sms": map[string]any{"available": true}, + "whatsapp": map[string]any{"available": false, "queueable": false, "reason": "the platform adapter is not registered with the v2 send stack in this run (receive-only); sends on this platform fail rather than queue"}, + "signal": map[string]any{"available": false, "queueable": true, "reason": "signal is disconnected; a send submitted now would wait in the outbox until it reconnects"}, + }, + }) + }) + mux.HandleFunc("/api/v1/outbox/messages", func(w http.ResponseWriter, r *http.Request) { + submits.Add(1) + json.NewEncoder(w).Encode(map[string]any{ + "outbox_id": "outbox-queued-signal", + "state": "queued", + }) + }) + mux.HandleFunc("/api/v1/outbox/outbox-queued-signal", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{ + "outbox_id": "outbox-queued-signal", + "state": "queued", + }) + }) + options := Options{Daemon: daemonClientFor(t, mux)} + handler := daemonSendToConversationHandler(options) + + // WhatsApp: hard-down → refused, nothing submitted. + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]any{ + "conversation_id": "whatsapp:15551230000@s.whatsapp.net", + "message": "hi over whatsapp", + } + result, err := handler(context.Background(), req) + if err != nil { + t.Fatalf("handler: %v", err) + } + if !result.IsError { + t.Fatalf("expected an error result for an unsendable platform, got %v", result.Content) + } + payload := structuredMap(t, result) + if got, _ := payload["error_kind"].(string); got != "platform_unsendable" { + t.Fatalf("error_kind = %q, want platform_unsendable", got) + } + if got, _ := payload["platform"].(string); got != "whatsapp" { + t.Fatalf("platform = %q, want whatsapp", got) + } + text := result.Content[0].(mcp.TextContent).Text + for _, fragment := range []string{"Cannot send on whatsapp", "NOT queued", "No fallback to another platform"} { + if !strings.Contains(text, fragment) { + t.Fatalf("unsendable text missing %q: %q", fragment, text) + } + } + if got := submits.Load(); got != 0 { + t.Fatalf("submit called %d times for an unsendable platform, want 0", got) + } + + // Signal: disconnected but queueable → the durable outbox accepts it. + req = mcp.CallToolRequest{} + req.Params.Arguments = map[string]any{ + "conversation_id": "signal:+15551230000", + "message": "hi over signal", + "idempotency_key": "signal-queueable-key", + "wait_seconds": float64(1), + } + result, err = handler(context.Background(), req) + if err != nil { + t.Fatalf("handler: %v", err) + } + if result.IsError { + t.Fatalf("queueable outage must queue, not error: %v", result.Content) + } + if got := submits.Load(); got != 1 { + t.Fatalf("submit called %d times for a queueable platform, want 1", got) + } +} + +// TestSendToConversationPlatformAssertionMismatch pins the hard channel +// contract: asserting platform=whatsapp against an sms conversation fails +// without sending anywhere. +func TestSendToConversationPlatformAssertionMismatch(t *testing.T) { + harness := newV2ToolHarness(t) + handler := sendToConversationHandler(harness.app, &harness.deps) + + result, err := handler(context.Background(), v2ToolCall(map[string]any{ + "conversation_id": v2ToolConversationID, // seeded as sms + "message": "meant for whatsapp", + "platform": "whatsapp", + })) + if err != nil { + t.Fatalf("handler error: %v", err) + } + if !result.IsError { + t.Fatalf("platform mismatch must fail, got %v", result.Content) + } + payload := v2ToolPayload(t, result) + if got := v2ToolString(payload, "error_kind"); got != "platform_mismatch" { + t.Fatalf("error_kind = %q, want platform_mismatch", got) + } + if got := v2ToolString(payload, "requested_platform"); got != "whatsapp" { + t.Fatalf("requested_platform = %q, want whatsapp", got) + } + if got := v2ToolString(payload, "actual_platform"); got != "sms" { + t.Fatalf("actual_platform = %q, want sms", got) + } + text := result.Content[0].(mcp.TextContent).Text + if !strings.Contains(text, "NOT queued") { + t.Fatalf("mismatch text missing NOT queued: %q", text) + } + + // The matching assertion passes through to a normal send... which then + // fails only because the scripted sender has no steps; the mismatch gate + // itself must not fire. + result, err = handler(context.Background(), v2ToolCall(map[string]any{ + "conversation_id": v2ToolConversationID, + "message": "explicitly sms", + "platform": "sms", + "idempotency_key": "assert-sms-key", + "wait_seconds": float64(1), + })) + if err != nil { + t.Fatalf("handler error: %v", err) + } + if payload := v2ToolPayload(t, result); v2ToolString(payload, "error_kind") == "platform_mismatch" { + t.Fatal("matching platform assertion must not fail as a mismatch") + } +} + +// TestV2SendCarriesTTLAndTransport pins ttl_seconds → expires_at_ms plumbing +// and the platform/conversation_id echo through the in-process v2 path. +func TestV2SendCarriesTTLAndTransport(t *testing.T) { + harness := newV2ToolHarness(t, v2ToolSendStep{result: bridge.SendResult{ + RemoteMessageID: "remote-ttl", + }}) + handler := sendToConversationHandler(harness.app, &harness.deps) + + before := time.Now() + result, err := handler(context.Background(), v2ToolCall(map[string]any{ + "conversation_id": v2ToolConversationID, + "message": "windowed send", + "idempotency_key": "ttl-plumb-key", + "ttl_seconds": float64(120), + })) + if err != nil { + t.Fatalf("handler error: %v", err) + } + payload := v2ToolPayload(t, result) + if got := v2ToolString(payload, "transport_state"); got != "transmitted" { + t.Fatalf("transport_state = %q, want transmitted; payload=%v", got, payload) + } + if got := v2ToolString(payload, "platform"); got != "sms" { + t.Fatalf("platform = %q, want sms", got) + } + if got := v2ToolString(payload, "conversation_id"); got != v2ToolConversationID { + t.Fatalf("conversation_id = %q, want %q", got, v2ToolConversationID) + } + expiresAt, ok := payload["expires_at_ms"].(float64) + if !ok { + t.Fatalf("expires_at_ms missing; payload=%v", payload) + } + wantLow := before.Add(115 * time.Second).UnixMilli() + wantHigh := before.Add(150 * time.Second).UnixMilli() + if int64(expiresAt) < wantLow || int64(expiresAt) > wantHigh { + t.Fatalf("expires_at_ms = %d, want within [%d, %d]", int64(expiresAt), wantLow, wantHigh) + } + text := result.Content[0].(mcp.TextContent).Text + if !strings.Contains(text, "transmitted") || strings.Contains(strings.ToLower(text), "delivery confirmed") { + t.Fatalf("transmitted text wrong: %q", text) + } +} + +// TestV2SendNearDuplicateBlockedThenForcedThroughMCP drives the guard through +// the whole tool path: second near-identical send blocked with guidance, +// force=true passes. +func TestV2SendNearDuplicateBlockedThenForcedThroughMCP(t *testing.T) { + harness := newV2ToolHarness(t, + v2ToolSendStep{result: bridge.SendResult{RemoteMessageID: "remote-dup-1"}}, + v2ToolSendStep{result: bridge.SendResult{RemoteMessageID: "remote-dup-2"}}, + ) + handler := sendToConversationHandler(harness.app, &harness.deps) + + first, err := handler(context.Background(), v2ToolCall(map[string]any{ + "conversation_id": v2ToolConversationID, + "message": "Lunch tomorrow at noon at Sfoglina?", + "idempotency_key": "dup-mcp-first", + })) + if err != nil || first.IsError { + t.Fatalf("first send failed: err=%v result=%v", err, first) + } + firstOutbox := v2ToolString(v2ToolPayload(t, first), "outbox_id") + + blocked, err := handler(context.Background(), v2ToolCall(map[string]any{ + "conversation_id": v2ToolConversationID, + "message": "Lunch today at noon at Sfoglina?", + "idempotency_key": "dup-mcp-second", + })) + if err != nil { + t.Fatalf("handler error: %v", err) + } + if !blocked.IsError { + t.Fatalf("near-duplicate must be blocked, got %v", blocked.Content) + } + payload := v2ToolPayload(t, blocked) + if got := v2ToolString(payload, "error_kind"); got != "near_duplicate_blocked" { + t.Fatalf("error_kind = %q, want near_duplicate_blocked", got) + } + if got := v2ToolString(payload, "duplicate_of_outbox_id"); got != firstOutbox { + t.Fatalf("duplicate_of_outbox_id = %q, want %q", got, firstOutbox) + } + text := blocked.Content[0].(mcp.TextContent).Text + for _, fragment := range []string{"NOT QUEUED", "force=true"} { + if !strings.Contains(text, fragment) { + t.Fatalf("duplicate text missing %q: %q", fragment, text) + } + } + + forced, err := handler(context.Background(), v2ToolCall(map[string]any{ + "conversation_id": v2ToolConversationID, + "message": "Lunch today at noon at Sfoglina?", + "idempotency_key": "dup-mcp-forced", + "force": true, + })) + if err != nil || forced.IsError { + t.Fatalf("forced send failed: err=%v result=%v", err, forced) + } + if got := v2ToolString(v2ToolPayload(t, forced), "transport_state"); got != "transmitted" { + t.Fatalf("forced transport_state = %q, want transmitted", got) + } +} + +// TestListAndCancelOutboxDaemonMode covers the new custody tools against a +// fake daemon. +func TestListAndCancelOutboxDaemonMode(t *testing.T) { + canceled := false + mux := http.NewServeMux() + mux.HandleFunc("GET /api/v1/outbox", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode([]map[string]any{{ + "outbox_id": "outbox-listed", + "conversation_id": "conversation-listed", + "kind": "text", + "state": "queued", + "created_at_ms": time.Now().UnixMilli(), + "expires_at_ms": time.Now().Add(5 * time.Minute).UnixMilli(), + "summary": "still waiting", + }}) + }) + mux.HandleFunc("POST /api/v1/outbox/outbox-listed/cancel", func(w http.ResponseWriter, r *http.Request) { + canceled = true + json.NewEncoder(w).Encode(map[string]any{ + "outbox_id": "outbox-listed", + "state": "canceled", + }) + }) + options := Options{Daemon: daemonClientFor(t, mux)} + + listReq := mcp.CallToolRequest{} + listReq.Params.Arguments = map[string]any{} + listResult, err := daemonListOutboxHandler(options)(context.Background(), listReq) + if err != nil { + t.Fatalf("list handler: %v", err) + } + if listResult.IsError { + t.Fatalf("list errored: %v", listResult.Content) + } + listPayload := structuredMap(t, listResult) + if got, _ := listPayload["count"].(int); got != 1 { + if gotFloat, _ := listPayload["count"].(float64); int(gotFloat) != 1 { + t.Fatalf("count = %v, want 1", listPayload["count"]) + } + } + listText := listResult.Content[0].(mcp.TextContent).Text + for _, fragment := range []string{"outbox-listed", "queued", "do not resend"} { + if !strings.Contains(listText, fragment) { + t.Fatalf("list text missing %q: %q", fragment, listText) + } + } + + cancelReq := mcp.CallToolRequest{} + cancelReq.Params.Arguments = map[string]any{"outbox_id": "outbox-listed"} + cancelResult, err := daemonCancelOutboxHandler(options)(context.Background(), cancelReq) + if err != nil { + t.Fatalf("cancel handler: %v", err) + } + if cancelResult.IsError { + t.Fatalf("cancel errored: %v", cancelResult.Content) + } + if !canceled { + t.Fatal("daemon cancel endpoint was not called") + } + cancelText := cancelResult.Content[0].(mcp.TextContent).Text + if !strings.Contains(cancelText, "NOT sent") { + t.Fatalf("cancel text missing NOT sent: %q", cancelText) + } +} + +// TestV2ListAndCancelOutboxInProcess covers the custody tools against the +// in-process service: a queued send is visible, cancelable, and a confirmed +// one refuses cancellation. +func TestV2ListAndCancelOutboxInProcess(t *testing.T) { + release := make(chan struct{}) + harness := newV2ToolHarness(t, v2ToolSendStep{ + result: bridge.SendResult{RemoteMessageID: "remote-after-cancel-check"}, + block: release, + }) + defer close(release) + sendHandler := sendToConversationHandler(harness.app, &harness.deps) + + // Submit with a tiny wait so the handler returns while the item is still + // in flight. + sendResult, err := sendHandler(context.Background(), v2ToolCall(map[string]any{ + "conversation_id": v2ToolConversationID, + "message": "cancel me maybe", + "idempotency_key": "custody-key", + "wait_seconds": float64(1), + })) + if err != nil { + t.Fatalf("send handler: %v", err) + } + outboxID := v2ToolString(v2ToolPayload(t, sendResult), "outbox_id") + if outboxID == "" { + t.Fatal("send result missing outbox_id") + } + + listReq := mcp.CallToolRequest{} + listReq.Params.Arguments = map[string]any{"conversation_id": v2ToolConversationID} + listResult, err := v2ListOutboxHandler(&harness.deps)(context.Background(), listReq) + if err != nil { + t.Fatalf("list handler: %v", err) + } + if listResult.IsError { + t.Fatalf("list errored: %v", listResult.Content) + } + listText := listResult.Content[0].(mcp.TextContent).Text + if !strings.Contains(listText, outboxID) { + t.Fatalf("list text missing %q: %q", outboxID, listText) + } + + // The item is dispatching (parked on the block channel), so cancel must + // refuse: it may already have crossed the transport boundary. + cancelReq := mcp.CallToolRequest{} + cancelReq.Params.Arguments = map[string]any{"outbox_id": outboxID} + cancelResult, err := v2CancelOutboxHandler(&harness.deps)(context.Background(), cancelReq) + if err != nil { + t.Fatalf("cancel handler: %v", err) + } + if !cancelResult.IsError { + t.Fatalf("cancel of a dispatching item must refuse, got %v", cancelResult.Content) + } + cancelText := cancelResult.Content[0].(mcp.TextContent).Text + if !strings.Contains(cancelText, "cannot cancel") { + t.Fatalf("refusal text = %q", cancelText) + } +} + +// TestDaemonUnavailableDaemonPredatesSendBlock: a daemon without the send +// capability block must not be blocked on (capability unknown ≠ unavailable). +func TestDaemonUnavailableDaemonPredatesSendBlock(t *testing.T) { + status := localapi.DaemonStatus{} + if failure := daemonCheckPlatformSendable(status, "whatsapp"); failure != nil { + t.Fatalf("old daemon without send block must pass through, got %v", failure.Content) + } +} + +// TestSendPayloadSettledMatrix pins settled/transmitted for every outbox +// state — the incident's overclaim, as a table. +func TestSendPayloadSettledMatrix(t *testing.T) { + tests := []struct { + state messaging.OutboxState + wantSettled bool + wantTransmitted bool + wantTransport string + }{ + {messaging.OutboxQueued, false, false, "queued"}, + {messaging.OutboxDispatching, false, false, "queued"}, + {messaging.OutboxNotDispatched, false, false, "queued"}, + {messaging.OutboxUncertain, false, false, "uncertain"}, + {messaging.OutboxConfirmed, true, true, "transmitted"}, + {messaging.OutboxStoreFailed, true, true, "transmitted"}, + {messaging.OutboxRejected, true, false, "failed"}, + {messaging.OutboxCanceled, true, false, "canceled"}, + } + for _, test := range tests { + t.Run(string(test.state), func(t *testing.T) { + payload := buildSendPayload(sendOutcome{Delivery: messaging.Delivery{ + OutboxID: "outbox-matrix", + State: test.state, + }}) + if got, _ := payload["settled"].(bool); got != test.wantSettled { + t.Fatalf("settled = %v, want %v", got, test.wantSettled) + } + if got, _ := payload["transmitted"].(bool); got != test.wantTransmitted { + t.Fatalf("transmitted = %v, want %v", got, test.wantTransmitted) + } + if got, _ := payload["transport_state"].(string); got != test.wantTransport { + t.Fatalf("transport_state = %q, want %q", got, test.wantTransport) + } + }) + } +} diff --git a/internal/tools/send_group_message.go b/internal/tools/send_group_message.go index 3049f19..c9c3928 100644 --- a/internal/tools/send_group_message.go +++ b/internal/tools/send_group_message.go @@ -39,6 +39,7 @@ func sendGroupMessageTool(v2Enabled ...bool) mcp.Tool { if v2Requested(v2Enabled) { options[0] = mcp.WithDescription(description + v2DeliveryDescription) options = append(options, mcp.WithString("idempotency_key", mcp.Description(v2IdempotencyDescription))) + options = withSendControlOptions(options, true) } options = append(options, mcp.WithDestructiveHintAnnotation(false), diff --git a/internal/tools/send_media_to_conversation.go b/internal/tools/send_media_to_conversation.go index b2ceea8..3882838 100644 --- a/internal/tools/send_media_to_conversation.go +++ b/internal/tools/send_media_to_conversation.go @@ -66,6 +66,7 @@ func sendMediaToConversationTool(v2Enabled ...bool) mcp.Tool { if v2Requested(v2Enabled) { options[0] = mcp.WithDescription(description + v2DeliveryDescription) options = append(options, mcp.WithString("idempotency_key", mcp.Description(v2IdempotencyDescription))) + options = withSendControlOptions(options, false) } options = append(options, mcp.WithDestructiveHintAnnotation(false), @@ -228,22 +229,39 @@ func submitV2MediaFile( if err != nil { return errorResult(err.Error()) } + ttl, err := parseSendTTL(args) + if err != nil { + return errorResult(err.Error()) + } + wait, err := parseSendWaitOptions(args) + if err != nil { + return errorResult(err.Error()) + } + conversationID := strArg(args, "conversation_id") + platform := v2.sendPlatform(a, conversationID) + if failure := checkPlatformSendable(localSendCapability(a, v2), platform); failure != nil { + return failure + } submission, err := v2.submitMedia(ctx, a, v2wire.MediaInput{ - ConversationID: strArg(args, "conversation_id"), + ConversationID: conversationID, Content: file, Filename: filename, MIME: mimeType, Caption: caption, ReplyToID: replyToID, IdempotencyKey: key, + TTL: ttl, }) if err != nil { if errors.Is(err, messaging.ErrTooLarge) { return errorResult(fmt.Sprintf("file too large (limit %d MB)", maxMediaUploadBytes>>20)) } + if errors.Is(err, v2wire.ErrPlatformNotSendable) { + return platformUnavailableResult(firstNonEmpty(platform, "the requested platform"), err.Error()) + } return errorResult(fmt.Sprintf("failed to submit media: %v", err)) } - return waitForV2Delivery(ctx, v2, submission, key) + return waitForV2Delivery(ctx, a, v2, submission, key, platform, conversationID, wait) } func detectMediaMimeType(filename string, data []byte, explicit string) string { diff --git a/internal/tools/send_message.go b/internal/tools/send_message.go index 24ab2d4..01dde8b 100644 --- a/internal/tools/send_message.go +++ b/internal/tools/send_message.go @@ -45,17 +45,18 @@ var ( ) func sendMessageTool(v2Enabled ...bool) mcp.Tool { - description := "Send a direct text message across supported platforms. Defaults to SMS/RCS when no platform is specified." + description := "Send a direct text message on ONE explicit platform. Defaults to SMS/RCS when no platform is specified. The requested platform is a hard contract: if it cannot send, the tool fails with the reason and queues nothing — it never falls back to a different platform." options := []mcp.ToolOption{ mcp.WithDescription(description), mcp.WithString("phone_number", mcp.Description("Legacy alias for recipient. For SMS/RCS use a phone number with country code (e.g., +15551234567).")), mcp.WithString("recipient", mcp.Description("Recipient identifier. Use a phone number for SMS/RCS or Signal, and a phone number or WhatsApp JID for WhatsApp.")), - mcp.WithString("platform", mcp.Description("Target platform: sms, rcs, whatsapp, or signal. Defaults to sms.")), + mcp.WithString("platform", mcp.Description("Target platform: sms (covers RCS), whatsapp, or signal. Defaults to sms. imessage is import/read-only — OpenMessage cannot send iMessages.")), mcp.WithString("message", mcp.Required(), mcp.Description("Message text to send")), } if v2Requested(v2Enabled) { options[0] = mcp.WithDescription(description + v2DeliveryDescription) options = append(options, mcp.WithString("idempotency_key", mcp.Description(v2IdempotencyDescription))) + options = withSendControlOptions(options, true) } options = append(options, mcp.WithDestructiveHintAnnotation(false), @@ -64,6 +65,16 @@ func sendMessageTool(v2Enabled ...bool) mcp.Tool { return mcp.NewTool("send_message", options...) } +// unsupportedSendPlatformResult is the hard-contract refusal for platforms +// OpenMessage cannot send on. The channel is part of the instruction, so the +// error never suggests silently substituting another platform. +func unsupportedSendPlatformResult(platform string) *mcp.CallToolResult { + if platform == "imessage" { + return errorResult("imessage is import/read-only: OpenMessage cannot send iMessages. Nothing was queued. If this recipient should get the message on a different platform, that is your call to make explicitly (resolve_contact_routes lists their sendable routes).") + } + return errorResult(fmt.Sprintf("unsupported platform %q (supported: sms, whatsapp, signal). Nothing was queued.", platform)) +} + func sendMessageHandler(a *app.App, v2Options ...*V2Dependencies) server.ToolHandlerFunc { v2 := activeV2(v2Options) return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { @@ -206,7 +217,7 @@ func sendMessageHandler(a *app.App, v2Options ...*V2Dependencies) server.ToolHan "message": summarizeMessage(storedMsg), }, fmt.Sprintf("Message sent to %s: %s", firstNonEmpty(conv.GetName(), recipient), message)), nil default: - return errorResult(fmt.Sprintf("unsupported platform %q (supported: sms, whatsapp, signal)", platform)), nil + return unsupportedSendPlatformResult(platform), nil } } } diff --git a/internal/tools/send_result.go b/internal/tools/send_result.go new file mode 100644 index 0000000..c55c5f8 --- /dev/null +++ b/internal/tools/send_result.go @@ -0,0 +1,528 @@ +package tools + +import ( + "errors" + "fmt" + "os" + "strconv" + "strings" + "time" + + "github.com/mark3labs/mcp-go/mcp" + + "github.com/maxghenis/openmessage/internal/app" + "github.com/maxghenis/openmessage/internal/db" + "github.com/maxghenis/openmessage/internal/localapi" + "github.com/maxghenis/openmessage/internal/messaging" + "github.com/maxghenis/openmessage/internal/readsource" + "github.com/maxghenis/openmessage/internal/sendcap" + "github.com/maxghenis/openmessage/internal/v2wire" +) + +// This file is the single place that translates durable outbox state into +// what agents are told about a send. The contract, after the 2026-08-05 +// incident (a send reported as "confirmed" flushed ~15 hours later and +// double-texted the recipient): +// +// queued → the message has NOT left this machine +// transmitted → the platform transport ACCEPTED it (remote message ID); +// acceptance is not delivery to the recipient's device +// delivered → a delivery receipt for it was observed +// +// "settled" is true only when the outcome is known and the dispatcher will +// not advance the intent further: transmitted (confirmed/store_failed) or +// terminal failure (rejected/canceled). It is never true while the intent is +// queued, retrying, or uncertain. + +// Transport-state labels of the agent-facing send contract. +const ( + transportStateQueued = "queued" + transportStateTransmitted = "transmitted" + transportStateDelivered = "delivered" + transportStateUncertain = "uncertain" + transportStateFailed = "failed" + transportStateCanceled = "canceled" +) + +// sendTTLEnvVar overrides the default send window for interactive MCP sends. +const sendTTLEnvVar = "OPENMESSAGES_SEND_TTL_SECONDS" + +// defaultSendTTL is the interactive-send window: a message still queued this +// long after submission is canceled instead of transmitted stale. Agents opt +// out per send with ttl_seconds=0 (never expire) or choose another window. +const defaultSendTTL = 10 * time.Minute + +const maxSendTTL = 24 * time.Hour + +// Wait bounds for the settle wait. The default matches the previous +// daemon-routed behavior; the cap keeps one MCP call from hanging a session. +const ( + defaultSendWait = 25 * time.Second + maxSendWait = 120 * time.Second +) + +const sendVerifyGuidance = "Transport acceptance is not delivery: verify the message appears in-thread (search_messages / get_conversation with a fresh timestamp) before reporting it as sent." + +// sendWaitOptions are the agent-facing wait knobs shared by the send tools. +type sendWaitOptions struct { + // WaitForTransmit keeps waiting through auto-retrying states until the + // transport acknowledges (or the intent fails terminally), instead of + // returning at the first stable-but-untransmitted state. + WaitForTransmit bool + // Wait bounds the whole wait. + Wait time.Duration +} + +func parseSendWaitOptions(args map[string]any) (sendWaitOptions, error) { + options := sendWaitOptions{Wait: defaultSendWait} + if raw, present := args["wait_for_transmit"]; present { + value, ok := raw.(bool) + if !ok { + return options, errors.New("wait_for_transmit must be a boolean") + } + options.WaitForTransmit = value + } + if raw, present := args["wait_seconds"]; present { + seconds, ok := numberArg(raw) + if !ok { + return options, errors.New("wait_seconds must be a number") + } + if seconds < 0 { + return options, errors.New("wait_seconds must not be negative") + } + options.Wait = time.Duration(seconds * float64(time.Second)) + } + if options.Wait > maxSendWait { + options.Wait = maxSendWait + } + return options, nil +} + +// parseSendTTL resolves the send window for one submission: the explicit +// ttl_seconds argument, else the OPENMESSAGES_SEND_TTL_SECONDS override, +// else the interactive default. ttl_seconds=0 means the send never expires. +func parseSendTTL(args map[string]any) (time.Duration, error) { + if raw, present := args["ttl_seconds"]; present { + seconds, ok := numberArg(raw) + if !ok { + return 0, errors.New("ttl_seconds must be a number") + } + if seconds < 0 { + return 0, errors.New("ttl_seconds must not be negative") + } + ttl := time.Duration(seconds * float64(time.Second)) + if ttl > maxSendTTL { + return 0, fmt.Errorf("ttl_seconds must not exceed %d (24 hours)", int(maxSendTTL/time.Second)) + } + return ttl, nil + } + if raw := strings.TrimSpace(os.Getenv(sendTTLEnvVar)); raw != "" { + seconds, err := strconv.ParseFloat(raw, 64) + if err != nil || seconds < 0 || time.Duration(seconds*float64(time.Second)) > maxSendTTL { + return 0, fmt.Errorf("%s must be a number of seconds between 0 and %d", sendTTLEnvVar, int(maxSendTTL/time.Second)) + } + return time.Duration(seconds * float64(time.Second)), nil + } + return defaultSendTTL, nil +} + +func parseSendForce(args map[string]any) (bool, error) { + raw, present := args["force"] + if !present { + return false, nil + } + value, ok := raw.(bool) + if !ok { + return false, errors.New("force must be a boolean") + } + return value, nil +} + +func numberArg(raw any) (float64, bool) { + switch value := raw.(type) { + case float64: + return value, true + case int: + return float64(value), true + default: + return 0, false + } +} + +// sendTransmitted reports transport acceptance: the transport returned a +// result for this intent (or did so and only the local record needs repair). +func sendTransmitted(state messaging.OutboxState) bool { + return state == messaging.OutboxConfirmed || state == messaging.OutboxStoreFailed +} + +// sendSettled reports a known final outcome. Uncertain is deliberately NOT +// settled: the outcome is unknown, and reporting it settled invites agents +// to treat the send as done. +func sendSettled(state messaging.OutboxState) bool { + switch state { + case messaging.OutboxConfirmed, messaging.OutboxStoreFailed, + messaging.OutboxRejected, messaging.OutboxCanceled: + return true + default: + return false + } +} + +// sendOutcome is everything the send tools know about one durable send when +// they answer the agent. +type sendOutcome struct { + Delivery messaging.Delivery + IdempotencyKey string + Deduplicated bool + // Platform is the send platform actually used ("sms", "whatsapp", + // "signal"); empty when it could not be resolved. + Platform string + // ConversationID is the conversation the send was written to. Falls back + // to the submitted conversation ID when the delivery record lacks one. + ConversationID string + // Delivered is set when a delivery receipt for the transmitted message + // was observed in the local store. + Delivered bool + // WaitedForTransmit and WaitExpired report an unfinished + // wait_for_transmit wait, so the text can say "still queued after Ns". + WaitedForTransmit bool + WaitExpired bool +} + +func (o sendOutcome) conversationID() string { + if strings.TrimSpace(o.Delivery.ConversationID) != "" { + return o.Delivery.ConversationID + } + return o.ConversationID +} + +func (o sendOutcome) transportState() string { + state := o.Delivery.State + switch { + case o.Delivered: + return transportStateDelivered + case sendTransmitted(state): + return transportStateTransmitted + case state == messaging.OutboxUncertain: + return transportStateUncertain + case state == messaging.OutboxRejected: + return transportStateFailed + case state == messaging.OutboxCanceled: + return transportStateCanceled + default: + return transportStateQueued + } +} + +// buildSendPayload is the structured result for every durable send answer. +func buildSendPayload(o sendOutcome) map[string]any { + delivery := o.Delivery + payload := map[string]any{ + "ok": sendTransmitted(delivery.State), + "settled": sendSettled(delivery.State), + "transmitted": sendTransmitted(delivery.State), + "transport_state": o.transportState(), + "state": string(delivery.State), + "outbox_id": delivery.OutboxID, + "idempotency_key": o.IdempotencyKey, + "deduplicated": o.Deduplicated, + } + if conversationID := o.conversationID(); conversationID != "" { + payload["conversation_id"] = conversationID + } + if o.Platform != "" { + payload["platform"] = o.Platform + } + if o.Delivered { + payload["delivered"] = true + } + if delivery.LocalMessageID != "" { + payload["local_message_id"] = delivery.LocalMessageID + } + if delivery.RemoteMessageID != "" { + payload["remote_message_id"] = delivery.RemoteMessageID + } + if delivery.ErrorClass != "" { + payload["error_class"] = delivery.ErrorClass + } + if delivery.ErrorCode != "" { + payload["error_code"] = delivery.ErrorCode + } + if delivery.Warning != "" { + payload["warning"] = delivery.Warning + } + if !delivery.ExpiresAt.IsZero() { + payload["expires_at_ms"] = delivery.ExpiresAt.UnixMilli() + } + if delivery.Expired() { + payload["expired"] = true + } + if delivery.State == messaging.OutboxNotDispatched { + payload["auto_retry"] = true + } + if delivery.State == messaging.OutboxUncertain { + payload["uncertain"] = true + } + return payload +} + +// sendResultText is the human/agent-readable line matching buildSendPayload. +func sendResultText(o sendOutcome) string { + delivery := o.Delivery + platform := o.Platform + if platform == "" { + platform = "the platform" + } + switch { + case o.Delivered: + return fmt.Sprintf( + "Message transmitted on %s and a delivery receipt was observed (outbox %s, remote message %s).", + platform, delivery.OutboxID, delivery.RemoteMessageID, + ) + case delivery.State == messaging.OutboxConfirmed: + return fmt.Sprintf( + "Message transmitted: the %s transport accepted it (outbox %s, remote message %s). %s", + platform, delivery.OutboxID, delivery.RemoteMessageID, sendVerifyGuidance, + ) + case delivery.State == messaging.OutboxStoreFailed: + return fmt.Sprintf( + "Message transmitted: the %s transport accepted it (outbox %s, remote message %s); the local record is being repaired automatically. Do not resend. %s", + platform, delivery.OutboxID, delivery.RemoteMessageID, sendVerifyGuidance, + ) + case delivery.State == messaging.OutboxUncertain: + return fmt.Sprintf( + "Send outcome UNCERTAIN (outbox %s): the transport may or may not have accepted it, and this will not resolve on its own. Do not retry automatically — check the conversation for the message before doing anything, and resend only as a deliberate new intent.", + delivery.OutboxID, + ) + case delivery.Expired(): + return fmt.Sprintf( + "NOT SENT: the send window expired before the message reached the transport (outbox %s). Nothing was transmitted and nothing will be. Submit a new send if the message is still wanted.", + delivery.OutboxID, + ) + case delivery.State == messaging.OutboxCanceled: + return fmt.Sprintf("NOT SENT: the send was canceled before reaching the transport (outbox %s).", delivery.OutboxID) + case delivery.State == messaging.OutboxRejected: + return fmt.Sprintf( + "NOT SENT: the send was rejected (outbox %s, error class %s). The app will not retry it; sending again creates a new message and may fail the same way.", + delivery.OutboxID, firstNonEmpty(delivery.ErrorClass, "unknown"), + ) + case delivery.State == messaging.OutboxNotDispatched: + return fmt.Sprintf( + "NOT YET TRANSMITTED: the last attempt failed (outbox %s, error class %s) and the app is retrying it automatically — do NOT send this message again; monitor with list_outbox or cancel with cancel_outbox. %s%s", + delivery.OutboxID, + firstNonEmpty(delivery.ErrorClass, "unknown"), + sendQueuedExpiryText(delivery), + sendWaitExpiredSuffix(o), + ) + default: + return fmt.Sprintf( + "NOT YET TRANSMITTED: the message is durably queued (outbox %s, state %s) and has not left this machine. The app keeps trying in the background — do NOT send this message again; monitor with list_outbox or cancel with cancel_outbox. %s%s", + delivery.OutboxID, delivery.State, sendQueuedExpiryText(delivery), sendWaitExpiredSuffix(o), + ) + } +} + +func sendQueuedExpiryText(delivery messaging.Delivery) string { + if delivery.ExpiresAt.IsZero() { + return "If it cannot transmit, it stays queued until canceled." + } + return fmt.Sprintf( + "If it has not transmitted by %s it is canceled as expired instead of sending stale.", + delivery.ExpiresAt.UTC().Format(time.RFC3339), + ) +} + +func sendWaitExpiredSuffix(o sendOutcome) string { + if o.WaitedForTransmit && o.WaitExpired { + return " The wait_for_transmit window elapsed without transport acceptance." + } + return "" +} + +// sendOutcomeResult renders one durable-send outcome. Never an IsError +// result: the intent is durably owned by the outbox, and a tool error +// invites the calling agent to resend. +func sendOutcomeResult(o sendOutcome) *mcp.CallToolResult { + return structuredResult(buildSendPayload(o), sendResultText(o)) +} + +// withSendControlOptions appends the durable-send control arguments shared +// by the send tools (v2/daemon modes only — legacy direct sends have no +// outbox for these to act on). +func withSendControlOptions(options []mcp.ToolOption, includeForce bool) []mcp.ToolOption { + options = append(options, + mcp.WithNumber("ttl_seconds", mcp.Description( + "Send window in seconds: if the message is still queued when the window closes, it is canceled as expired instead of transmitting stale. Default 600 (10 minutes; installation override via OPENMESSAGES_SEND_TTL_SECONDS). 0 = never expire. Max 86400.", + )), + mcp.WithBoolean("wait_for_transmit", mcp.Description( + "Keep waiting (bounded by wait_seconds) until the transport acknowledges the send — or it fails terminally — instead of returning while it is queued or auto-retrying. Use this when you must report truthfully whether the message actually went out.", + )), + mcp.WithNumber("wait_seconds", mcp.Description( + "How long to wait for the send outcome before reporting the durable queued state (default 25, max 120).", + )), + ) + if includeForce { + options = append(options, mcp.WithBoolean("force", mcp.Description( + "Bypass the near-duplicate guard: submit even though a very similar message was sent to this conversation within the last few minutes. Use only for a deliberate repeat.", + ))) + } + return options +} + +// messageByIDReader is the optional read-source capability used to check for +// delivery receipts. The legacy store satisfies it; when the active read +// source does not, sends simply never report "delivered". +type messageByIDReader interface { + GetMessageByID(messageID string) (*db.Message, error) +} + +// deliveryReceiptObserved reports whether the local store has seen a +// delivery/read receipt for the transmitted message. Google Messages stores +// OUTGOING_DELIVERED/OUTGOING_READ/OUTGOING_DISPLAYED on the message row; +// WhatsApp receipts normalize to DELIVERED/READ. Absence of a receipt is not +// evidence of non-delivery — many paths never record one. +func deliveryReceiptObserved(reads readsource.ReadSource, remoteMessageID string) bool { + if reads == nil || strings.TrimSpace(remoteMessageID) == "" { + return false + } + reader, ok := reads.(messageByIDReader) + if !ok { + return false + } + message, err := reader.GetMessageByID(remoteMessageID) + if err != nil || message == nil { + return false + } + status := strings.ToUpper(message.Status) + return strings.Contains(status, "DELIVERED") || + strings.Contains(status, "READ") || + strings.Contains(status, "DISPLAYED") +} + +// platformUnavailableResult refuses a send whose platform cannot send right +// now. Nothing is queued; there is deliberately no cross-platform fallback — +// the channel is part of the instruction. +func platformUnavailableResult(platform, reason string) *mcp.CallToolResult { + if reason == "" { + reason = "the platform cannot send right now" + } + text := fmt.Sprintf( + "Cannot send on %s: %s. The message was NOT queued. No fallback to another platform is attempted — the requested channel is part of the instruction. Use resolve_contact_routes to list this contact's sendable routes and choose one explicitly, or fix the platform and retry.", + platform, reason, + ) + result := structuredResult(map[string]any{ + "ok": false, + "error": text, + "error_kind": "platform_unsendable", + "platform": platform, + "reason": reason, + }, text) + result.IsError = true + return result +} + +// platformMismatchResult refuses a send whose conversation belongs to a +// different platform than the caller demanded. +func platformMismatchResult(requested, actual, conversationID string) *mcp.CallToolResult { + text := fmt.Sprintf( + "Platform mismatch: conversation %s is a %s conversation, but platform=%q was requested. The message was NOT queued. Re-check the route (resolve_contact_routes) or omit the platform argument to send on the conversation's own platform.", + conversationID, actual, requested, + ) + result := structuredResult(map[string]any{ + "ok": false, + "error": text, + "error_kind": "platform_mismatch", + "requested_platform": requested, + "actual_platform": actual, + "conversation_id": conversationID, + }, text) + result.IsError = true + return result +} + +// duplicateBlockedResult surfaces the near-duplicate guard. Nothing was +// queued, so an error result is safe (it cannot cause a double-send; it +// prevents one). +func duplicateBlockedResult(err *messaging.DuplicateSendError) *mcp.CallToolResult { + text := fmt.Sprintf( + "NOT QUEUED: a very similar message was submitted to this conversation %s ago (outbox %s, state %s) and may still reach the recipient. Sending this too would risk a double-text. Check that prior send first (list_outbox / get_conversation). If both messages are genuinely intended, resend with force=true.", + (time.Duration(err.PriorAgeMS) * time.Millisecond).Round(time.Second), + err.PriorOutboxID, + err.PriorState, + ) + result := structuredResult(map[string]any{ + "ok": false, + "error": text, + "error_kind": "near_duplicate_blocked", + "duplicate_of_outbox_id": err.PriorOutboxID, + "duplicate_state": string(err.PriorState), + }, text) + result.IsError = true + return result +} + +// daemonDuplicateBlockedResult recognizes the daemon's HTTP 409 for the +// near-duplicate guard and renders the same guidance as the in-process path. +func daemonDuplicateBlockedResult(responseErr *localapi.ResponseError) *mcp.CallToolResult { + text := fmt.Sprintf( + "NOT QUEUED: the app blocked this as a near-duplicate of a message submitted moments ago that may still reach the recipient (%s). Check that prior send first (list_outbox / get_conversation). If both messages are genuinely intended, resend with force=true.", + responseErr.Body, + ) + result := structuredResult(map[string]any{ + "ok": false, + "error": text, + "error_kind": "near_duplicate_blocked", + }, text) + result.IsError = true + return result +} + +func isDaemonDuplicateRejection(err error) (*localapi.ResponseError, bool) { + responseErr, ok := localapi.AsResponseError(err) + if !ok || responseErr.StatusCode != 409 { + return nil, false + } + if !strings.Contains(responseErr.Body, "near-duplicate") { + return nil, false + } + return responseErr, true +} + +// localSendCapability computes per-platform send capability for a process +// that owns its own transports (standalone serve). Client mode uses daemon +// truth instead. +func localSendCapability(a *app.App, v2 *V2Dependencies) map[string]sendcap.Capability { + inputs := sendcap.Inputs{ + TransportsEnabled: true, + Google: googleStatus(a), + WhatsApp: whatsAppStatus(a), + Signal: signalStatus(a), + } + if v2 != nil && v2.Registry != nil { + inputs.AdapterTextSend = func(platform string) bool { + accountID := v2wire.AccountIDForPlatform(platform) + if accountID == "" { + return false + } + return v2.Registry.Capabilities(accountID).TextSend + } + } + return sendcap.Compute(inputs) +} + +// checkPlatformSendable enforces route sendability at send time against a +// capability map. Unknown platforms pass through (the submit path validates +// them); missing maps mean "capability unknown", which must not block. A +// queueable outage (transient disconnect) also passes: the durable outbox +// exists exactly for that case, and the result reports queued/not-transmitted +// truthfully with a TTL bounding staleness. +func checkPlatformSendable(capabilities map[string]sendcap.Capability, platform string) *mcp.CallToolResult { + if capabilities == nil || platform == "" { + return nil + } + capability, known := capabilities[platform] + if !known || capability.Available || capability.Queueable { + return nil + } + return platformUnavailableResult(platform, capability.Reason) +} diff --git a/internal/tools/send_to_conversation.go b/internal/tools/send_to_conversation.go index 70bee4f..0dcfb99 100644 --- a/internal/tools/send_to_conversation.go +++ b/internal/tools/send_to_conversation.go @@ -3,6 +3,7 @@ package tools import ( "context" "fmt" + "strings" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" @@ -21,15 +22,17 @@ var ( ) func sendToConversationTool(v2Enabled ...bool) mcp.Tool { - description := "Send a text message to an existing conversation by conversation ID across supported platforms" + description := "Send a text message to an existing conversation by conversation ID. The message goes out on the conversation's own platform — there is never a fallback to a different platform; pass the optional platform argument to assert which platform you intend, and the tool fails on a mismatch instead of sending." options := []mcp.ToolOption{ mcp.WithDescription(description), mcp.WithString("conversation_id", mcp.Required(), mcp.Description("Existing conversation ID from list_conversations or get_conversation")), mcp.WithString("message", mcp.Required(), mcp.Description("Message text to send")), + mcp.WithString("platform", mcp.Description("Optional assertion of the platform this conversation must be on (sms, whatsapp, signal). Mismatch fails the send instead of routing to an unintended channel.")), } if v2Requested(v2Enabled) { options[0] = mcp.WithDescription(description + v2DeliveryDescription) options = append(options, mcp.WithString("idempotency_key", mcp.Description(v2IdempotencyDescription))) + options = withSendControlOptions(options, true) } options = append(options, mcp.WithDestructiveHintAnnotation(false), @@ -38,6 +41,20 @@ func sendToConversationTool(v2Enabled ...bool) mcp.Tool { return mcp.NewTool("send_to_conversation", options...) } +// checkConversationPlatformAssertion enforces the optional platform argument +// against the conversation's actual platform. +func checkConversationPlatformAssertion(args map[string]any, actualPlatform, conversationID string) *mcp.CallToolResult { + requestedRaw := strArg(args, "platform") + if strings.TrimSpace(requestedRaw) == "" { + return nil + } + requested := normalizeDirectSendPlatform(requestedRaw) + if actualPlatform != "" && requested != actualPlatform { + return platformMismatchResult(requested, actualPlatform, conversationID) + } + return nil +} + func sendToConversationHandler(a *app.App, v2Options ...*V2Dependencies) server.ToolHandlerFunc { v2 := activeV2(v2Options) return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { @@ -52,9 +69,15 @@ func sendToConversationHandler(a *app.App, v2Options ...*V2Dependencies) server. return errorResult("message is required"), nil } if v2 != nil { + if failure := checkConversationPlatformAssertion(args, v2.sendPlatform(a, conversationID), conversationID); failure != nil { + return failure, nil + } return submitV2Text(ctx, a, v2, args, conversationID, message), nil } + if failure := checkConversationPlatformAssertion(args, legacyConversationPlatform(a, conversationID), conversationID); failure != nil { + return failure, nil + } conv, msg, err := sendTextToConversation(a, conversationID, message) if err != nil { return errorResult(fmt.Sprintf("failed to send: %v", err)), nil @@ -62,8 +85,26 @@ func sendToConversationHandler(a *app.App, v2Options ...*V2Dependencies) server. return structuredResult(map[string]any{ "ok": true, + "platform": conv.SourcePlatform, "conversation": conv, "message": msg, }, fmt.Sprintf("Message sent to %s (%s): %s", conv.Name, conversationID, message)), nil } } + +// legacyConversationPlatform resolves a conversation's platform from the +// legacy store, with the same prefix shortcuts as the durable paths. +func legacyConversationPlatform(a *app.App, conversationID string) string { + switch { + case strings.HasPrefix(conversationID, "whatsapp:"): + return "whatsapp" + case strings.HasPrefix(conversationID, "signal:"), strings.HasPrefix(conversationID, "signal-group:"): + return "signal" + } + if a != nil && a.Store != nil { + if conversation, err := a.Store.GetConversation(conversationID); err == nil && conversation != nil { + return normalizedPlatform(conversation.SourcePlatform) + } + } + return "" +} diff --git a/internal/tools/tools.go b/internal/tools/tools.go index 59746e6..9e65d70 100644 --- a/internal/tools/tools.go +++ b/internal/tools/tools.go @@ -79,10 +79,21 @@ func RegisterWithOptions(s *server.MCPServer, a *app.App, options Options) { } else { s.AddTool(reactToMessageTool(), reactToMessageHandler(a)) } + switch { + case options.Daemon != nil: + s.AddTool(listOutboxTool(), daemonListOutboxHandler(options)) + s.AddTool(cancelOutboxTool(), daemonCancelOutboxHandler(options)) + case configuredV2 != nil: + s.AddTool(listOutboxTool(), v2ListOutboxHandler(configuredV2)) + s.AddTool(cancelOutboxTool(), v2CancelOutboxHandler(configuredV2)) + default: + s.AddTool(listOutboxTool(), outboxUnavailableHandler()) + s.AddTool(cancelOutboxTool(), outboxUnavailableHandler()) + } s.AddTool(setMessageTranscriptTool(), setMessageTranscriptHandler(a)) s.AddTool(listConversationsTool(), listConversationsHandler(a, options)) s.AddTool(listContactsTool(), listContactsHandler(a)) - s.AddTool(resolveContactRoutesTool(), resolveContactRoutesHandler(a)) + s.AddTool(resolveContactRoutesTool(), resolveContactRoutesHandler(a, options)) if options.Daemon != nil { s.AddTool(getStatusTool(), daemonGetStatusHandler(a, options)) } else { diff --git a/internal/tools/tools_test.go b/internal/tools/tools_test.go index b1f0e20..c162e76 100644 --- a/internal/tools/tools_test.go +++ b/internal/tools/tools_test.go @@ -682,15 +682,20 @@ func TestResolveContactRoutesPrefersSMSThread(t *testing.T) { t.Fatalf("seed unified contact: %v", err) } + originalGoogleStatus := googleStatus originalWhatsAppStatus := whatsAppStatus originalSignalStatus := signalStatus + googleStatus = func(*app.App) app.GoogleStatusSnapshot { + return app.GoogleStatusSnapshot{Connected: true, Paired: true, PhoneResponding: true} + } whatsAppStatus = func(*app.App) whatsapplive.StatusSnapshot { - return whatsapplive.StatusSnapshot{Connected: true} + return whatsapplive.StatusSnapshot{Connected: true, Paired: true} } signalStatus = func(*app.App) signallive.StatusSnapshot { return signallive.StatusSnapshot{} } t.Cleanup(func() { + googleStatus = originalGoogleStatus whatsAppStatus = originalWhatsAppStatus signalStatus = originalSignalStatus }) diff --git a/internal/tools/v2.go b/internal/tools/v2.go index 7cd669d..92dfa65 100644 --- a/internal/tools/v2.go +++ b/internal/tools/v2.go @@ -7,12 +7,14 @@ import ( "errors" "fmt" "strings" + "time" "github.com/mark3labs/mcp-go/mcp" "github.com/maxghenis/openmessage/internal/app" "github.com/maxghenis/openmessage/internal/bridge" "github.com/maxghenis/openmessage/internal/messaging" + "github.com/maxghenis/openmessage/internal/readsource" "github.com/maxghenis/openmessage/internal/storage/sqlite" "github.com/maxghenis/openmessage/internal/v2wire" ) @@ -28,7 +30,7 @@ type V2Dependencies struct { Registry bridge.Registry } -const v2DeliveryDescription = " With v2 sending enabled, this waits for a settled delivery result. Every result includes outbox_id and idempotency_key. If settled is false (the wait was interrupted or the app is retrying automatically), the message is still durably queued and the app finishes sending it in the background: never send it again in response. An uncertain result means the transport may have accepted the message; do not retry automatically. Send again only as a deliberate new intent, reusing the returned idempotency_key when repeating the exact same send after a lost response." +const v2DeliveryDescription = " With v2 sending enabled, this reports truthful transport state: transport_state is queued (has NOT left this machine), transmitted (the platform transport accepted it — NOT proof of delivery), delivered (a delivery receipt was observed), uncertain, failed, or canceled. settled/transmitted are true only on transport acknowledgment; while they are false the message is still durably queued and the app keeps sending it in the background — never send it again in response. An uncertain result means the transport may have accepted the message; do not retry automatically. Results include the platform actually used and the conversation_id written to; there is never a silent fallback to another platform. Sends carry a default ~10-minute send window (ttl_seconds; 0 = never expire) after which a still-queued message cancels as expired instead of sending stale. Near-identical resends within a few minutes are blocked unless force=true. Set wait_for_transmit=true (with wait_seconds, max 120) to keep waiting for transport acknowledgment before returning. Reuse the returned idempotency_key only to replay the exact same send after a lost response." const v2IdempotencyDescription = "Optional retry key for the exact same send. Every result echoes the key in use; reuse the same key only when repeating a send whose response was lost. Omit it to mint a new intent." @@ -128,6 +130,39 @@ func newMCPIdempotencyKey() (string, error) { }, "-"), nil } +// sendPlatform resolves the send platform for a conversation ID. Prefixed +// IDs are authoritative; otherwise the serving store's conversation row +// decides. Empty when unresolvable — the submit path still validates the +// conversation, so an unknown platform never blocks a legitimate send. +func (v *V2Dependencies) sendPlatform(a *app.App, conversationID string) string { + switch { + case strings.HasPrefix(conversationID, "whatsapp:"): + return "whatsapp" + case strings.HasPrefix(conversationID, "signal:"), strings.HasPrefix(conversationID, "signal-group:"): + return "signal" + } + if v.V2Primary && v.V2Store != nil { + conversation, err := v.V2Store.GetConversation(conversationID) + if err != nil { + return "" + } + account, err := v.V2Store.GetAccount(conversation.AccountID) + if err != nil { + return "" + } + if account.BridgeKey == "google" { + return "sms" + } + return account.BridgeKey + } + if a != nil && a.Store != nil { + if conversation, err := a.Store.GetConversation(conversationID); err == nil && conversation != nil { + return normalizedPlatform(conversation.SourcePlatform) + } + } + return "" +} + func submitV2Text( ctx context.Context, a *app.App, @@ -140,15 +175,40 @@ func submitV2Text( if err != nil { return errorResult(err.Error()) } + ttl, err := parseSendTTL(args) + if err != nil { + return errorResult(err.Error()) + } + force, err := parseSendForce(args) + if err != nil { + return errorResult(err.Error()) + } + wait, err := parseSendWaitOptions(args) + if err != nil { + return errorResult(err.Error()) + } + platform := v2.sendPlatform(a, conversationID) + if failure := checkPlatformSendable(localSendCapability(a, v2), platform); failure != nil { + return failure + } submission, err := v2.submitText(ctx, a, v2wire.TextInput{ ConversationID: conversationID, Body: body, IdempotencyKey: key, + TTL: ttl, + Force: force, }) if err != nil { + var duplicate *messaging.DuplicateSendError + if errors.As(err, &duplicate) { + return duplicateBlockedResult(duplicate) + } + if errors.Is(err, v2wire.ErrPlatformNotSendable) { + return platformUnavailableResult(firstNonEmpty(platform, "the requested platform"), err.Error()) + } return errorResult(fmt.Sprintf("failed to submit message: %v", err)) } - return waitForV2Delivery(ctx, v2, submission, key) + return waitForV2Delivery(ctx, a, v2, submission, key, platform, conversationID, wait) } // waitForV2Delivery reports the durable send's outcome. The intent is already @@ -158,109 +218,130 @@ func submitV2Text( // reported as non-settled statuses with explicit do-not-resend guidance. func waitForV2Delivery( ctx context.Context, + a *app.App, v2 *V2Dependencies, submission messaging.Submission, idempotencyKey string, + platform string, + conversationID string, + wait sendWaitOptions, ) *mcp.CallToolResult { if v2.Service == nil { return errorResult("v2 send service is unavailable") } - delivery, err := v2.Service.Wait(ctx, submission.OutboxID) - if err != nil { - return v2InterruptedResult(submission, idempotencyKey, delivery, err) - } + waitCtx, cancel := context.WithTimeout(ctx, wait.Wait) + defer cancel() - settled := delivery.State != messaging.OutboxNotDispatched - payload := map[string]any{ - "ok": v2DeliveryOK(delivery.State), - "settled": settled, - "outbox_id": delivery.OutboxID, - "state": delivery.State, - "deduplicated": submission.Deduplicated, - "local_message_id": delivery.LocalMessageID, - "idempotency_key": idempotencyKey, - } - if !settled { - payload["auto_retry"] = true - } - if delivery.RemoteMessageID != "" { - payload["remote_message_id"] = delivery.RemoteMessageID + var delivery messaging.Delivery + var waitErr error + for { + delivery, waitErr = v2.Service.Get(waitCtx, submission.OutboxID) + if waitErr != nil { + break + } + if sendSettled(delivery.State) || delivery.State == messaging.OutboxUncertain { + break + } + // not_dispatched is stable-but-retrying: report it unless the caller + // asked to hold out for transport acknowledgment. + if delivery.State == messaging.OutboxNotDispatched && !wait.WaitForTransmit { + break + } + changed := v2.Service.Changes() + select { + case <-waitCtx.Done(): + waitErr = waitCtx.Err() + case <-changed: + case <-time.After(250 * time.Millisecond): + } + if waitErr != nil { + break + } } - if delivery.ErrorClass != "" { - payload["error_class"] = delivery.ErrorClass + if waitErr != nil { + return v2InterruptedResult(a, v2, submission, idempotencyKey, platform, conversationID, waitErr) } - if delivery.ErrorCode != "" { - payload["error_code"] = delivery.ErrorCode + + outcome := sendOutcome{ + Delivery: delivery, + IdempotencyKey: idempotencyKey, + Deduplicated: submission.Deduplicated, + Platform: platform, + ConversationID: conversationID, + WaitedForTransmit: wait.WaitForTransmit, } - if delivery.Warning != "" { - payload["warning"] = delivery.Warning + if sendTransmitted(delivery.State) { + outcome.Delivered = deliveryReceiptObserved(legacyReads(a), delivery.RemoteMessageID) } - - return structuredResult(payload, v2DeliveryText(delivery)) + return sendOutcomeResult(outcome) } -// v2InterruptedResult handles Wait ending before the delivery settled (request -// context canceled or timed out, or a transient read failure). The durable row -// is untouched by the interruption and the dispatcher runs on its own context, -// so the send still completes in the background. +// v2InterruptedResult handles the wait ending before the delivery settled +// (request context canceled or timed out, or a transient read failure). The +// durable row is untouched by the interruption and the dispatcher runs on its +// own context, so the send still completes in the background — unless its +// send window expires first. func v2InterruptedResult( + a *app.App, + v2 *V2Dependencies, submission messaging.Submission, idempotencyKey string, - lastKnown messaging.Delivery, + platform string, + conversationID string, waitErr error, ) *mcp.CallToolResult { - outboxID := lastKnown.OutboxID - if outboxID == "" { - outboxID = submission.OutboxID - } - state := string(lastKnown.State) - if state == "" { - state = string(messaging.OutboxQueued) + // Best-effort fresh read on a detached context: the wait's own context is + // typically the thing that just expired. + lastKnown := messaging.Delivery{ + OutboxID: submission.OutboxID, + ConversationID: conversationID, + State: messaging.OutboxQueued, + LocalMessageID: submission.LocalMessageID, + ExpiresAt: submission.ExpiresAt, } - payload := map[string]any{ - "ok": false, - "settled": false, - "outbox_id": outboxID, - "state": state, - "deduplicated": submission.Deduplicated, - "idempotency_key": idempotencyKey, - "wait_error": waitErr.Error(), + if v2.Service != nil { + readCtx, cancel := context.WithTimeout(context.WithoutCancel(context.Background()), 2*time.Second) + if delivery, err := v2.Service.Get(readCtx, submission.OutboxID); err == nil { + lastKnown = delivery + } + cancel() + if sendSettled(lastKnown.State) || lastKnown.State == messaging.OutboxUncertain { + outcome := sendOutcome{ + Delivery: lastKnown, + IdempotencyKey: idempotencyKey, + Deduplicated: submission.Deduplicated, + Platform: platform, + ConversationID: conversationID, + } + if sendTransmitted(lastKnown.State) { + outcome.Delivered = deliveryReceiptObserved(legacyReads(a), lastKnown.RemoteMessageID) + } + return sendOutcomeResult(outcome) + } } - if lastKnown.LocalMessageID != "" { - payload["local_message_id"] = lastKnown.LocalMessageID + + outcome := sendOutcome{ + Delivery: lastKnown, + IdempotencyKey: idempotencyKey, + Deduplicated: submission.Deduplicated, + Platform: platform, + ConversationID: conversationID, } + payload := buildSendPayload(outcome) + payload["wait_error"] = waitErr.Error() text := fmt.Sprintf( - "The send is durably queued (outbox %s, state %s) and the app will finish sending it in the background; this wait was interrupted (%v) before the outcome settled. Do NOT send this message again. To repeat the exact same send deliberately, reuse idempotency_key %s.", - outboxID, state, waitErr, idempotencyKey, + "The send is durably queued (outbox %s, state %s) and has NOT been transmitted; this wait was interrupted (%v) before the outcome was known. The app keeps sending it in the background. Do NOT send this message again — check progress with list_outbox, or repeat the exact same send deliberately by reusing idempotency_key %s.", + lastKnown.OutboxID, lastKnown.State, waitErr, idempotencyKey, ) return structuredResult(payload, text) } -// v2DeliveryOK reports whether the message reached the transport. store_failed -// means the transport accepted the send and only the local record needs repair, -// which the dispatcher performs automatically. -func v2DeliveryOK(state messaging.OutboxState) bool { - return state == messaging.OutboxConfirmed || state == messaging.OutboxStoreFailed -} - -func v2DeliveryText(delivery messaging.Delivery) string { - switch delivery.State { - case messaging.OutboxConfirmed: - if delivery.RemoteMessageID != "" { - return fmt.Sprintf("Message delivery confirmed (outbox %s, remote message %s).", delivery.OutboxID, delivery.RemoteMessageID) - } - return fmt.Sprintf("Message delivery confirmed (outbox %s).", delivery.OutboxID) - case messaging.OutboxUncertain: - return fmt.Sprintf("Message delivery is uncertain (outbox %s): the transport may have accepted it. Do not retry automatically; send again only as a deliberate new intent.", delivery.OutboxID) - case messaging.OutboxNotDispatched: - return fmt.Sprintf("Delivery has not succeeded yet (outbox %s, error class %s). The app is retrying it automatically; do NOT send this message again.", delivery.OutboxID, firstNonEmpty(delivery.ErrorClass, "unknown")) - case messaging.OutboxStoreFailed: - return fmt.Sprintf("The transport accepted the message (outbox %s, remote message %s); the local record is being repaired automatically. Do not resend.", delivery.OutboxID, delivery.RemoteMessageID) - case messaging.OutboxRejected: - return fmt.Sprintf("Message delivery was rejected (outbox %s, error class %s). The app will not retry it; sending again creates a new message and may fail the same way.", delivery.OutboxID, firstNonEmpty(delivery.ErrorClass, "unknown")) - case messaging.OutboxCanceled: - return fmt.Sprintf("Message delivery was canceled (outbox %s).", delivery.OutboxID) - default: - return fmt.Sprintf("Message delivery settled as %s (outbox %s).", delivery.State, delivery.OutboxID) +// legacyReads returns the legacy store for delivery-receipt lookups. Receipt +// statuses (OUTGOING_DELIVERED, DELIVERED, READ) are recorded by the live +// event handlers on the legacy store regardless of serving mode. +func legacyReads(a *app.App) readsource.ReadSource { + if a == nil || a.Store == nil { + return nil } + return a.Store } diff --git a/internal/tools/v2_send_test.go b/internal/tools/v2_send_test.go index 462521e..18ca16c 100644 --- a/internal/tools/v2_send_test.go +++ b/internal/tools/v2_send_test.go @@ -20,8 +20,10 @@ import ( "github.com/maxghenis/openmessage/internal/bridge" "github.com/maxghenis/openmessage/internal/db" "github.com/maxghenis/openmessage/internal/messaging" + "github.com/maxghenis/openmessage/internal/signallive" "github.com/maxghenis/openmessage/internal/storage/blob" "github.com/maxghenis/openmessage/internal/storage/sqlite" + "github.com/maxghenis/openmessage/internal/whatsapplive" ) func TestV2SendToConversationReturnsConfirmedDelivery(t *testing.T) { @@ -85,7 +87,12 @@ func TestV2SendToConversationReturnsHonestUncertainDelivery(t *testing.T) { payload := v2ToolPayload(t, result) assertV2ToolBool(t, payload, "ok", false) - assertV2ToolBool(t, payload, "settled", true) + // An unknown outcome is NOT settled: reporting it settled invited agents + // to treat the send as finished. It is flagged uncertain instead. + assertV2ToolBool(t, payload, "settled", false) + assertV2ToolBool(t, payload, "transmitted", false) + assertV2ToolBool(t, payload, "uncertain", true) + assertV2ToolString(t, payload, "transport_state", "uncertain") assertV2ToolString(t, payload, "state", string(messaging.OutboxUncertain)) assertV2ToolString(t, payload, "remote_message_id", "") assertV2ToolString(t, payload, "idempotency_key", "mcp-uncertain-key") @@ -419,8 +426,31 @@ type v2ToolHarness struct { blobRoot string } +// stubHealthyTransportStatus models all three platforms paired, connected, +// and healthy for the duration of one test, so send-capability prechecks +// exercise the durable pipeline rather than refusing at the door. +func stubHealthyTransportStatus(t *testing.T) { + t.Helper() + originalGoogle, originalWhatsApp, originalSignal := googleStatus, whatsAppStatus, signalStatus + googleStatus = func(*app.App) app.GoogleStatusSnapshot { + return app.GoogleStatusSnapshot{Connected: true, Paired: true, PhoneResponding: true} + } + whatsAppStatus = func(*app.App) whatsapplive.StatusSnapshot { + return whatsapplive.StatusSnapshot{Connected: true, Paired: true} + } + signalStatus = func(*app.App) signallive.StatusSnapshot { + return signallive.StatusSnapshot{Connected: true, Paired: true} + } + t.Cleanup(func() { + googleStatus = originalGoogle + whatsAppStatus = originalWhatsApp + signalStatus = originalSignal + }) +} + func newV2ToolHarness(t *testing.T, steps ...v2ToolSendStep) *v2ToolHarness { t.Helper() + stubHealthyTransportStatus(t) a := testApp(t) if err := a.Store.UpsertConversation(&db.Conversation{ ConversationID: v2ToolConversationID, diff --git a/internal/tools/v2_wait_test.go b/internal/tools/v2_wait_test.go index 481fe16..44207c4 100644 --- a/internal/tools/v2_wait_test.go +++ b/internal/tools/v2_wait_test.go @@ -173,27 +173,31 @@ func TestV2SendRejectedIsSettledWithNoRetryGuidance(t *testing.T) { } } -func TestV2DeliveryTextAndOKForRepairAndCancelStates(t *testing.T) { - storeFailed := messaging.Delivery{ +func TestSendResultTextAndOKForRepairAndCancelStates(t *testing.T) { + storeFailed := sendOutcome{Delivery: messaging.Delivery{ OutboxID: "outbox-repair", State: messaging.OutboxStoreFailed, RemoteMessageID: "remote-repair", + }} + if !sendTransmitted(storeFailed.Delivery.State) { + t.Fatal("store_failed means the transport accepted the send; transmitted must be true") } - if !v2DeliveryOK(storeFailed.State) { - t.Fatal("store_failed means the transport delivered; ok must be true") - } - text := v2DeliveryText(storeFailed) + text := sendResultText(storeFailed) for _, fragment := range []string{"transport accepted", "repaired automatically", "Do not resend"} { if !strings.Contains(text, fragment) { t.Fatalf("store_failed text missing %q: %q", fragment, text) } } + // The word "delivery" must never be claimed on bare transport acceptance. + if strings.Contains(strings.ToLower(text), "delivery confirmed") { + t.Fatalf("store_failed text claims delivery: %q", text) + } - canceled := messaging.Delivery{OutboxID: "outbox-canceled", State: messaging.OutboxCanceled} - if v2DeliveryOK(canceled.State) { - t.Fatal("canceled must not report ok") + canceled := sendOutcome{Delivery: messaging.Delivery{OutboxID: "outbox-canceled", State: messaging.OutboxCanceled}} + if sendTransmitted(canceled.Delivery.State) { + t.Fatal("canceled must not report transmitted") } - if got := v2DeliveryText(canceled); !strings.Contains(got, "canceled") { + if got := sendResultText(canceled); !strings.Contains(got, "NOT SENT") { t.Fatalf("canceled text = %q", got) } } diff --git a/internal/v2wire/mirror.go b/internal/v2wire/mirror.go index e16c624..166b07b 100644 --- a/internal/v2wire/mirror.go +++ b/internal/v2wire/mirror.go @@ -321,6 +321,22 @@ func accountBridgeKey(accountID string) string { } } +// AccountIDForPlatform maps a send platform key ("sms", "whatsapp", +// "signal") to the primary account ID the daemon registers adapters under. +// Empty for unknown platforms. +func AccountIDForPlatform(platform string) string { + switch platform { + case "sms", "rcs": + return googleAccountID + case "whatsapp": + return whatsappAccountID + case "signal": + return signalAccountID + default: + return "" + } +} + // localDeviceID is account-scoped because devices.device_id is a global // primary key. A constant ID lets mirroring a second account steal the first // account's device row and invalidates that account's read-cursor foreign key. diff --git a/internal/v2wire/submit.go b/internal/v2wire/submit.go index 3d46954..11c5d14 100644 --- a/internal/v2wire/submit.go +++ b/internal/v2wire/submit.go @@ -28,6 +28,8 @@ type TextInput struct { ReplyToID string IdempotencyKey string NotBefore time.Time + TTL time.Duration + Force bool } type MediaInput struct { @@ -39,6 +41,7 @@ type MediaInput struct { ReplyToID string IdempotencyKey string NotBefore time.Time + TTL time.Duration } // SubmitText mirrors only the graph needed by the durable service, rejects @@ -79,6 +82,8 @@ func SubmitText(ctx context.Context, deps Deps, input TextInput) (messaging.Subm ConversationID: conversationID, IdempotencyKey: input.IdempotencyKey, NotBefore: input.NotBefore, + TTL: input.TTL, + Force: input.Force, }, Body: input.Body, ReplyToMessageID: replyToMessageID, @@ -121,6 +126,7 @@ func SubmitMedia(ctx context.Context, deps Deps, input MediaInput) (messaging.Su ConversationID: conversationID, IdempotencyKey: input.IdempotencyKey, NotBefore: input.NotBefore, + TTL: input.TTL, }, Content: input.Content, Filename: input.Filename, diff --git a/internal/v2wire/submit_native.go b/internal/v2wire/submit_native.go index 7e0b071..4c10b62 100644 --- a/internal/v2wire/submit_native.go +++ b/internal/v2wire/submit_native.go @@ -62,6 +62,8 @@ func SubmitTextV2( ConversationID: conversation.ConversationID, IdempotencyKey: input.IdempotencyKey, NotBefore: input.NotBefore, + TTL: input.TTL, + Force: input.Force, }, Body: input.Body, ReplyToMessageID: replyToMessageID, @@ -109,6 +111,7 @@ func SubmitMediaV2( ConversationID: conversation.ConversationID, IdempotencyKey: input.IdempotencyKey, NotBefore: input.NotBefore, + TTL: input.TTL, }, Content: input.Content, Filename: input.Filename, diff --git a/internal/web/api.go b/internal/web/api.go index dcfd1a3..1aaaca3 100644 --- a/internal/web/api.go +++ b/internal/web/api.go @@ -32,6 +32,7 @@ import ( "github.com/maxghenis/openmessage/internal/media" "github.com/maxghenis/openmessage/internal/messaging" "github.com/maxghenis/openmessage/internal/readsource" + "github.com/maxghenis/openmessage/internal/sendcap" "github.com/maxghenis/openmessage/internal/storage/sqlite" "github.com/maxghenis/openmessage/internal/story" "github.com/maxghenis/openmessage/internal/whatsapplive" @@ -84,11 +85,21 @@ type StatusChecker func() bool // UnpairFunc deletes the session and disconnects. type UnpairFunc func() error +// SendPlatformCapability reports whether one platform's SEND path is expected +// to dispatch promptly. It is deliberately distinct from "connected": a +// paired-but-disconnected transport still receives sends into the durable +// outbox, where they wait — exactly the condition agents must see before +// submitting time-sensitive messages. The computation lives in +// internal/sendcap so the daemon status block and the MCP client's send-time +// enforcement answer identically. +type SendPlatformCapability = sendcap.Capability + // APIOptions holds optional callbacks for the API handler. type APIOptions struct { Auth *ControlAuth V2 *V2Options V2IngestCounters func() map[string]ingest.CounterSnapshot + SendCapability func() map[string]SendPlatformCapability Reads readsource.ReadSource V2Primary bool Client func() *client.Client @@ -310,6 +321,9 @@ func APIHandlerWithOptions(store *db.Store, cli *client.Client, logger zerolog.L if opts.SignalStatus != nil { payload["signal"] = opts.SignalStatus() } + if opts.SendCapability != nil { + payload["send"] = opts.SendCapability() + } if opts.BackfillStatus != nil { payload["backfill"] = opts.BackfillStatus() } diff --git a/internal/web/apiv1.go b/internal/web/apiv1.go index e138a3e..ee20626 100644 --- a/internal/web/apiv1.go +++ b/internal/web/apiv1.go @@ -47,17 +47,23 @@ type v1SubmissionResponse struct { LocalMessageID string `json:"local_message_id"` State messaging.OutboxState `json:"state"` ScheduledForMS int64 `json:"scheduled_for_ms"` + ExpiresAtMS int64 `json:"expires_at_ms,omitempty"` Deduplicated bool `json:"deduplicated"` } type v1DeliveryResponse struct { OutboxID string `json:"outbox_id"` + AccountID string `json:"account_id,omitempty"` + ConversationID string `json:"conversation_id,omitempty"` + Platform string `json:"platform,omitempty"` State messaging.OutboxState `json:"state"` LocalMessageID string `json:"local_message_id,omitempty"` RemoteMessageID string `json:"remote_message_id,omitempty"` ErrorClass string `json:"error_class,omitempty"` ErrorCode string `json:"error_code,omitempty"` Warning string `json:"warning,omitempty"` + ExpiresAtMS int64 `json:"expires_at_ms,omitempty"` + Expired bool `json:"expired,omitempty"` } type v1PendingResponse struct { @@ -68,6 +74,7 @@ type v1PendingResponse struct { State messaging.OutboxState `json:"state"` ScheduledForMS int64 `json:"scheduled_for_ms"` NextAttemptMS *int64 `json:"next_attempt_at_ms,omitempty"` + ExpiresAtMS int64 `json:"expires_at_ms,omitempty"` AttemptCount int64 `json:"attempt_count"` CreatedAtMS int64 `json:"created_at_ms"` Summary string `json:"summary"` @@ -120,6 +127,8 @@ func (a *v1API) submitText(w http.ResponseWriter, r *http.Request) { ReplyToID string `json:"reply_to_id,omitempty"` IdempotencyKey string `json:"idempotency_key"` NotBeforeMS *int64 `json:"not_before_ms,omitempty"` + TTLMS *int64 `json:"ttl_ms,omitempty"` + Force bool `json:"force,omitempty"` } if err := json.NewDecoder(r.Body).Decode(&request); err != nil { httpError(w, "invalid JSON: "+err.Error(), http.StatusBadRequest) @@ -140,6 +149,11 @@ func (a *v1API) submitText(w http.ResponseWriter, r *http.Request) { httpError(w, err.Error(), http.StatusBadRequest) return } + ttl, err := validateOptionalTTL(request.TTLMS) + if err != nil { + httpError(w, err.Error(), http.StatusBadRequest) + return + } if !a.submitDependenciesAvailable(w) { return } @@ -150,6 +164,8 @@ func (a *v1API) submitText(w http.ResponseWriter, r *http.Request) { ReplyToID: strings.TrimSpace(request.ReplyToID), IdempotencyKey: idempotencyKey, NotBefore: notBefore, + TTL: ttl, + Force: request.Force, } var submission messaging.Submission if a.primary { @@ -190,6 +206,11 @@ func (a *v1API) submitMedia(w http.ResponseWriter, r *http.Request) { httpError(w, err.Error(), http.StatusBadRequest) return } + ttl, err := parseOptionalTTL(r.FormValue("ttl_ms")) + if err != nil { + httpError(w, err.Error(), http.StatusBadRequest) + return + } if !a.submitDependenciesAvailable(w) { return } @@ -214,6 +235,7 @@ func (a *v1API) submitMedia(w http.ResponseWriter, r *http.Request) { ReplyToID: strings.TrimSpace(r.FormValue("reply_to_id")), IdempotencyKey: idempotencyKey, NotBefore: notBefore, + TTL: ttl, } var submission messaging.Submission if a.primary { @@ -257,7 +279,7 @@ func (a *v1API) getDelivery(w http.ResponseWriter, r *http.Request) { a.writeError(w, err) return } - writeJSON(w, deliveryResponse(delivery)) + writeJSON(w, a.deliveryResponse(delivery)) } func (a *v1API) cancel(w http.ResponseWriter, r *http.Request) { @@ -285,7 +307,7 @@ func (a *v1API) deliveryAction( a.writeError(w, err) return } - writeJSON(w, deliveryResponse(delivery)) + writeJSON(w, a.deliveryResponse(delivery)) } func (a *v1API) v2Cancel(ctx context.Context, id string) (messaging.Delivery, error) { @@ -448,6 +470,8 @@ func v1ErrorResponse(err error) (int, string) { switch { case errors.Is(err, v2wire.ErrReplyTargetUnavailable): return http.StatusUnprocessableEntity, "reply_target_unavailable" + case errors.Is(err, messaging.ErrDuplicateSend): + return http.StatusConflict, err.Error() case errors.Is(err, messaging.ErrIdempotencyConflict): return http.StatusConflict, err.Error() case errors.Is(err, messaging.ErrInvalidState): @@ -501,6 +525,28 @@ func parseOptionalSchedule(raw string) (time.Time, error) { return validateOptionalSchedule(&value) } +func validateOptionalTTL(ttlMS *int64) (time.Duration, error) { + if ttlMS == nil { + return 0, nil + } + if *ttlMS < 0 { + return 0, errors.New("ttl_ms must not be negative") + } + return time.Duration(*ttlMS) * time.Millisecond, nil +} + +func parseOptionalTTL(raw string) (time.Duration, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return 0, nil + } + value, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return 0, fmt.Errorf("ttl_ms must be an integer") + } + return validateOptionalTTL(&value) +} + func normalizeRequiredV2IdempotencyKey(raw string) (string, error) { key, err := normalizeSendIdempotencyKey(raw) if err != nil { @@ -513,24 +559,57 @@ func normalizeRequiredV2IdempotencyKey(raw string) (string, error) { } func submissionResponse(submission messaging.Submission) v1SubmissionResponse { - return v1SubmissionResponse{ + response := v1SubmissionResponse{ OutboxID: submission.OutboxID, LocalMessageID: submission.LocalMessageID, State: submission.State, ScheduledForMS: submission.ScheduledFor.UnixMilli(), Deduplicated: submission.Deduplicated, } + if !submission.ExpiresAt.IsZero() { + response.ExpiresAtMS = submission.ExpiresAt.UnixMilli() + } + return response } -func deliveryResponse(delivery messaging.Delivery) v1DeliveryResponse { - return v1DeliveryResponse{ +func (a *v1API) deliveryResponse(delivery messaging.Delivery) v1DeliveryResponse { + response := v1DeliveryResponse{ OutboxID: delivery.OutboxID, + AccountID: delivery.AccountID, + ConversationID: delivery.ConversationID, + Platform: a.accountPlatform(delivery.AccountID), State: delivery.State, LocalMessageID: delivery.LocalMessageID, RemoteMessageID: delivery.RemoteMessageID, ErrorClass: delivery.ErrorClass, ErrorCode: delivery.ErrorCode, Warning: delivery.Warning, + Expired: delivery.Expired(), + } + if !delivery.ExpiresAt.IsZero() { + response.ExpiresAtMS = delivery.ExpiresAt.UnixMilli() + } + return response +} + +// accountPlatform maps a v2 account to the platform label agents use for +// sends. The account's bridge key is the transport family ("google", +// "whatsapp", "signal"); Google Messages carries SMS/RCS, reported here as +// "sms" to match conversation source_platform values. RCS-vs-SMS is not +// distinguishable at this layer and is deliberately not guessed. +func (a *v1API) accountPlatform(accountID string) string { + if a.v2 == nil || a.v2.V2Store == nil || strings.TrimSpace(accountID) == "" { + return "" + } + account, err := a.v2.V2Store.GetAccount(accountID) + if err != nil { + return "" + } + switch account.BridgeKey { + case "google": + return "sms" + default: + return account.BridgeKey } } @@ -552,6 +631,9 @@ func pendingResponse(delivery messaging.PendingDelivery) v1PendingResponse { nextAttemptMS := delivery.NextAttemptAt.UnixMilli() response.NextAttemptMS = &nextAttemptMS } + if !delivery.ExpiresAt.IsZero() { + response.ExpiresAtMS = delivery.ExpiresAt.UnixMilli() + } return response } diff --git a/internal/web/apiv1_test.go b/internal/web/apiv1_test.go index d9154fc..892dd57 100644 --- a/internal/web/apiv1_test.go +++ b/internal/web/apiv1_test.go @@ -259,6 +259,7 @@ func TestV1ErrorStatusMapping(t *testing.T) { wantMessage string }{ {name: "idempotency conflict", err: messaging.ErrIdempotencyConflict, wantStatus: http.StatusConflict}, + {name: "near-duplicate blocked", err: &messaging.DuplicateSendError{PriorOutboxID: "outbox-prior", PriorState: messaging.OutboxQueued}, wantStatus: http.StatusConflict}, {name: "invalid command", err: messaging.ErrInvalidCommand, wantStatus: http.StatusBadRequest}, {name: "invalid state", err: messaging.ErrInvalidState, wantStatus: http.StatusConflict}, {name: "platform", err: v2wire.ErrPlatformNotSendable, wantStatus: http.StatusNotImplemented}, diff --git a/internal/web/send_truth_test.go b/internal/web/send_truth_test.go new file mode 100644 index 0000000..0f3be91 --- /dev/null +++ b/internal/web/send_truth_test.go @@ -0,0 +1,129 @@ +package web + +// Wire-level pieces of the truthful-send-states change: TTL parsing, the +// enriched delivery response (account/conversation/platform/expiry), and the +// /api/status per-platform send capability block. + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + "time" + + "github.com/maxghenis/openmessage/internal/messaging" + "github.com/maxghenis/openmessage/internal/sendcap" + "github.com/maxghenis/openmessage/internal/storage/sqlite" +) + +func TestOptionalTTLParsing(t *testing.T) { + if ttl, err := validateOptionalTTL(nil); err != nil || ttl != 0 { + t.Fatalf("validateOptionalTTL(nil) = %v, %v; want 0, nil", ttl, err) + } + value := int64(600_000) + if ttl, err := validateOptionalTTL(&value); err != nil || ttl != 10*time.Minute { + t.Fatalf("validateOptionalTTL(600000) = %v, %v; want 10m, nil", ttl, err) + } + negative := int64(-1) + if _, err := validateOptionalTTL(&negative); err == nil { + t.Fatal("validateOptionalTTL(-1) accepted a negative window") + } + if ttl, err := parseOptionalTTL(""); err != nil || ttl != 0 { + t.Fatalf("parseOptionalTTL(\"\") = %v, %v; want 0, nil", ttl, err) + } + if ttl, err := parseOptionalTTL("90000"); err != nil || ttl != 90*time.Second { + t.Fatalf("parseOptionalTTL(90000) = %v, %v; want 90s, nil", ttl, err) + } + if _, err := parseOptionalTTL("not-a-number"); err == nil { + t.Fatal("parseOptionalTTL accepted junk") + } +} + +func TestDeliveryResponseCarriesTransportAndExpiry(t *testing.T) { + store, err := sqlite.Open(filepath.Join(t.TempDir(), "v2.sqlite3")) + if err != nil { + t.Fatalf("sqlite.Open(): %v", err) + } + t.Cleanup(func() { _ = store.Close() }) + nowMS := time.Now().UnixMilli() + if err := store.UpsertAccount(sqlite.Account{ + AccountID: "google-primary", + BridgeKey: "google", + DisplayName: "Google", + Mode: sqlite.AccountModeLive, + Enabled: true, + ConfigJSON: `{}`, + CreatedAtMS: nowMS, + UpdatedAtMS: nowMS, + }); err != nil { + t.Fatalf("UpsertAccount(): %v", err) + } + + api := &v1API{v2: &V2Options{V2Store: store}} + expiry := time.UnixMilli(nowMS).Add(10 * time.Minute) + response := api.deliveryResponse(messaging.Delivery{ + OutboxID: "outbox-wire", + AccountID: "google-primary", + ConversationID: "conversation-wire", + State: messaging.OutboxConfirmed, + ExpiresAt: expiry, + }) + if response.Platform != "sms" { + t.Fatalf("platform = %q, want sms (google bridge key maps to the sms send platform)", response.Platform) + } + if response.AccountID != "google-primary" || response.ConversationID != "conversation-wire" { + t.Fatalf("identity fields = %q/%q", response.AccountID, response.ConversationID) + } + if response.ExpiresAtMS != expiry.UnixMilli() { + t.Fatalf("expires_at_ms = %d, want %d", response.ExpiresAtMS, expiry.UnixMilli()) + } + if response.Expired { + t.Fatal("confirmed delivery must not report expired") + } + + ttlClass := sqlite.TTLErrorClass + expired := api.deliveryResponse(messaging.Delivery{ + OutboxID: "outbox-expired", + State: messaging.OutboxCanceled, + ErrorClass: ttlClass, + }) + if !expired.Expired { + t.Fatal("ttl-canceled delivery must report expired") + } +} + +func TestStatusReportsSendCapabilityBlock(t *testing.T) { + ts := newV1RecorderHarness(t, APIOptions{ + SendCapability: func() map[string]SendPlatformCapability { + return map[string]SendPlatformCapability{ + sendcap.PlatformSMS: {Available: true}, + sendcap.PlatformWhatsApp: {Available: false, Reason: "whatsapp is not paired"}, + sendcap.PlatformSignal: {Available: false, Queueable: true, Reason: "signal is disconnected; a send submitted now would wait in the outbox until it reconnects"}, + } + }, + }) + resp := ts.do(t, httptest.NewRequest(http.MethodGet, "http://127.0.0.1/api/status", nil)) + defer resp.Body.Close() + + var payload struct { + Send map[string]SendPlatformCapability `json:"send"` + } + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + t.Fatal(err) + } + if payload.Send == nil { + t.Fatal("status payload missing send block") + } + if !payload.Send["sms"].Available { + t.Fatalf("sms = %+v, want available", payload.Send["sms"]) + } + whatsApp := payload.Send["whatsapp"] + if whatsApp.Available || whatsApp.Queueable || whatsApp.Reason == "" { + t.Fatalf("whatsapp = %+v, want unavailable+non-queueable with reason", whatsApp) + } + signal := payload.Send["signal"] + if signal.Available || !signal.Queueable { + t.Fatalf("signal = %+v, want unavailable but queueable", signal) + } +}