Repository navigation
Conversation
Add an optional Sendblue webhook bridge. Each allowlisted phone number gets one persistent conversation with the selected Dot, answered through the same server-side turn path scheduled tasks use, so texts appear in the web app. `/new` starts a fresh conversation. The webhook runs on its own port and serves only POST /sendblue/webhook, so a tunnel never exposes the app. It checks the shared webhook secret before reading a bounded body, ignores echoes, receipts, groups, other lines and unknown senders, deduplicates message handles in SQLite, and answers 503 without claiming a handle when its bounded queue is full. Replies are plain text in SMS-safe parts and are never resent after an unconfirmed send. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sendblue hosts the iMessage line, so OpenDots can answer texts from Linux, Docker, or any server without a Mac or the Messages app. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jerelvelarde
left a comment
There was a problem hiding this comment.
Useful optional text channel and good template fit. Webhook authentication, sender allowlisting, parsing limits, deduplication, listener separation and safe logs were checked against the official Sendblue contract. Hold merge for the shared-queue timeout issue below. All 45 focused tests and full branch checks pass. Synthetic HTTP/lifecycle fixtures were used; no live messages, provider calls or account registration occurred.
| .some((thread) => thread.id === current && thread.dotId === dotId) | ||
| ) | ||
| return current; | ||
| const { id } = await this.host.createConversation( |
There was a problem hiding this comment.
[P2] Include conversation creation in the reply deadline. A first message or /new awaits createConversation without the abort signal; the installed client has no request timeout. A stalled creation therefore keeps the shared inbox queue blocked after the two-minute deadline and prevents later senders from receiving replies. A deferred-creation fixture confirms the deadline aborts while draining remains active and no turn begins. Abort or bound creation as well as the Dot turn, and cover pending creation timing out with the queue continuing.
Conversation creation cannot be cancelled, so a stalled Intelligence request kept the shared queue blocked past the two-minute deadline and held up replies to every sender, as well as shutdown. Stop waiting for the whole reply step at the deadline, and never start a turn once it has passed. A conversation created late is kept for the sender's next text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts: # src/server/platform.ts # src/server/workspace.ts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Workflow
Text a Dot from your phone over iMessage or SMS through a Sendblue line. Each allowlisted phone number gets one persistent conversation with the selected Dot, which also appears in the web app. Texting
/newstarts a fresh one.No Mac required. Sendblue hosts the iMessage line, so the machine running OpenDots doesn't need to be a Mac or have the Messages app. It works wherever OpenDots runs, including Linux, Docker, and hosted servers.
Setup is a set of
SENDBLUE_*variables, a public HTTPS route to the webhook port, and one receive-webhook registration. It is documented indocs/SETUP.md#text-messages-sendblue, including account creation, a two-turn handset check, and removal. Without those variables nothing changes: no listener starts and Settings readsSendblue: not configured.How it works
Platform.turn(), the server-side path scheduled tasks use, in a conversation created withPlatform.createConversation(). History lives in Intelligence like any other conversation. I did not use a Channels SDK direct adapter: in Intelligence mode, a Channel whose only adapter is direct reportssetup_required(no managed provider is declared), and direct adapters keep conversation state in the adapter rather than in Intelligence Threads, so texts would not show up in the web app.HOSTandSENDBLUE_WEBHOOK_PORT(default 4313) serves onlyPOST /sendblue/webhookand returns 404 for everything else. A tunnel or proxy pointed at it cannot reach the app API. That matters because, withoutOWNER_TOKEN, the app API relies on the Host check.sb-signing-secretwithSENDBLUE_WEBHOOK_SECRETin constant time before reading the body, and caps the body at 64 KiB. It then validates field types. Outbound echoes, non-RECEIVEDevents, group messages, other lines, and senders outsideSENDBLUE_ALLOWED_NUMBERSare acknowledged and ignored; an unknown sender is logged without the number. The allowlist has no wildcard. Attachments become a text note for the Dot, and media URLs are never fetched./newis never merged into the texts around it. Message handles are deduplicated in SQLite for seven days. When the 32-item queue is full, the webhook answers 503 before claiming the handle, so Sendblue's retry is accepted later.POST /api/send-messageand needs amessage_handle, an accepted status, and noerror_code. Sendblue has no idempotency key, so a rejected or unconfirmed part is never resent and later parts are skipped. Logs carry onlysafeFailure()names and HTTP statuses, never message text, numbers, or provider bodies.Platform.start()starts the listener only when the settings are valid, the Dot exists, and conversations are configured. Otherwise Settings showssetup requiredand the log names the missing settings without their values.Platform.stop()closes the listener and aborts any in-flight turn; nothing is sent after shutdown. A port conflict reportsactivation failedwithout affecting the app.sendblue_threads(number → conversation) andsendblue_inbound(recent handles), are created next to the existing workspace tables. No existing table changes.Relationship to #53
#53 bridges the Messages app on a signed-in Mac. This PR uses a hosted Sendblue line instead, so it runs in containers or on a server without Full Disk Access, and it also covers SMS. Both use the same model of one
Platform.turn()conversation per sender. They touch the same setup-status and config spots, so I'm happy to rebase onto whichever lands first.Verification
Base
565bf781d654339ee1ce83b17ee00d76d679608e. The evidence below was collected atdce22939d406d00161eb75c86cb8aebf4705e683; the current headdad6250ca8ed03ccf9f66bd3dd013943e3c78938only adds the no-Mac wording to the README and setup guide, andnpm run check-formatpasses on it.npm run check-format,lint,typecheck,testandbuildall pass on Node 26.3.1. Tests: 266 passing, 254 existing plus 12 new.npm testandnpm run buildalso pass on Node 24.10.0, the CI major.tests/sendblue.test.tsuses the realPlatform, theStore/WorkspaceStoreSQLite (file-backed for the restart case),Platform.start()/stop(), the real HTTP listener, and a local HTTP stand-in for the Sendblue API. Only Intelligence thread creation and the model turn are stubbed. It covers:/newcontent-length), malformed JSON, invalid field types, and paths other than the webhook/newseparationERRORstatus, a missing handle, a nonzeroerror_code, and a dropped connection: each is sent once and never resentCannot find module '../src/server/sendblue.js'./newseparationnode dist/server/server/index.jswith fixture keys andDO_NOT_TRACK=1):/api/workspacereportssendblue: "listening".SIGTERMexits 0 and closes the listener.setup_required, the log names only the missing settings, and no listener starts.{"type":"receive","webhooks":[{url, secret, sendblue_numbers}]}, reports "Already registered" on a second run, and prints no credentials.Not verified: a live Sendblue account, delivery to a real handset, SMS fallback, or a live Intelligence and model turn started by a text. The README lists text messages next to Slack as still needing connected-service verification, and the setup guide includes the handset checklist. A
QUEUEDresponse means Sendblue accepted the message, not that the phone received it.Limitations
Rollback
Revert the commit and remove the
SENDBLUE_*variables. The two new tables are inert without the feature, and no existing data changes.AI assistance
The implementation, tests, docs, and this description were generated with Claude Code. Maintainer review is pending.
🤖 Generated with Claude Code