Skip to main content

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 @username derived 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_id
  • specialist_id
  • channel_endpoint_id
  • conversation scope (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 InputProfilePhotoStatic schema)
  • Photos must be uploaded as fresh multipart file assets β€” a previously-uploaded file_id cannot 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​

FieldSourceUpdate mechanismPer-Specialist or per-OSA
Usernamenaming pattern + Org slug/setusername via BotFather (one-time at creation)per-OSA
First namespecialists.firstName (default)setMyName Bot API (0-64 chars)per-OSA, sourced from L1
Descriptionspecialists.descriptionsetMyDescription Bot API (0-512 chars)per-OSA, sourced from L1
Short descriptionderived from L1setMyShortDescription Bot API (0-120 chars)per-OSA, sourced from L1
CommandsL2 templatesetMyCommands Bot API (up to 100)per-OSA, templated at L2
Profile photospecialists.avatar_urlsetMyProfilePhoto Bot API (JPG, fresh upload)per-OSA, sourced from L1
Privacy modeL2 default/setprivacy via BotFather (privacy default enabled)per-OSA, templated at L2
Can-join-groupsL2 default/setjoingroups via BotFatherper-OSA, templated at L2
Bot tokenissued by BotFather (or Managed Bots flow)/token (read) or /revoke (rotate) via BotFatherper-OSA
Webhook URLh.work-controlled (ports 443 / 80 / 88 / 8443)setWebhook Bot APIper-OSA
Webhook secret_tokenh.work-generatedpassed 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:

ObjectCount formulaBound
TG ops user account1 platform-wideh.work internal infrastructure
Persona (primary)M = number of Specialists with Telegramone per Specialist
Persona (secondary)0..K per Specialistexception-driven (staging / migration)
Botsum(OSAs per Specialist with Telegram enabled)one per OSA
Endpoint rowidentical to bot countone 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:

ConditionResponse
Endpoint not found by token_hash200 + structured log (Telegram would retry on 4xx/5xx β€” accept and drop)
secret_token header missing or mismatch401 (treat as forged)
Endpoint found + provisioned + ACK enqueued200
Endpoint suspended/revoked200 + log orphan event (avoid retry storm)
Internal worker error after ACKalready 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 Requests responses with the retry_after parameter 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:

FieldMethodNotes
First namesetMyName0-64 chars
DescriptionsetMyDescription0-512 chars, shown in empty-chat-with-bot
Short descriptionsetMyShortDescription0-120 chars, shown on profile + shared links
CommandssetMyCommandsup to 100 commands
Profile photosetMyProfilePhotoJPG, 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):

  1. Manual via @BotFather β€” operator runs /setuserpic. Documented in the ops runbook.
  2. 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 = @username is the human-facing handle; token_hash is the webhook routing key; bot_id is the stable machine identity.
  • Bot username can change via BotFather (/setusername); the external_id row would be updated and token_hash is 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 Bots replaceManagedBotToken) issues a new token and invalidates the old one. New token β†’ new token_hash β†’ new webhook URL must be re-set via setWebhook. Both credentials_secret_ref and metadata.token_hash update atomically. bot_id is 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_state records the field-level failure for async retry
  • Webhook config failure β†’ endpoint stays in profile_configured; retry
  • Customer initiates /stop on the bot (Telegram limits bot from sending more messages to that user) β†’ conversation-level state, not endpoint state; endpoint remains provisioned
  • 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_bots privilege on the manager bot
  • managed_bot update type delivered to the manager bot when a managed bot is created or has its token / owner changed
  • getManagedBotToken to fetch a managed bot's current token
  • replaceManagedBotToken to 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:

  1. 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.
  2. Spike Managed Bots feasibility (Β§16 Phase 3).
  3. If Managed Bots is sufficient β†’ wire as Path A.
  4. If not β†’ build MTProto worker as Path B, with explicit fragility framing in the runbook.
  5. 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)​

FeatureWhereWhy 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 subsystemADR-028β€”
Group chat / channel administration featuresOut of MVP scopeBots 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 AppsFuture product decisionDifferent UX surface; not in the messaging credential isolation scope.
Telegram Premium / Business interopOut of MVP scopeCustomer's Telegram account features; orthogonal to bot identity.
Discovery bot (e.g., @kai_hp_bot that redirects to per-OSA bot)Optional polishCould 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:

