Telegram Per-OSA Bot with Persona-Templated Identity Model
Status: Draft (subdocument of ADR-030) Date: 2026-06-03 Issue: #1471 Parent ADRs: ADR-007, ADR-020 (this design extends row 6 of the matrix for Telegram) Implementation tracking: dedicated P2 follow-up issue (to be opened post-acceptance)
1. Product invariantsβ
Three rules govern Telegram at h.work. They are h.work product decisions, not Telegram platform requirements.
Invariant I-1 β Specialist owns a Telegram persona templateβ
Every Specialist that engages with Telegram has exactly one primary Telegram persona registered in specialist_channel_personas. Unlike Slack's App or WhatsApp's WABA, Telegram's L2 has no corresponding third-party object β Telegram has no app store, no verified-business registration, and no developer-account-level container for bots. L2 on Telegram is therefore an internal record that holds:
- Username naming pattern (e.g.,
@{firstname_lower}_{org_slug}_hp_bot) - Description / short_description templates
- Default commands list
- Default privacy mode (per-bot setting controlled at BotFather creation time)
- Default group-add permissions
- Avatar source reference (
specialists.avatar_url) - Operational ownership boundary
The reason this row exists at all is to keep Telegram symmetric with Slack/WhatsApp in the schema and to give provisioning a deterministic template per Specialist.
A Specialist MAY also have zero or more secondary personas for limited reasons (staging / migration); see Β§11.
Invariant I-2 β Every OSA gets a dedicated Telegram botβ
By default, each (Org Γ Specialist) assignment β an OSA β has one dedicated Telegram bot created via BotFather, with:
- A unique
@usernamederived from the L2 naming pattern + Org slug - An independent bot token (from BotFather)
- An independent webhook URL
- An independent profile (name / description / avatar, populated from Specialist identity)
The bot's token is the credential and the routing key for that OSA.
Invariant I-3 β Inbound (token_hash, bot_id) resolves exactly one OSAβ
Every Telegram inbound update resolves to exactly one OSA via single-row lookup. The routing key is the URL-path-encoded token_hash (SHA-256 prefix of the bot token) plus the secret_token header check; from the resolved endpoint the platform derives:
org_idspecialist_idchannel_endpoint_idconversationscope (per ADR-020 row 1)- RLS GUC values (
app.current_org_id,app.current_specialist_id)
There is no second-step routing in the default model. The bot's @username is human-facing; routing is done by token hash so the username can change without breaking routing.
2. Display identity modelβ
Telegram surfaces several identity fields on a bot. All can be set via the Bot API except the profile photo, which requires user-mode MTProto access (see Β§6).
2.1 Bot username (one per OSA)β
Bot username = "@{Specialist.firstName | lower}_{Org.slug | lower}_hp_bot"
example: "@kai_acme_hp_bot"
Telegram bot usernames are globally unique across all of Telegram. The naming pattern minimizes collisions but does not eliminate them. Provisioning must handle the case where the desired username is taken (see Β§9.2).
The bot's @username is what customers click via t.me/<username> to start a conversation. It surfaces in their chat list, contact card, and link previews.
2.2 Bot name (one per OSA)β
Bot first name = "{Specialist.firstName}"
example: "Kai"
This is the display name in the customer's chat list. Default is just the Specialist's first name β keeping recognition consistent across Orgs. Optional per-OSA override stored on the endpoint row (e.g., "Acme KYC") for customers who want Org-scoped naming.
Updated via Bot API setMyName β immediate effect, no review.
2.3 Bot description and short descriptionβ
Bot description = specialists.description (0-512 chars per setMyDescription)
shown in the empty-chat-with-bot screen (the "what this bot
does" surface a user sees before the first message)
Bot short_description = computed from specialists.tagline or first sentence
of specialists.description (0-120 chars per setMyShortDescription)
shown on the bot's profile page and included when the
bot link is shared
Updated via Bot API setMyDescription / setMyShortDescription. Both methods are language-aware (Telegram supports per-locale descriptions); MVP uses default locale.
2.4 Bot commandsβ
Bot commands = template from L2.metadata.default_commands
example: [
{ command: "start", description: "Begin a conversation" },
{ command: "help", description: "Show available actions" },
...
]
Updated via setMyCommands (up to 100 commands per scope). Specialist-domain-specific commands can be templated at L2 level.
2.5 Bot profile photo (Bot API)β
Bot profile photo = specialists.avatar_url
Telegram Bot API 10.0 (released 2026-05-08) added setMyProfilePhoto and removeMyProfilePhoto, which let a bot manage its own profile photo through the Bot API. This is the primary path for h.work avatar sync.
Constraints on setMyProfilePhoto:
- Static profile photos must be JPG (per Telegram's
InputProfilePhotoStaticschema) - Photos must be uploaded as fresh multipart file assets β a previously-uploaded
file_idcannot be reused - Subject to Bot API rate limits and Telegram's general anti-abuse heuristics
If setMyProfilePhoto fails in a given environment for any reason (e.g., regional API differences, transient Bot API errors, future Telegram restrictions), the platform falls back to either manual operator setting via @BotFather /setuserpic or to MTProto-based upload through the optional MTProto worker (see Β§9.0). The fallback is optional, not part of MVP.
2.6 Profile fields summaryβ
| Field | Source | Update mechanism | Per-Specialist or per-OSA |
|---|---|---|---|
| Username | naming pattern + Org slug | /setusername via BotFather (one-time at creation) | per-OSA |
| First name | specialists.firstName (default) | setMyName Bot API (0-64 chars) | per-OSA, sourced from L1 |
| Description | specialists.description | setMyDescription Bot API (0-512 chars) | per-OSA, sourced from L1 |
| Short description | derived from L1 | setMyShortDescription Bot API (0-120 chars) | per-OSA, sourced from L1 |
| Commands | L2 template | setMyCommands Bot API (up to 100) | per-OSA, templated at L2 |
| Profile photo | specialists.avatar_url | setMyProfilePhoto Bot API (JPG, fresh upload) | per-OSA, sourced from L1 |
| Privacy mode | L2 default | /setprivacy via BotFather (privacy default enabled) | per-OSA, templated at L2 |
| Can-join-groups | L2 default | /setjoingroups via BotFather | per-OSA, templated at L2 |
| Bot token | issued by BotFather (or Managed Bots flow) | /token (read) or /revoke (rotate) via BotFather | per-OSA |
| Webhook URL | h.work-controlled (ports 443 / 80 / 88 / 8443) | setWebhook Bot API | per-OSA |
Webhook secret_token | h.work-generated | passed at setWebhook; received as X-Telegram-Bot-Api-Secret-Token header. 1-256 chars from charset A-Z a-z 0-9 _ -. | per-OSA |
Identity rule: the Specialist row is the source for firstName, avatar_url, description. Identity-related changes trigger sync to every provisioned Telegram endpoint via Bot API. All identity fields β including avatar β sync without MTProto in the default model. See Β§6.
3. Architecture overviewβ
Telegram (user-facing surface)
β
βββ h.work bot creation control plane
(Managed Bots manager bot β preferred β OR a fallback Telegram user account
scripted via MTProto, depending on the outcome of the Β§9.0 feasibility
spike. In both cases, h.work is the owner of the resulting per-OSA bots.)
β
βββ Persona: Kai (Specialist = Kai, Domain = KYC)
β L2 record only β no Telegram-native object
β username_pattern = "@kai_{org_slug}_hp_bot"
β role = primary
β βββ @kai_acme_hp_bot β OSA(Kai, Acme) bot_id = 5111111111
β βββ @kai_bigcorp_hp_bot β OSA(Kai, BigCorp) bot_id = 5111111112
β βββ @kai_datacorp_hp_bot β OSA(Kai, DataCorp) bot_id = 5111111113
β
βββ Persona: Mei (Specialist = Mei, Domain = Finance)
β username_pattern = "@mei_{org_slug}_hp_bot"
β βββ @mei_acme_hp_bot β OSA(Mei, Acme)
β βββ @mei_shopco_hp_bot β OSA(Mei, ShopCo)
β
βββ Persona: Lin (Specialist = Lin, Domain = E-commerce)
βββ @lin_bigcorp_hp_bot β OSA(Lin, BigCorp)
βββ @lin_shopco_hp_bot β OSA(Lin, ShopCo)
Cardinality:
| Object | Count formula | Bound |
|---|---|---|
| TG ops user account | 1 platform-wide | h.work internal infrastructure |
| Persona (primary) | M = number of Specialists with Telegram | one per Specialist |
| Persona (secondary) | 0..K per Specialist | exception-driven (staging / migration) |
| Bot | sum(OSAs per Specialist with Telegram enabled) | one per OSA |
| Endpoint row | identical to bot count | one per OSA |
Note: Acme's customers may end up with multiple "Acme via h.work" bots in their Telegram chat list (one per Specialist Acme has hired). That is expected and product-acceptable; the bot first-name distinguishes (Kai vs Mei).
4. Inbound routingβ
Telegram POSTs every update to the bot's configured webhook URL. We embed a token hash in the URL path (not the token itself β never log tokens) and require Telegram's secret_token header for double-verification:
Customer (Bob) β DMs @kai_bigcorp_hp_bot in Telegram
β
βΌ
Telegram β POST https://api.h.work/v1/channels/telegram/webhook/{token_hash}
path: /webhook/a1b2c3d4e5f6... (first 32 chars of SHA-256(bot_token))
body: { update_id: 12345, message: { from, chat, text, ... } }
header X-Telegram-Bot-Api-Secret-Token: <per-endpoint secret>
β
βΌ
TelegramController
1. Extract token_hash from URL path
2. Resolve endpoint:
SELECT *
FROM osa_channel_endpoints
WHERE channel_type = 'telegram'
AND metadata->>'token_hash' = :token_hash
AND provisioning_status = 'provisioned'
AND revoked_at IS NULL;
3. Load endpoint's webhook_secret_ref from Secrets Manager
4. Compare header X-Telegram-Bot-Api-Secret-Token == webhook_secret
Reject with 401 on mismatch
5. InboundDedupService.check('telegram', endpoint.id, body.update_id)
-- update_id is unique only WITHIN a single bot's update stream;
-- two different OSA bots may legitimately produce the same update_id.
-- The dedup key must therefore be scoped per endpoint (or per bot_id).
6. Enqueue normalized event to BullMQ (telegram-inbound queue) with endpoint_id
7. Return HTTP 200 immediately (Telegram retries on non-2xx)
β
βΌ
(BullMQ worker, async)
TelegramInboundProcessor
1. Derive (org_id, specialist_id) from endpoint.osa_id
2. rls.setBoth({ org_id, specialist_id })
3. Customer.findOrCreate({ org_id, external_id: 'telegram:'+from.id, channel: 'telegram' })
4. Conversation.findOrCreateByChannel(...)
5. Message saved β agent dispatch β expert queue
HTTP response policy:
| Condition | Response |
|---|---|
| Endpoint not found by token_hash | 200 + structured log (Telegram would retry on 4xx/5xx β accept and drop) |
secret_token header missing or mismatch | 401 (treat as forged) |
| Endpoint found + provisioned + ACK enqueued | 200 |
| Endpoint suspended/revoked | 200 + log orphan event (avoid retry storm) |
| Internal worker error after ACK | already returned 200; processor handles via DLQ |
Invariant check (enforced in inbound handler unit tests):
expect(endpoint.specialist_id).toBe(conversation.specialist_id);
expect(endpoint.org_id).toBe(conversation.org_id);
expect(sha256(creds.bot_token).slice(0, 32)).toBe(urlPathTokenHash);
Why token_hash in URL, not token: tokens land in Sentry breadcrumbs, nginx access logs, browser history, and reverse-proxy headers when used in URL paths. A SHA-256 prefix is a stable opaque identifier that lets us reverse-look up the endpoint without exposing the secret.
5. Outbound dispatchβ
async dispatch(osaId: string, payload: TelegramPayload): Promise<void> {
const endpoint = await endpoints.findActive({ osaId, channel_type: 'telegram' });
if (!endpoint) throw new ChannelNotProvisionedError('telegram', osaId);
const creds = await secretsManager.get(endpoint.credentials_secret_ref);
// creds = { bot_token: 'NNNNNNNNNN:AAA...' }
// Validate before send:
// - text length 1..4096 chars after entities parsing
// - chat_id may be numeric (private chat / group) or @username target
if (payload.text.length > 4096) {
throw new MessageTooLongError('telegram', payload.text.length);
}
await withCircuit('telegram', () =>
withRetry(() =>
fetch(`https://api.telegram.org/bot${creds.bot_token}/sendMessage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: payload.chat_id,
text: payload.text,
parse_mode: payload.parse_mode ?? 'HTML',
reply_to_message_id: payload.reply_to_message_id,
}),
}),
{
maxAttempts: 3,
baseDelayMs: 200,
label: 'dispatch.telegram',
// On 429 Telegram returns { ok: false, error_code: 429, parameters: { retry_after: N } }
// β honor the retry_after value rather than using fixed backoff.
respectRetryAfter: true,
},
),
);
}
Customer's Telegram client renders the sender as the bot's configured name + avatar (set at provisioning via Β§6).
Message length: text payload is 1-4096 characters after entities parsing. Longer payloads must be split by the application layer; ChannelDispatcher does not implicitly split.
Chat target: chat_id accepts numeric private chat / group / channel IDs, and @username target for channels and supergroups. For per-OSA customer DM flow, numeric chat IDs are the norm.
Delivery feedback: Telegram does not natively push delivered/read callbacks for bot messages. delivery_status on the resulting Message row stays at sent after a successful API response. This is a Telegram-platform limit, not a design choice.
Rate limits β operational defaults, not protocol guarantees:
Telegram enforces flood-control limits per bot, per chat, and per group. The official Telegram documentation deliberately does not publish fixed numeric guarantees and advises implementing adaptive throttling and retry rather than hard-coding limits. Community-known defaults (e.g., ~30 msg/sec across chats, ~1/sec same user, ~20/min same group) MAY be used as conservative initial throttle config but MUST NOT be treated as protocol guarantees.
ChannelDispatcher requirements:
- Per-endpoint adaptive throttle config (default values configurable, not hard-coded)
- Honor
429 Too Many Requestsresponses with theretry_afterparameter from Telegram - Serialize sends to the same
chat_id(low-concurrency lane) to avoid per-chat flood limits - Surface persistent throttle hits in operational dashboards
Per-bot isolation is a natural property of this design: an OSA experiencing high traffic does not consume another OSA's rate-limit budget because each OSA has its own bot.
6. Identity sync jobβ
All identity fields β including avatar β sync via the Bot API in the default model. There is no MTProto dependency in the MVP sync path.
@OnEvent('specialist.updated')
async onSpecialistChanged({ specialistId, changedFields }: SpecialistChange) {
if (!intersects(changedFields, IDENTITY_FIELDS)) return;
const endpoints = await endpoints.findAll({
channel_type: 'telegram',
osa: { specialist_id: specialistId },
provisioning_status: 'provisioned',
revoked_at: IsNull(),
});
for (const ep of endpoints) {
await channelSyncQueue.add('sync-telegram', {
endpointId: ep.id,
changedFields,
}, {
jobId: `tg-sync-${ep.id}`,
attempts: 5,
backoff: { type: 'exponential', delay: 60_000 },
});
}
}
The sync processor handles all fields via Bot API:
| Field | Method | Notes |
|---|---|---|
| First name | setMyName | 0-64 chars |
| Description | setMyDescription | 0-512 chars, shown in empty-chat-with-bot |
| Short description | setMyShortDescription | 0-120 chars, shown on profile + shared links |
| Commands | setMyCommands | up to 100 commands |
| Profile photo | setMyProfilePhoto | JPG, fresh multipart upload; cannot reuse a previous file_id |
Per-field result is recorded in osa_channel_endpoints.profile_sync_state:
{
"first_name": { "status": "synced", "last_synced_at": "...", "method": "bot_api.setMyName" },
"description": { "status": "synced", "last_synced_at": "...", "method": "bot_api.setMyDescription" },
"short_description": { "status": "synced", "last_synced_at": "...", "method": "bot_api.setMyShortDescription" },
"commands": { "status": "synced", "last_synced_at": "...", "method": "bot_api.setMyCommands" },
"avatar": {
"status": "synced",
"last_synced_at": "...",
"method": "bot_api.setMyProfilePhoto",
"last_uploaded_asset_hash": "sha256:..."
}
}
Avatar fallback (optional, not MVP)β
If setMyProfilePhoto is unavailable or fails persistently in a given environment, two fallback paths exist (both outside MVP):
- Manual via @BotFather β operator runs
/setuserpic. Documented in the ops runbook. - MTProto-based upload β through the optional MTProto worker (Β§9.0). Only required if the platform decides to operate one for other reasons.
The MVP does not depend on either fallback.
Failure handlingβ
Sync failures do not demote provisioning_status. The endpoint continues to serve traffic with its previously-configured identity. Persistent failed surfaces in the ops dashboard.
Rate limit awarenessβ
Sync calls share the bot's outbound rate-limit budget with chat.postMessage traffic. The sync processor reads endpoint-level throttle config and respects Telegram 429 Too Many Requests responses with the indicated retry_after value. See Β§5 on rate limits.
7. Data modelβ
Reuses the shared tables from the parent ADR (specialist_channel_personas, osa_channel_endpoints, channel_pricing_rate_cards). Telegram-specific column usage:
-- =========================================================================
-- L2: Specialist's Telegram persona template (one primary per Specialist)
-- Shared schema with WhatsApp/Slack (see whatsapp.md Β§7)
-- =========================================================================
-- Telegram-specific column usage on specialist_channel_personas:
-- channel_type = 'telegram'
-- provider = 'telegram'
-- external_app_id = NULL (Telegram has no L2 third-party object)
-- verified_business_name = NULL (no verification concept)
-- verification_status = 'n/a' (no review path on Telegram)
-- provisioning_status = 'provisioned' as soon as the L2 row is written
-- metadata.username_pattern = '@{firstname_lower}_{org_slug}_hp_bot'
-- metadata.description_template = "I am {firstName}, your AI Specialist..."
-- metadata.short_description_template = derived format
-- metadata.default_commands = JSON array of {command, description}
-- metadata.default_privacy_mode = 'enabled' (bot sees only direct mentions in groups)
-- | 'disabled' (bot sees all group messages)
-- metadata.default_can_join_groups = boolean
-- metadata.naming_collision_strategy = 'numeric_suffix' | 'random_suffix' | 'manual'
-- =========================================================================
-- L3: Per-OSA Telegram bot
-- Shared schema with WhatsApp/Slack (see whatsapp.md Β§7)
-- =========================================================================
-- Telegram-specific column usage on osa_channel_endpoints:
-- channel_type = 'telegram'
-- provider = 'telegram'
-- external_id = '@kai_acme_hp_bot' -- username including @
-- credentials_secret_ref = Secrets Manager ARN, value = { bot_token: 'NNN:AAA...' }
-- webhook_secret_ref = Secrets Manager ARN, value = { secret_token: '...' }
-- (this is the X-Telegram-Bot-Api-Secret-Token value)
-- sender_display_name = bot first_name set in Telegram (defaults to Specialist.firstName)
-- metadata.bot_id = Telegram numeric bot_id (returned by getMe at provisioning)
-- metadata.username = '@kai_acme_hp_bot' (mirrored from external_id for clarity)
-- metadata.token_hash = first 32 chars of SHA-256(bot_token) β webhook URL routing key
-- metadata.privacy_mode = 'enabled' | 'disabled' (per-bot)
-- metadata.can_join_groups = boolean
-- metadata.commands_set = current commands list (for diff with L2 template)
-- metadata.creation_method = 'managed_bots' | 'mtproto_botfather' | 'manual_ops'
-- -- which path was used to create this bot
Inbound reverse-lookup indexesβ
-- token_hash is the webhook URL routing key β primary lookup on inbound
CREATE UNIQUE INDEX uniq_telegram_endpoint_token_hash
ON osa_channel_endpoints ((metadata->>'token_hash'))
WHERE channel_type = 'telegram' AND revoked_at IS NULL;
-- bot_id is the stable machine identity β username and token both may change
-- (username via /setusername, token via /revoke); bot_id does not.
-- Use this index for cross-channel joins and audit queries.
CREATE UNIQUE INDEX uniq_telegram_endpoint_bot_id
ON osa_channel_endpoints ((metadata->>'bot_id'))
WHERE channel_type = 'telegram' AND revoked_at IS NULL;
-- username uniqueness within the active set (Telegram enforces global uniqueness;
-- this index catches local conflicts during provisioning)
CREATE UNIQUE INDEX uniq_telegram_endpoint_username
ON osa_channel_endpoints (external_id)
WHERE channel_type = 'telegram' AND revoked_at IS NULL;
Inbound dedup tableβ
The shared inbound dedup table must scope by endpoint for Telegram, because update_id is unique only within a single bot's update stream:
-- Dedup key for Telegram: (channel, endpoint_id, update_id)
-- Reusing the parent dedup schema with composite vendor_key:
-- vendor_key = endpoint_id::text || ':' || update_id::text
-- A global ('telegram', update_id) key WOULD cause cross-bot false-positive
-- duplicates between different OSAs.
Schema notesβ
external_id = @usernameis the human-facing handle;token_hashis the webhook routing key;bot_idis the stable machine identity.- Bot username can change via BotFather (
/setusername); theexternal_idrow would be updated andtoken_hashis not affected (token doesn't change with username). Changing username breaks customer chat links β treat as rare event. - Token rotation via BotFather
/revoke(or via Managed BotsreplaceManagedBotToken) issues a new token and invalidates the old one. New token β newtoken_hashβ new webhook URL must be re-set viasetWebhook. Bothcredentials_secret_refandmetadata.token_hashupdate atomically.bot_idis preserved through rotation. - The parent ADR's global
(channel_type, external_id)uniqueness applies cleanly to Telegram (usernames are globally unique on Telegram).
8. State machinesβ
8.1 Persona (Telegram L2) lifecycleβ
Telegram's L2 has no third-party object, so the persona lifecycle is trivial:
created
β
βΌ
provisioned (L2 row written with naming pattern + templates; immediately usable)
β
βΌ
deprecated (no new endpoints allowed; existing endpoints continue)
There is no in_review, no verified, no Meta/Slack equivalent. The persona is just a config row.
8.2 Endpoint (per-OSA bot) lifecycleβ
pending
β
βΌ
creation_requested (job dispatched to the active control plane:
Managed Bots / MTProto BotFather / manual ops)
β
βΌ
bot_created (control plane returned token; bot_id + @username captured)
β
βΌ
token_persisted (bot_token + webhook secret_token in Secrets Manager;
metadata.token_hash + metadata.bot_id filled in)
β
βΌ
profile_configured (Bot API: setMyName, setMyDescription,
setMyShortDescription, setMyCommands, setMyProfilePhoto.
Privacy mode + join-groups via active control plane
if programmatic; otherwise deferred + reconciled async.)
β
βΌ
webhook_configured (setWebhook called with URL + secret_token;
getWebhookInfo confirms healthy delivery target)
β
βΌ
provisioned ββββΊ suspended (token compromise, customer pause, ops freeze)
β
βΌ
revoked (token revoked via active control plane; webhook removed)
Profile-photo sync runs as part of profile_configured step via Bot API setMyProfilePhoto. Sync failure does not demote the endpoint; it stays provisioned with profile_sync_state.avatar.status = failed for ops retry.
Failure & recovery:
- Bot creation failure (e.g., username taken) β naming collision strategy from L2 template (see Β§9.2)
- Token persistence failure β endpoint moves to
failed; manual ops intervention - Profile config failure on a specific field β endpoint may still reach
provisioned;profile_sync_staterecords the field-level failure for async retry - Webhook config failure β endpoint stays in
profile_configured; retry - Customer initiates
/stopon the bot (Telegram limits bot from sending more messages to that user) β conversation-level state, not endpoint state; endpoint remainsprovisioned - Token invalidated externally (Telegram support action, control-plane revoke, customer initiates uninstall via @BotFather) β endpoint moves to
suspended; reissue token via the active control plane if customer relationship continues
9. Provisioning flowβ
9.0 Bot creation control planeβ
Telegram bot creation needs an interaction with @BotFather (a Telegram user-mode interaction the public Bot API cannot perform directly on the legacy path). h.work evaluates two paths for this control plane; the choice is gated by a feasibility spike (see Β§16 Phase 3) and the design accommodates either outcome.
Path A β Managed Bots (preferred official path)β
Telegram Bot API 9.6 introduced Managed Bots: a Bot API surface for creating and managing bots through a designated "manager bot". Relevant capabilities:
can_manage_botsprivilege on the manager botmanaged_botupdate type delivered to the manager bot when a managed bot is created or has its token / owner changedgetManagedBotTokento fetch a managed bot's current tokenreplaceManagedBotTokento rotate a managed bot's token (replaces MTProto/revoke)- Creation link:
https://t.me/newbot/{manager_bot_username}/{suggested_bot_username}?name={suggested_bot_name}β opens a Telegram client flow that creates a bot owned/manageable by the manager bot
If Managed Bots can satisfy h.work-owned per-OSA bot creation with acceptable ops UX, it becomes the canonical control plane:
Service: tg-bot-control plane (lives inside the existing API service; no new microservice)
Manager: one Telegram manager bot per environment, with can_manage_bots privilege,
identity registered in specialist_channel_personas as a system-level
persona (not a Specialist).
Surface: internal HTTP / BullMQ consumer for: create / rotate-token / delete
Webhook: the manager bot's webhook receives `managed_bot` updates whenever a
managed (per-OSA) bot is created or its token changes; the platform
reconciles these into osa_channel_endpoints.
Failure: if manager bot or its update path is degraded, new provisioning
stalls; existing per-OSA bots are unaffected (they operate
independently via Bot API).
Path B β MTProto BotFather automation (fallback, only if Managed Bots is insufficient)β
If the Managed Bots flow cannot satisfy the per-OSA model (e.g., it cannot establish h.work as the bot owner without an interactive human step, or the API surface is too limited for the operations needed), h.work falls back to a user-mode MTProto worker that scripts @BotFather:
Service: tg-mtproto-worker (separate Python service, only if needed)
Runtime: Python with pyrogram or telethon
Auth: MTProto session as a designated h.work user account
(phone-auth-based; session stored encrypted in Secrets Manager)
Surface: BullMQ consumer for BotFather operations
Status: UNOFFICIAL & OPERATIONALLY FRAGILE β flagged as such in the runbook.
Telegram may at any time apply anti-abuse heuristics to user accounts
that mass-create bots. The session can be invalidated by Telegram
support, by user-account 2FA reset, or by Telegram-side policy.
Mitigations: manual recovery runbook; 2FA cloud password stored in Secrets
Manager; emergency contact path to Telegram support; audit log
on every BotFather interaction.
Important: avatar sync is not a reason to operate the MTProto worker. With setMyProfilePhoto available via Bot API 10.0, the avatar path is covered entirely by the Bot API control plane (Β§6). The MTProto worker, if introduced, exists solely for bot creation and is not on the identity-sync critical path.
Path C β Manual ops (always available)β
A platform operator can use the standard Telegram client to interact with @BotFather and complete bot creation manually, then paste the token into an admin form. This is the universal safety net and is documented for incident scenarios.
Decision sequenceβ
The implementation order is:
- Build the per-OSA bot lifecycle on Bot API only (sendMessage, setMyName, setMyDescription, setMyShortDescription, setMyCommands, setMyProfilePhoto, setWebhook). This works regardless of which creation path is chosen.
- Spike Managed Bots feasibility (Β§16 Phase 3).
- If Managed Bots is sufficient β wire as Path A.
- If not β build MTProto worker as Path B, with explicit fragility framing in the runbook.
- Always retain Path C (manual ops) as the documented recovery path.
The MVP scope does not assume Path B is required.
9.1 One-time per Specialist (persona record)β
Trigger: SuperAdmin creates Specialist + opts in to Telegram
1. Create specialist_channel_personas row with:
channel_type = 'telegram'
metadata.username_pattern = '@{firstname_lower}_{org_slug}_hp_bot'
metadata.description_template = '...'
metadata.short_description_template = '...'
metadata.default_commands = [...]
metadata.default_privacy_mode = 'enabled'
metadata.default_can_join_groups = false
metadata.naming_collision_strategy = 'numeric_suffix'
2. provisioning_status = 'provisioned' (immediately β no external call required)
3. Persona is ready to act as parent for OSA endpoint provisioning
9.2 Per-OSA (bot creation)β
Trigger: AM enables Telegram for OSA (e.g., Kai-for-Acme)
1. Pre-check: persona exists with status='provisioned'
2. Compute desired_username = persona.username_pattern with substitutions
example: "@kai_acme_hp_bot"
3. Create osa_channel_endpoints row (provisioning_status = 'pending')
4. Bot creation β via the active control plane (see Β§9.0):
Path A (Managed Bots, preferred):
- h.work manager bot exposes a managed bot creation flow per the Bot API
9.6 Managed Bots surface.
- On success, the manager bot's webhook receives a `managed_bot` update
containing the new bot's identity (bot_id, username, token).
- Apply collision strategy on the desired_username before requesting
creation; on collision retry with a suffixed candidate.
Path B (MTProto BotFather, fallback if Path A is insufficient):
- Enqueue tg.botfather.create_bot job to the MTProto worker with
{ endpoint_id, desired_username, bot_display_name, persona_id }
- Worker scripts @BotFather: /newbot β name β username
(collision strategy applied per persona.metadata.naming_collision_strategy)
- Worker captures the token from BotFather's response.
Path C (Manual ops, recovery only):
- Operator pastes a token + username produced via direct BotFather
interaction into an admin form. Same downstream steps as A/B.
5. Persist credentials:
- Compute token_hash = sha256(token)[:32]
- Store bot_token + generated webhook secret_token in Secrets Manager
- Update osa_channel_endpoints.metadata.bot_id, .username, .token_hash,
.creation_method, .token_rotation_supported
β status = 'bot_created' β 'token_persisted'
6. Configure bot settings:
- Privacy mode and group-join behavior:
* If Managed Bots provides programmatic settings: use that surface.
* Otherwise: defer to BotFather (Path B) or manual ops (Path C).
- This step is allowed to be deferred to a follow-up job; the endpoint
can reach 'provisioned' with default Telegram settings, then have
settings reconciled to the L2 template asynchronously.
7. Configure bot profile via Bot API:
POST setMyName { name: bot_display_name }
POST setMyDescription { description: rendered from L1 + L2 template }
POST setMyShortDescription { short_description: ... }
POST setMyCommands { commands: persona.default_commands }
POST setMyProfilePhoto { photo: <multipart, JPG of Specialist.avatar_url> }
8. Configure webhook via Bot API:
POST setWebhook
url: https://api.h.work/v1/channels/telegram/webhook/{token_hash}
secret_token: {generated, stored in webhook_secret_ref;
1-256 chars from charset A-Z a-z 0-9 _ -}
allowed_updates: ["message", "edited_message", "callback_query", "my_chat_member"]
-- my_chat_member catches user block/unblock and
-- group-member changes (bot added/removed from groups)
drop_pending_updates: true
max_connections: <optional, default per Telegram>
β status = 'webhook_configured'
9. Verify webhook health:
POST getWebhookInfo
-- confirm last_error_date is null or recent_only,
-- pending_update_count is sane, ip_address resolves to expected host.
10. Final transition: 'provisioned'
11. Emit channel.endpoint.provisioned event
12. AM hands customer a link: t.me/<username>
All vendor API calls go through withCircuit + withRetry. getWebhookInfo is the operational health check for inbound delivery.
Manager bot webhook (Path A only):
If Managed Bots is the active control plane, the manager bot must subscribe to the managed_bot update type:
Manager bot setWebhook:
allowed_updates: ["managed_bot", "message"]
-- managed_bot is delivered when a managed bot is created, its token is
-- changed (replaceManagedBotToken), or ownership is transferred.
The manager bot's inbound handler reconciles managed_bot updates into osa_channel_endpoints (specifically updating metadata.bot_id and rotated tokens after replaceManagedBotToken).
9.3 Token rotationβ
Trigger: scheduled rotation, token compromise, ops request
Path A (Managed Bots, if active control plane):
1. Manager bot: replaceManagedBotToken with new token for the target bot
2. New token returned; old token invalidated
3. Update Secrets Manager + endpoint.metadata.token_hash atomically
4. Call setWebhook on new URL (containing new token_hash) with existing secret_token
5. Audit log entry; profile_sync_state recorded
Path B (BotFather via MTProto):
1. Worker: /token β select bot β /revoke
2. BotFather returns new token; capture and proceed as in A.3-A.5
Path C (manual):
1. Operator runs /revoke in BotFather, pastes new token into admin form.
Username remains stable across token rotation. Customer chat links unaffected.
9.4 Revocationβ
Trigger: OSA termination, customer churn
1. revoked_at = now()
2. Bot API: POST deleteWebhook (stop receiving updates)
3. Optional: delete the bot via the active control plane (Path A / B / C).
Alternatively, invalidate the token but keep the bot record at Telegram for
audit. Choice is policy-driven.
4. Mark Secrets Manager secret for deletion (30-day soft delete)
5. Endpoint row preserved for audit; lifecycle events for this token_hash
received post-revocation return 200 + log orphan
6. If bot is deleted, username returns to Telegram pool after Telegram's
reuse cooldown (may be re-used by other apps).
10. Out of scope (deferred to other ADRs)β
| Feature | Where | Why deferred |
|---|---|---|
| Org-shared front-door bot (one bot per Org with topic routing to multiple Specialists) | ADR-028 (future) | Same reasons as Slack/WhatsApp front-door. Requires topic classifier. |
| Topic classifier subsystem | ADR-028 | β |
| Group chat / channel administration features | Out of MVP scope | Bots can be added to groups today; admin features (kick, ban, manage messages) require additional scopes and are not part of customer-conversation flow. |
| Telegram Mini Apps / Web Apps | Future product decision | Different UX surface; not in the messaging credential isolation scope. |
| Telegram Premium / Business interop | Out of MVP scope | Customer's Telegram account features; orthogonal to bot identity. |
Discovery bot (e.g., @kai_hp_bot that redirects to per-OSA bot) | Optional polish | Could be added later as a per-Specialist convenience bot; not load-bearing. |
11. Secondary persona exceptionβ
A Specialist MAY have additional Telegram personas beyond the primary, with limited use cases:
| Reason | shard_key value | Use case |
|---|---|---|
| Staging | env:staging | Separate persona template for staging-env bots (different webhook host, different ops user) |
| Migration | migration:<reason> | Changing username naming convention without breaking existing bots |
| Test | env:test | Test-suite bots created on demand |
Rules:
- Each Specialist has exactly one primary persona (
role='primary',shard_key IS NULL). Enforced by partial unique index. - Secondary personas do not change Specialist identity ownership; first_name/avatar/description still flow from L1.
- Unlike WhatsApp (WABA capacity) or Slack (Marketplace separation), Telegram has no platform-imposed reason to need a secondary persona β the cases above are operational, not vendor-driven.
12. Cost modelβ
Telegram is free for both the platform side and the customer side:
- Bot creation: free, unlimited (subject to Telegram-side anti-abuse heuristics on the active control plane)
- API calls: free, subject to per-bot rate limits
- Webhook delivery: free, Telegram handles infra
- Storage: bot data lives on Telegram's side
The only h.work-side cost dimensions:
- Manager bot operations (if Managed Bots control plane is active) β Telegram-side operations are free; h.work-side cost is the manager bot's webhook + reconciliation logic, which lives inside the existing API service (no new infrastructure)
- MTProto worker (only if MTProto BotFather fallback is operating per Β§9.0) β one always-on Python service per environment, similar footprint to the agent service
- Ops user account integrity (only if MTProto fallback is operating) β if the h.work TG ops user account is banned or session-invalidated, in-flight bot creation stalls until recovery
No entries in channel_pricing_rate_cards are required for Telegram at launch.
13. Provider abstractionβ
persona.provider is fixed at 'telegram'. There is no BSP layer (Telegram does not have a Twilio-equivalent intermediary at meaningful scale for the h.work use case). The provider column exists for symmetry with WhatsApp/Slack but Telegram has only one provider value.
14. Security and compliance boundariesβ
| Boundary | Isolation provided by |
|---|---|
| Kai-Acme vs Kai-BigCorp (same Specialist, different Org) | Different bot + different token + different @username + different endpoint row + different RLS GUC + different webhook secret |
| Kai-Acme vs Mei-Acme (same Org, different Specialist) | Different bot + different token + different @username + different persona + different specialist_id |
| Kai-Acme vs Mei-BigCorp (different Org, different Specialist) | Both of the above, composed |
Threat model coverage:
- Per-bot token compromise β single OSA affected. Rotate via the active control plane (Managed Bots
replaceManagedBotTokenor BotFather/revoke); issues new token, invalidates old; newtoken_hashβ new webhook URL viasetWebhook. Username andbot_idpreserved. - Manager bot compromise (Managed Bots path) β attacker with the manager bot's token could call
replaceManagedBotTokenor trigger creation flows. Single largest blast radius if Path A is active. Mitigations: manager bot token in Secrets Manager with strict access policy;can_manage_botsprivilege scoped tightly; audit log on everymanaged_botupdate. - MTProto ops user account compromise (only if Path B / MTProto fallback is operating) β attacker could mass-delete bots via BotFather scripting. Mitigations: ops account uses Telegram 2FA (cloud password in Secrets Manager); session storage encrypted at rest; audit log on every BotFather interaction; manual recovery runbook. Not applicable when Managed Bots is the active path.
- Webhook URL hijack (an attacker controls a DNS / CDN that intercepts Telegram POSTs) β mitigated by
X-Telegram-Bot-Api-Secret-Tokenheader check (per-endpoint secret); attacker without that secret cannot inject events - Username squatting during provisioning collision β naming collision strategy is bounded (numeric suffix capped at e.g., 99); on exhaustion, fail and require manual operator selection
- Bot privacy mode set incorrectly β bot in a group may see messages not intended for it. Default privacy mode is
enabled(bot only sees mentions, replies, /commands, and bot-specific events in groups; private chats are always full-visibility). Set at provisioning, recorded inmetadata.privacy_mode. Drift is detected by a periodic audit job. Important caveat: a bot added as a group administrator receives all messages regardless of privacy mode β this is a Telegram behavior, not a configurable flag.
14.4 Bot must not be group admin (invariant)β
For MVP, h.work-operated bots must not request or accept group-admin rights. A customer Org adding a bot as group admin is treated as an elevated mode and recorded as a group_admin_elevation audit event. Operations on a bot in elevated mode are allowed but flagged on the ops dashboard. This protects the privacy boundary: a bot operating with privacy_mode=enabled only sees mentions/replies/commands, but a group-admin bot sees all messages β which would violate the per-OSA isolation expectation.
14.5 Privacy mode change requires bot re-addβ
Telegram has historically required a bot to be removed and re-added to a group for a privacy-mode change to take effect within that group. If h.work changes a bot's privacy mode (via the active control plane), existing group memberships of that bot must be flagged in the ops dashboard as "pending re-add for privacy update". This is captured in profile_sync_state.privacy_mode_dirty_groups.
Token rotation procedureβ
See Β§9.3 for full path-by-path token rotation. Summary:
- Managed Bots active β
replaceManagedBotToken - MTProto BotFather active β
/revoke - Manual ops β operator interaction
In all cases, token rotation:
- Generates a new token, invalidating the old one
- Computes a new
token_hashand atomically updates Secrets Manager + endpoint metadata - Calls
setWebhookwith the new URL (containing the newtoken_hash) and the existingsecret_tokenheader value - Preserves
@usernameandbot_idβ customer chat links remain valid - Writes an audit log entry with the actor, reason, and timing
15. Open questions to verify against current Telegram docsβ
Before implementation, the following facts must be confirmed against current Telegram Bot API / MTProto documentation. Treat the design above as architecturally correct but vendor-API details are indicative:
- Confirm
setMyName,setMyDescription,setMyShortDescription,setMyCommands,setMyProfilePhoto,removeMyProfilePhotoare all available in the production Bot API at implementation time; confirm exact field constraints (lengths, charset, image format/dimensions for profile photo). - Managed Bots feasibility spike (largest open question): confirm whether the Bot API 9.6 Managed Bots flow (
can_manage_bots,managed_botupdate,getManagedBotToken,replaceManagedBotToken,t.me/newbot/...link) can satisfy h.work-owned per-OSA creation with acceptable ops UX. If yes, MTProto fallback (Path B) is not required at MVP. - If Managed Bots is insufficient: confirm current MTProto layer behavior for BotFather scripting via pyrogram / telethon; confirm anti-abuse heuristics on user accounts that mass-create bots.
- Current Telegram rate-limit posture per Bot API method β verify that documentation still advises against hard-coded limits and that adaptive throttling + Retry-After honoring remains the recommended pattern.
- Confirm
setWebhookwithsecret_tokenremains the recommended webhook verification mechanism; confirm supported webhook ports (currently 443 / 80 / 88 / 8443) and certificate requirements. - Username collision behavior: confirm the deleted-bot username cooldown period and any reuse restrictions.
- Privacy-mode change semantics: confirm whether removing-and-re-adding bot to group is still required for privacy-mode changes to take effect, or whether Telegram has automated this.
setMyProfilePhotooperational details: confirm acceptable image dimensions, file-size limits, format requirements (JPG static), animation support, frequency limits.- Whether Telegram Business / Premium changes any bot-interaction semantics relevant to identity isolation.
- Whether
managed_botupdate delivery is reliable enough to be the canonical reconciliation source for bot identity changes, or whether the platform must also poll periodically.
16. Implementation planβ
The work is sized for 5-7 PRs, sequenced to defer the bot-creation control plane decision until after a feasibility spike. The MVP avatar path uses setMyProfilePhoto (Bot API), so no MTProto worker is required on the critical path.
Phase 1 β Schema (Expand)β
- Tables
specialist_channel_personas,osa_channel_endpointsfrom the parent ADR are reused. - Add Telegram-specific indexes:
uniq_telegram_endpoint_token_hash,uniq_telegram_endpoint_bot_id,uniq_telegram_endpoint_username. - Inbound dedup table key includes
endpoint_id(per-bot scoping; not global onupdate_id). - Migration applies cleanly to active-active cluster.
Phase 2 β Bot API Telegram coreβ
Everything that does NOT depend on the creation path:
- Webhook handler (
POST /v1/channels/telegram/webhook/{token_hash}) withX-Telegram-Bot-Api-Secret-Tokenverification + per-endpoint_iddedup setWebhookwithsecret_token,allowed_updates=["message","edited_message","callback_query","my_chat_member"],drop_pending_updates=truesendMessageoutbound withwithCircuit + withRetryhonoring 429retry_after- Identity sync via Bot API:
setMyName,setMyDescription,setMyShortDescription,setMyCommands,setMyProfilePhoto(JPG, fresh multipart upload) - Per-endpoint adaptive throttle config (no hard-coded limits)
- Token rotation abstraction (path-agnostic): given a new token, atomically update Secrets Manager + endpoint metadata + call setWebhook
getWebhookInfohealth-check job- DLQ for failed sync jobs
This phase delivers a fully working per-OSA Telegram bot β the only thing missing is automated creation.
Phase 3 β Bot creation control-plane spikeβ
Time-boxed evaluation of two paths:
Spike A β Managed Bots:
- Build a prototype manager bot with
can_manage_bots - Verify that the
t.me/newbot/...flow +managed_botupdates produce h.work-owned bots with per-OSA isolation - Measure ops UX: how many steps does AM go through to provision a new OSA?
- Verify
replaceManagedBotTokenfor rotation
Spike B β MTProto BotFather scripting (only if Spike A is insufficient):
- Smaller scope: enough to validate the path is operationally viable
- Surface fragility (anti-abuse, session invalidation) in the runbook
Spike outcome chooses Path A or Path B for Phase 4. If neither is fully viable, Path C (manual ops) is the documented bridge.
Phase 4 β Provisioning service + AM Setup Wizardβ
TelegramProvisioningServiceorchestrates the L3 state machine using whichever control plane Phase 3 selected.- AM Setup Wizard adds "Enable Telegram" per OSA, with collision-strategy choice and live status polling.
- Existing customers using legacy
integration_credentialsTelegram rows are migrated via backfill (create new endpoint per OSA, mark legacy row deprecated, keep token in-use during dual-write).
Phase 5 β Inbound module + dual-write production soakβ
- New
TelegramModulereads routing fromosa_channel_endpointsbymetadata->>'token_hash'. - 200-ack within Telegram's expected budget; BullMQ for business processing.
- 7-day production soak; alert on any legacy fallback hits.
Phase 6 β Switch read + contractβ
- Remove legacy
integration_credentialsTelegram rows after dual-write soak. - Remove legacy lookup paths.
Phase 7 β Operational hardeningβ
- Webhook health monitor (periodic
getWebhookInfoper endpoint; alert onlast_error_datedrift) - Privacy-mode drift audit (periodic check that every Telegram endpoint's privacy mode matches L2 template; flag
privacy_mode_dirty_groupsfrom Β§14.5) - Group-admin elevation audit (flag any endpoint whose bot is a group admin per Β§14.4)
- Token rotation runbook
- Bot-creation failure dashboard (Managed Bots
managed_botupdate gaps OR MTProto BotFather rate-limit detection, depending on active path) - Avatar sync health (Bot API call result trend; surface persistent 4xx for
setMyProfilePhoto)
17. ADR decision text (drop-in)β
For ADR-030 Β§Per-channel application / Telegram:
For Telegram, h.work uses a persona-per-Specialist (L2 template) and bot-per-OSA model.
Telegram has no third-party object equivalent to Slack's App or WhatsApp's WABA β its L2 is therefore an internal template row holding username naming pattern, description templates, default commands, default privacy mode, and default group-join policy. This is an h.work product-level isolation invariant, not a Telegram platform requirement.
Every Org Γ Specialist assignment (OSA) receives one dedicated Telegram bot. Each bot has a unique global
@usernamederived from the persona naming pattern, an independent bot token, an independent webhook URL, and an independent profile.Telegram inbound updates resolve to exactly one OSA via single-row lookup on the URL-path-encoded
token_hash(SHA-256 prefix of the bot token), with double-verification via theX-Telegram-Bot-Api-Secret-Tokenheader. Tokens never appear in webhook URLs in plaintext. Inbound dedup is scoped per endpoint becauseupdate_idis unique only within a single bot's update stream. All inbound webhooks ACK with HTTP 200 within Telegram's expected budget and dispatch business processing to BullMQ.Bot identity is sourced from the Specialist row and synced to each provisioned bot through the Telegram Bot API. Text fields use
setMyName,setMyDescription,setMyShortDescription, andsetMyCommands. Bot avatar usessetMyProfilePhoto, which is supported by the current Bot API; profile photos must be uploaded as freshInputProfilePhotoassets and cannot be reused from previous file IDs.Bot provisioning requires a bot-creation control plane. h.work will first verify Telegram's Managed Bots flow (Bot API 9.6
can_manage_bots,managed_botupdates,replaceManagedBotToken) as the preferred official path. If Managed Bots cannot satisfy h.work-owned per-OSA bot provisioning, h.work may operate an MTProto-based BotFather automation worker as a fallback. The fallback is treated as an operationally fragile integration and must include manual recovery, audit logging, rate-limit handling, and session recovery documentation. Manual operator interaction with @BotFather is always available as the recovery path.Outbound dispatch honors
429 Too Many Requestsresponses with the indicatedretry_after, validates message length (1-4096 chars), and applies per-endpoint adaptive throttling. Rate-limit values are operational defaults; the platform does not treat any specific numeric limit as a Telegram protocol guarantee.Bots must not request or operate as group administrators in MVP. Privacy mode is
enabledby default and changes may require removing-and-re-adding the bot to existing groups.A Specialist MAY own additional secondary personas for staging, testing, or migration reasons. Secondary personas do not change Specialist identity ownership.
Org-shared front-door bot (one bot per Org with internal topic-based routing to multiple Specialists) is deferred to ADR-028 and is not part of the default model. Group-chat administration features, Mini Apps, and Telegram Premium / Business interop are out of MVP scope. Vendor-API specifics (current Bot API surface, Managed Bots maturity, BotFather scripting behavior, current rate limits) are indicative and must be verified against current Telegram documentation at implementation time.
18. Recommended implementation issueβ
Title: [P2][feat] Telegram per-OSA bot with persona-templated identity model
Parent: #1471 (ADR-030)
Labels: priority:p2 type:feature area:backend
Scope checklist:
- Phase 1: schema migration + Telegram-specific token_hash + bot_id + username indexes; inbound dedup scoped per
endpoint_id - Phase 2: Bot API Telegram core β webhook handler with
secret_tokenverification + per-endpoint dedup;sendMessagewith 429 /retry_after; identity sync via Bot API (text +setMyProfilePhoto); token rotation abstraction;getWebhookInfohealth check - Phase 3: Bot creation control-plane spike β evaluate Managed Bots (preferred) vs MTProto BotFather fallback; pick path; document decision
- Phase 4:
TelegramProvisioningService+ AM Setup Wizard "Enable Telegram" using selected control plane - Phase 5: inbound dual-write + 7-day production soak
- Phase 6: switch read + contract; remove legacy
integration_credentialsTelegram rows - Phase 7: operational hardening (webhook health monitor, privacy-mode drift audit, group-admin elevation audit, token rotation runbook)
- Naming collision strategy implementation (numeric_suffix / random_suffix / manual)
- Audit log: bot creation + revocation + token rotation + group-admin elevation events
- Operational dashboards: persona state + endpoint state aggregates + creation-failure trend
- Secondary persona support (env / migration shard_key)
- Per-endpoint adaptive throttle config (no hard-coded rate limits)
Verification gates before merge:
- All Bot API calls behind
withCircuit + withRetryhonoring429 retry_after+channel_failuresDLQ - Inbound handler 200-ACKs within Telegram's expected budget; lifecycle events for unknown endpoints log + 200 (avoid retry storms)
- Token never appears in URL paths, logs, or breadcrumbs (only
token_hashin URLs) X-Telegram-Bot-Api-Secret-Tokenheader check enforced;secret_tokenvalue matches charset constraint (A-Z a-z 0-9 _ -, 1-256 chars)- Inbound dedup uses
(channel, endpoint_id, update_id)β global(channel, update_id)is rejected in code review - Webhook
allowed_updatesincludesmy_chat_member(block/unblock detection) - Avatar sync uses Bot API
setMyProfilePhoto; MTProto path appears ONLY in optional fallback - E2E test: provision Kai-for-Acme + Kai-for-BigCorp on same Specialist, verify isolated bots with same identity
- E2E test: token rotation atomically updates webhook URL without inbound gap
- E2E test:
setMyProfilePhotoagainst a fresh JPG upload, verify successful sync recorded inprofile_sync_state - E2E test: same
update_idarriving on two different OSA endpoints are processed independently (no false dedup)
One-line summaryβ
One Telegram persona template per Specialist (L2 is internal-only, no Telegram-native object); one dedicated bot per OSA; inbound resolves single OSA via URL-path token_hash + X-Telegram-Bot-Api-Secret-Token header with per-endpoint update_id dedup; full identity (including avatar via setMyProfilePhoto) syncs through the Bot API; bot creation control plane preferred Managed Bots, with MTProto BotFather automation as a fragility-flagged fallback and manual ops as the recovery path; rate limits are operational defaults honoring 429 retry_after, not protocol guarantees.