TermAl supports an experimental Telegram bot relay. The current implementation has one UI-configured bot with one linked Telegram chat; the target design is a small set of named bot profiles, each with its own token, chat binding, project subscriptions, defaults, and relay runtime. The normal path is UI-configured and runs inside the main backend process.
Parent feature: whatsapp-integration.md.
Implemented:
~/.termal/telegram-bot.json.config.botToken values migrated out of telegram-bot.json on read./projects and /project <id>./sessions and /session <name>.forwardAssistantReplies is enabled./approve, /reject, /continue, /fix,
/commit, /iterate, /stop, and /review.Not implemented yet:
Link chat button as a placeholder.@BotFather and copy the token.Test connection./start.The relay is part of the main TermAl backend. Saving enabled settings starts,
stops, or restarts the in-process relay from the saved configuration. Telegram
permits only one getUpdates poller per bot token, so run one TermAl backend
per configured bot token.
/projects lists subscribed projects and marks the active one./project <id> switches the active project./project default returns to the saved default project./sessions lists sessions for the active project by name, active first and
then by latest update./session <name> selects a session in the active project by exact name or id./session clear returns free text to the latest promptable root session in
the active project.Free text is sent to the selected session when one is set. Otherwise it goes to the latest promptable root session in the active project. The selected session is also tailed: assistant text produced from prompts typed directly in TermAl is forwarded back to Telegram after the message settles.
Project digests, inline digest controls, and digest action commands are temporarily disabled. Existing digest buttons are acknowledged as disabled and do not dispatch backend work.
The relay keeps a conservative boundary when Telegram free text is queued behind an already-active or approval-paused TermAl turn. While that older turn is still open, the relay treats the latest assistant text as a moving baseline and does not forward it to Telegram. On the first settled poll, if the tracked assistant message has already grown, the relay records the grown length as the baseline and waits for later growth or a later assistant message.
That means same-message reply text already present before the first settled poll is intentionally not forwarded for queued Telegram prompts. Forwarding it would risk sending output from the pre-existing local turn. Supporting that case requires a stronger per-turn boundary from the session or agent layer.
UI configuration is stored in the revisioned app state under
preferences.telegram, so settings saves publish ordinary state snapshots and
SSE updates. ~/.termal/telegram-bot.json remains the runtime metadata file and
contains a mirrored config block for relay interop and legacy migration. The
bot token itself is stored in the OS credential store under a TermAl service
entry scoped to the TermAl data directory. Existing plaintext config.botToken
values from older releases are migrated into the credential store and removed
from the JSON file the next time the Telegram settings are read or updated.
The UI config contains:
enabledsubscribedProjectIdsdefaultProjectIddefaultSessionIdforwardAssistantRepliesThe runtime state contains fields such as:
chatIdselectedProjectIdselectedSessionIdnextUpdateIdlastDigestHashlastDigestMessageIdThe full bot token is never returned through /api/telegram/status or persisted
back to telegram-bot.json; status responses expose only a masked suffix.
The multi-bot feature should treat a bot as a named route profile. Examples:
Personal, Work, Client A, or On-call. Each profile owns:
id generated by TermAl. IDs must be URL-safe and must not be
derived from the Telegram token.name.enabled.botTokenMasked.chatId in the first multi-bot version.subscribedProjectIds.defaultProjectId.defaultSessionId.selectedProjectId, selectedSessionId,
nextUpdateId, digest message/hash state, assistant-forwarding cursors, and
any future poll-health fields.The current singleton file should migrate forward without losing settings. The target JSON shape is:
{
"version": 2,
"bots": [
{
"id": "default",
"name": "Telegram",
"config": {
"enabled": true,
"subscribedProjectIds": ["project-id"],
"defaultProjectId": "project-id",
"defaultSessionId": null
},
"state": {
"chatId": 123456789,
"selectedProjectId": "project-id",
"selectedSessionId": null,
"nextUpdateId": 42
}
}
]
}
Token storage moves from one singleton keyring entry to per-bot entries:
telegram-bot-token:<data-dir-scope>telegram-bot-token:<data-dir-scope>:<bot-id>Migration rules:
telegram-bot.json has the current singleton config + flat runtime
state, convert it to one bot profile with id: "default" and
name: "Telegram".default bot key when
possible. If the move cannot be made atomically, copy first, persist the v2
file, then delete the old key only after the new key reads back correctly.botToken.The current relay runtime is singleton. Multi-bot support requires a supervised runtime map keyed by bot id:
getUpdates cursors isolated per bot. Sharing one cursor across bots is
invalid because Telegram update ids are scoped to each bot token.getUpdates poller per token; duplicate tokens should fail
validation with a user-facing error.The existing TelegramBotConfig can stay as the per-runtime value, but it needs
an added bot_id and a per-bot state_path or state accessor. The relay should
never write another bot’s runtime state during digest/cursor persistence.
Keep the current singleton routes temporarily for compatibility, but introduce bot-profile routes as the new contract:
| Method | Path | Purpose |
|---|---|---|
| GET | /api/telegram/status |
Return aggregate Telegram status with bots: TelegramBotStatus[]; during migration may also include the singleton-compatible fields for the default bot |
| POST | /api/telegram/bots |
Create a bot profile with name, optional token, project subscriptions, defaults, and enabled flag |
| PATCH | /api/telegram/bots/{bot_id} |
Update name, token, enabled flag, subscriptions, defaults, or clear token/defaults using nullable marker fields |
| DELETE | /api/telegram/bots/{bot_id} |
Disable relay, delete token, and remove the bot profile/runtime state |
| POST | /api/telegram/bots/{bot_id}/test |
Validate a supplied token or that bot’s saved token with Telegram getMe |
Response sketch:
type TelegramStatusResponse = {
bots: TelegramBotStatus[];
};
type TelegramBotStatus = {
id: string;
name: string;
configured: boolean;
enabled: boolean;
running: boolean;
lifecycle: "inProcess";
linkedChatId?: number | null;
botTokenMasked?: string | null;
subscribedProjectIds: string[];
defaultProjectId?: string | null;
defaultSessionId?: string | null;
};
PATCH fields should keep the existing tri-state convention:
null means “clear”List fields should keep the existing convention:
null means “leave unchanged”The Settings -> Telegram panel should become a profile list plus detail editor:
Add bot, Rename, Disable, and Remove actions.defaultSessionId, when present, must belong to the effective default project.Platform credential-store coverage is split intentionally:
keyring_core::mock so they are deterministic and do
not write secrets to the developer machine.telegram_bot_token_native_credential_store_round_trips writes and deletes a
disposable entry in the real OS credential store through the same platform
store-selection helper used by production initialization. Run it explicitly on
Windows, macOS, or Linux with:cargo test --bin termal telegram_bot_token_native_credential_store_round_trips -- --ignored
Linux runs require a usable desktop Secret Service/keyring session.
Current routes:
Telegram uses a focused API surface instead of the generic POST /api/settings
route because the settings response must include relay lifecycle state and a
masked token without ever placing secret token material in the normal
StateResponse / SSE snapshot stream. The test route is also intentionally
separate because it performs an outbound Telegram getMe check without saving
configuration. The remaining config route returns a sanitized
TelegramStatusResponse so clients can replace their local Telegram settings
view from one response.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/telegram/status |
Read configured/enabled/running state, lifecycle, linked chat, masked token, subscribed projects, and defaults |
| POST | /api/telegram/config |
Update token in the OS credential store, enabled flag, subscriptions, and defaults |
| POST | /api/telegram/test |
Validate a supplied or saved token with Telegram getMe |
These routes currently operate on the singleton bot. Multi-bot work should add the bot-profile routes listed in the target spec before changing the settings UI to create multiple profiles.
The relay itself uses existing TermAl routes:
| Method | Path | Purpose |
|---|---|---|
| GET | /api/state |
Read projects and sessions for /projects, /sessions, and selected-session validation |
| GET | /api/sessions/{id} |
Read settled assistant messages for forwarding |
| POST | /api/sessions/{id}/messages |
Forward Telegram free text into TermAl |
Telegram endpoints return the standard TermAl API error shape, { "error":
"..." }, with a human-readable diagnostic. Treat that message as
presentation text, not a stable discriminator. In particular,
/api/telegram/test can return 422 for both local config validation failures
and Telegram getMe validation/auth failures; clients should present the
message and branch on request context or status, not parse English text.
Config validation also checks that referenced projects/sessions still exist
before checking default-project membership, so orphaned defaults can report
unknown ... project/session wording instead of an older membership-specific
message.
POST /api/telegram/config returns the sanitized current settings after the
patch is applied, not an echo of request fields. Omitted or null patch fields
leave the matching setting unchanged, but stale persisted project/session
references can still be scrubbed from the response when they no longer exist.
Clients should replace local Telegram settings state with the response instead
of diffing request fields against response fields.
<pre> table. Telegram
Bot API HTML does not support real <table> markup, so future options
include generated PNG/SVG snapshots or attached HTML files for wider tables
and richer report layouts.