Reasonshard_key valueUse case
Stagingenv:stagingSeparate persona template for staging-env bots (different webhook host, different ops user)
Migrationmigration:<reason>Changing username naming convention without breaking existing bots
Testenv:testTest-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​

BoundaryIsolation 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 replaceManagedBotToken or BotFather /revoke); issues new token, invalidates old; new token_hash β†’ new webhook URL via setWebhook. Username and bot_id preserved.
  • Manager bot compromise (Managed Bots path) β†’ attacker with the manager bot's token could call replaceManagedBotToken or 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_bots privilege scoped tightly; audit log on every managed_bot update.
  • 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-Token header 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 in metadata.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:

  1. Generates a new token, invalidating the old one
  2. Computes a new token_hash and atomically updates Secrets Manager + endpoint metadata
  3. Calls setWebhook with the new URL (containing the new token_hash) and the existing secret_token header value
  4. Preserves @username and bot_id β€” customer chat links remain valid
  5. 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:

  1. Confirm setMyName, setMyDescription, setMyShortDescription, setMyCommands, setMyProfilePhoto, removeMyProfilePhoto are all available in the production Bot API at implementation time; confirm exact field constraints (lengths, charset, image format/dimensions for profile photo).
  2. Managed Bots feasibility spike (largest open question): confirm whether the Bot API 9.6 Managed Bots flow (can_manage_bots, managed_bot update, 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.
  3. 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.
  4. 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.
  5. Confirm setWebhook with secret_token remains the recommended webhook verification mechanism; confirm supported webhook ports (currently 443 / 80 / 88 / 8443) and certificate requirements.
  6. Username collision behavior: confirm the deleted-bot username cooldown period and any reuse restrictions.
  7. 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.
  8. setMyProfilePhoto operational details: confirm acceptable image dimensions, file-size limits, format requirements (JPG static), animation support, frequency limits.
  9. Whether Telegram Business / Premium changes any bot-interaction semantics relevant to identity isolation.
  10. Whether managed_bot update 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_endpoints from 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 on update_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}) with X-Telegram-Bot-Api-Secret-Token verification + per-endpoint_id dedup
  • setWebhook with secret_token, allowed_updates=["message","edited_message","callback_query","my_chat_member"], drop_pending_updates=true
  • sendMessage outbound with withCircuit + withRetry honoring 429 retry_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
  • getWebhookInfo health-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_bot updates 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 replaceManagedBotToken for 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​

  • TelegramProvisioningService orchestrates 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_credentials Telegram 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 TelegramModule reads routing from osa_channel_endpoints by metadata->>'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_credentials Telegram rows after dual-write soak.
  • Remove legacy lookup paths.

Phase 7 β€” Operational hardening​

  • Webhook health monitor (periodic getWebhookInfo per endpoint; alert on last_error_date drift)
  • Privacy-mode drift audit (periodic check that every Telegram endpoint's privacy mode matches L2 template; flag privacy_mode_dirty_groups from Β§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_bot update 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 @username derived 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 the X-Telegram-Bot-Api-Secret-Token header. Tokens never appear in webhook URLs in plaintext. Inbound dedup is scoped per endpoint because update_id is 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, and setMyCommands. Bot avatar uses setMyProfilePhoto, which is supported by the current Bot API; profile photos must be uploaded as fresh InputProfilePhoto assets 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_bot updates, 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 Requests responses with the indicated retry_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 enabled by 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.


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_token verification + per-endpoint dedup; sendMessage with 429 / retry_after; identity sync via Bot API (text + setMyProfilePhoto); token rotation abstraction; getWebhookInfo health 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_credentials Telegram 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 + withRetry honoring 429 retry_after + channel_failures DLQ
  • 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_hash in URLs)
  • X-Telegram-Bot-Api-Secret-Token header check enforced; secret_token value 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_updates includes my_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: setMyProfilePhoto against a fresh JPG upload, verify successful sync recorded in profile_sync_state
  • E2E test: same update_id arriving 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.