Skip to content

feat: Messaging Channels 1, the seam, Linked identities and Telegram (#1044) - #1064

Merged
Daanish2003 merged 30 commits into
mainfrom
feature/messaging-channels-1
Oct 30, 2026
Merged

Daanish2003 merged 30 commits into
mainfrom
feature/messaging-channels-1

Conversation

@Daanish2003

@Daanish2003 Daanish2003 commented Oct 30, 2026 •

Copy link
Copy Markdown
Owner

Summary

Spec 1 of the Messaging Channels Wayfinder. A user can now talk to their Bot on Telegram.

  • Messaging Channel seam: a seam in the core channel plugin, with Channel accounts (a platform bot's token per Bot, encrypted under the ADR 0010 keyring). Listeners run one per account across instances, held by a Postgres advisory lock.
  • Linked identities: proved by a 6-digit link code, so a Bot answers only its user.
  • Inbound: messages from the Linked identity land in the Bot's Conversation as Messages and Triggers, with Attachments and a "via Telegram" mark.
  • Outbound: replies and notification routes (telegram) reach Telegram.
  • Rich controls: choices and Approvals show as buttons and can also be answered by typing. A Secret request only ever links into the app.
  • @botsdk/channel-telegram: long polling with plain fetch, so no new dependency.
  • Web app: installs the adapter. It has the Channels popover in the Bot header and Linked identities on Settings.

Closes #1044. Tickets #1045–#1050 are closed. #1051's Host wiring is merged, and the ticket stays open for the owner's manual checklist with a real @Botfather bot. ADR 0033 and the CONTEXT terms Channel account and Linked identity come in with this branch.

How it was tested

  • Test-first integration tests at the spec's seams, driven through a new fakeMessagingAdapter in @botsdk/testing on real Postgres:
    • channel-accounts, linked-identities, channel-messaging-inbound, channel-messaging-out and channel-messaging-controls;
    • UI component tests for the popover, the Settings sections, the route pills and the "via" mark;
    • the Telegram package, tested against a local fake Bot API server.
  • Each implementer broke the behaviour and saw its new tests go red.
  • Full verify at d7d274f: lint, format and typecheck OK, then tests 283 files passed and 3 skipped, run in 3 shards.
  • Full e2e at d6ab94e, after the web wiring: 58/58.
  • The branch-review fixes (b3efea7) were checked with lint, format, root typecheck and the affected files: 252 tests, each new one seen red first.
  • The full suite was not re-run after the review fixes, and CI was not waited for, at the owner's request.

Decisions I made

  • Schema (owner-approved): bot_trigger.channel changed from an enum to text, so any adapter id can be recorded. notification_route gained telegram. Approved on Linked messages wake the Bot #1047 and on One platform bot per Bot, or one per Host #1036.
  • Link-code guesses (owner-approved, from the branch review): a link code is void after 5 wrong guesses, counted in a link_code.attempts column inside the Linked identities and link codes #1046 migration.
  • Public API (owner-approved): the SDK Logger gains an optional debug?. The silent drops of strangers and bad codes log there.
  • Public API: the Email popover's nextChannel output widened from "web" | "email" to a string, which may be a platform id such as telegram.
  • Telegram 403s: a 403 counts as a token rejection only on account-wide calls (getMe, getUpdates, deleteWebhook). One user blocking the bot doesn't mark the account needs attention.
  • Moving a platform bot: saving a platform bot that sits on another of the user's own Bots moves it there (ADR 0033). One on another user's Bot is refused.
  • Default route: the default route follows the user to Telegram when they last wrote from there and it can carry the Message. A messaging route without an installed adapter falls back to the defaults.
  • Refused typed answers: a typed answer the domain refuses because the Bot is paused, or because it was already answered, goes on as an ordinary Message.
  • Webhook Routines: approving a webhook Routine on Telegram replies with a link to the app for the webhook URL. The token is never sent on the Channel.
  • "Other" answers: a pending "Other" answer is kept in memory per SDK instance and expires after 10 minutes.

Risk

  • Migrations: four new bot migrations: channel_account, linked_identity (with link_code), trigger_channel_text (drops the bot_trigger_channel enum type) and route_telegram.
  • Credentials: Channel account tokens are stored encrypted, never returned, and left out of the user export. rotateKeys covers them.
  • Known gaps, for follow-ups:
    • A Host with only a messaging Channel has no app URL for links, because it comes from the email or push adapter. Fixing it needs a channel plugin option.
    • A multi-part send that fails partway resends the parts already sent.
    • The "Linked to your account" notice is lost if the token is rejected while sending it.
    • Voice transcription is out of scope.

🤖 Generated with Claude Code

Daanish2003 and others added 30 commits October 30, 2026 11:52
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Defines the messaging adapter interface (check, listen, send, editButtons,
download, deepLink, limits) and the channel_account table, with encrypted
credentials, the channels.accounts list/save/remove RPCs, one listener per
active account under a session advisory lock across instances, needs-attention
on a credentials rejection, key rotation, the fake messaging adapter and the
Bot header's Channels popover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The committed listeners were an older copy: the version stayed a number while
listenerAccount returns the ciphertext, which broke check-types, and the
review's session, stop and rejection fixes were missing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…1050)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ates, retry_after (#1050)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
bin/botsdk.mjs loads the sources with Node's strip-only TypeScript, which
refuses constructor parameter properties; declare the field instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#1050)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Adds the linked_identity and link_code tables, the channels.identities
list, unlink and linkCode RPCs, linking an unknown sender by a link code
with a system notice, user delete and export, the Settings section and
the Channels popover's Now link yourself.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…de (#1046)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ance that stops them (#1046)

Adds an optional debug method to the SDK Logger and logs each dropped
messaging update without its text or code. The channel plugin made its
listeners in init, so composing the plugin into a second instance after
start left the started listeners unreachable by stop and the pool open.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A private-chat message from the user's Linked identity becomes a user Message plus a Trigger in the
Bot's Conversation, recording the platform as its Channel; files are kept as Attachments. The
Trigger's channel is now text (migration), the hand-in shared with email, and the web app marks
user Messages from a messaging Channel "via <platform>".

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… only when kept (#1047)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A Turn's reply goes back to the messaging Channel of the latest Trigger it took, and the
notification routes can name telegram (refused unless linked; unhonourable routes fall back to the
defaults). Delivery extends the channel.deliver job with a messaging payload; a credentials
rejection marks the Channel account needs_attention.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…l rules (#1048)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
#1051)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A choice and an Approval go out as buttons that a press or a typed reply answers through the
app's own paths, a Secret request as a link only, files as documents or a link to the app.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… too (#1044)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…plugin (#1044)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… unique-violation helper (#1044)

A link code takes 5 wrong guesses on any of the user's Bots, then is void
(attempts column folded into the unreleased linked_identity migration).
Presses, typed answers and link codes act once per source id per Channel
account. linkFromCode takes the owner its caller already read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ages, webhook Approvals link to the app (#1044)

A delivery keeps where a Message's buttons were sent (buttonsOn on its
parts), so a typed answer edits them as a press does. A typed answer the
app refuses (a paused Bot) goes on as an ordinary Message. The Other wait
is per SDK instance and expires after 10 minutes. Approving a webhook
Routine on a Channel says so and links to the app for the URL, never the
token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…el, never to one without an adapter (#1044)

A reply follows the Turn's latest Trigger to any messaging adapter the Host
installed. A route or Trigger whose platform has no adapter can't be
honoured, so the defaults decide. The default "the Channel the user last
wrote from" includes a messaging Channel, and the Email popover's
nextChannel names it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A Turn whose latest user Trigger came from a messaging Channel that can't carry the reply now follows the notification routes, so an urgent Message isn't lost.
- Only a paused Bot or an already answered choice lets a typed answer go on as a Message. Other refusals are dropped with their reason.
- A failed send of the webhook-approval notice is logged, not thrown, so the approval isn't redone as "Already answered".
- An update's sourceId is remembered before it is handled, so concurrent copies act once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Spec: Messaging Channels 1, the seam, Linked identities and Telegram

1 participant