From e43326c40d08842e10d3af30dcf652d5cd1121fd Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Sun, 19 Jul 2026 14:35:45 -0400 Subject: [PATCH 1/6] feat: automatic Telegram bot creation via managed-bot pairing Adds a pairing service (Bot API 9.6 Managed Bots) so the setup wizard can create a deployment's Telegram bot with one tap in Telegram instead of manual BotFather token copy-paste. The bot token goes straight from the pairing service to the deployment server and never touches the browser. --- apps/api/src/handlers/index.ts | 1 + .../telegram-pairing/__tests__/index.test.ts | 226 ++++++++++++++++ .../src/handlers/telegram-pairing/index.ts | 254 ++++++++++++++++++ .../src/handlers/telegram-pairing/store.ts | 124 +++++++++ apps/api/src/route-policies.ts | 24 ++ apps/api/src/server.ts | 17 +- .../providers/communications/telegram.mdx | 31 +++ apps/web/package.json | 1 + .../(onboarding)/setup/StepTelegramSetup.tsx | 168 ++++++++---- .../setup/TelegramManagedBotPairing.tsx | 156 +++++++++++ apps/web/src/trpc/commands/comms/index.ts | 12 +- .../commands/comms/telegram-pairing.test.ts | 213 +++++++++++++++ .../trpc/commands/comms/telegram-pairing.ts | 172 ++++++++++++ apps/web/src/trpc/routers/_app.ts | 14 + packages/communication/package.json | 1 + .../__tests__/telegram-managed-bot.test.ts | 161 +++++++++++ .../communication/src/mock-telegram-server.ts | 18 ++ .../communication/src/telegram-managed-bot.ts | 210 +++++++++++++++ packages/env/src/index.ts | 12 + pnpm-lock.yaml | 16 +- 20 files changed, 1774 insertions(+), 57 deletions(-) create mode 100644 apps/api/src/handlers/telegram-pairing/__tests__/index.test.ts create mode 100644 apps/api/src/handlers/telegram-pairing/index.ts create mode 100644 apps/api/src/handlers/telegram-pairing/store.ts create mode 100644 apps/web/src/app/(onboarding)/setup/TelegramManagedBotPairing.tsx create mode 100644 apps/web/src/trpc/commands/comms/telegram-pairing.test.ts create mode 100644 apps/web/src/trpc/commands/comms/telegram-pairing.ts create mode 100644 packages/communication/src/__tests__/telegram-managed-bot.test.ts create mode 100644 packages/communication/src/telegram-managed-bot.ts diff --git a/apps/api/src/handlers/index.ts b/apps/api/src/handlers/index.ts index 33ac2be76..bec2b9cdc 100644 --- a/apps/api/src/handlers/index.ts +++ b/apps/api/src/handlers/index.ts @@ -13,6 +13,7 @@ export { slack } from './slack'; export { linear } from './linear'; export { teams } from './teams'; export { telegram } from './telegram'; +export { telegramManagerWebhook, telegramPairing } from './telegram-pairing'; export { discord } from './discord'; // trpc diff --git a/apps/api/src/handlers/telegram-pairing/__tests__/index.test.ts b/apps/api/src/handlers/telegram-pairing/__tests__/index.test.ts new file mode 100644 index 000000000..291bcabdd --- /dev/null +++ b/apps/api/src/handlers/telegram-pairing/__tests__/index.test.ts @@ -0,0 +1,226 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { envMock, redisStore, redisMock } = vi.hoisted(() => { + const store = new Map(); + return { + envMock: { + R_APP_URL: 'https://roomote.example.com', + R_TELEGRAM_MANAGER_BOT_TOKEN: '7000000002:manager-token' as + | string + | undefined, + R_TELEGRAM_MANAGER_BOT_USERNAME: 'RoomoteSetupBot' as string | undefined, + R_TELEGRAM_MANAGER_WEBHOOK_SECRET: 'manager-secret' as string | undefined, + }, + redisStore: store, + redisMock: { + get: vi.fn(async (key: string) => store.get(key) ?? null), + set: vi.fn(async (key: string, value: string, ..._args: unknown[]) => { + store.set(key, value); + return 'OK'; + }), + del: vi.fn(async (key: string) => { + store.delete(key); + return 1; + }), + }, + }; +}); + +vi.mock('@roomote/env', () => ({ Env: envMock })); +vi.mock('@roomote/redis', () => ({ getRedis: () => redisMock })); +vi.mock('../../../logging.js', () => ({ + apiLogger: { warn: vi.fn(), error: vi.fn() }, +})); + +import { telegramManagerWebhook, telegramPairing } from '../index.js'; + +const CHILD_BOT_TOKEN = '7000000003:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; + +function mockTelegramApi() { + const calls: Array<{ url: string; body: Record }> = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const body = init?.body ? JSON.parse(String(init.body)) : {}; + calls.push({ url, body }); + + if (url.includes('/getManagedBotToken')) { + return Response.json({ ok: true, result: { token: CHILD_BOT_TOKEN } }); + } + return Response.json({ ok: true, result: true }); + }), + ); + return calls; +} + +async function createPairing(): Promise<{ + pairingId: string; + pollToken: string; + suggestedUsername: string; + deepLink: string; +}> { + const response = await telegramPairing.request('/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ botName: 'Roomote' }), + }); + expect(response.status).toBe(201); + return response.json(); +} + +function managedBotUpdate(botUsername: string) { + return { + update_id: 5, + managed_bot: { + user: { id: 111000111, username: 'grace_mock' }, + bot: { id: 7000000003, is_bot: true, username: botUsername }, + }, + }; +} + +async function deliverManagerWebhook( + update: unknown, + secret = 'manager-secret', +) { + return telegramManagerWebhook.request('/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Telegram-Bot-Api-Secret-Token': secret, + }, + body: JSON.stringify(update), + }); +} + +describe('telegram-pairing service', () => { + beforeEach(() => { + redisStore.clear(); + envMock.R_TELEGRAM_MANAGER_BOT_TOKEN = '7000000002:manager-token'; + envMock.R_TELEGRAM_MANAGER_BOT_USERNAME = 'RoomoteSetupBot'; + envMock.R_TELEGRAM_MANAGER_WEBHOOK_SECRET = 'manager-secret'; + mockTelegramApi(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it('returns 404 everywhere when no manager bot is configured', async () => { + envMock.R_TELEGRAM_MANAGER_BOT_TOKEN = undefined; + + const create = await telegramPairing.request('/', { method: 'POST' }); + expect(create.status).toBe(404); + + const poll = await telegramPairing.request('/some-id'); + expect(poll.status).toBe(404); + + const webhook = await deliverManagerWebhook(managedBotUpdate('x_bot')); + expect(webhook.status).toBe(404); + }); + + it('creates a pairing with a deep link and registers the manager webhook', async () => { + const calls = mockTelegramApi(); + const pairing = await createPairing(); + + expect(pairing.suggestedUsername).toMatch(/^roomote_[a-z2-7]{16}_bot$/); + expect(pairing.deepLink).toBe( + `https://t.me/newbot/RoomoteSetupBot/${pairing.suggestedUsername}?name=Roomote`, + ); + + const webhookCall = calls.find((call) => call.url.includes('/setWebhook')); + expect(webhookCall?.body).toMatchObject({ + url: 'https://roomote.example.com/api/webhooks/telegram-manager', + secret_token: 'manager-secret', + allowed_updates: ['managed_bot'], + }); + }); + + it('completes the full pairing flow and hands the token out exactly once', async () => { + const pairing = await createPairing(); + + // Pending before the managed_bot update arrives. + const pending = await telegramPairing.request(`/${pairing.pairingId}`, { + headers: { Authorization: `Bearer ${pairing.pollToken}` }, + }); + expect(await pending.json()).toEqual({ status: 'pending' }); + + const webhook = await deliverManagerWebhook( + managedBotUpdate(pairing.suggestedUsername), + ); + expect(webhook.status).toBe(200); + + const ready = await telegramPairing.request(`/${pairing.pairingId}`, { + headers: { Authorization: `Bearer ${pairing.pollToken}` }, + }); + expect(await ready.json()).toEqual({ + status: 'ready', + token: CHILD_BOT_TOKEN, + botUsername: pairing.suggestedUsername, + ownerTelegramUserId: '111000111', + ownerTelegramUsername: 'grace_mock', + }); + + // One-shot: the second poll finds nothing. + const gone = await telegramPairing.request(`/${pairing.pairingId}`, { + headers: { Authorization: `Bearer ${pairing.pollToken}` }, + }); + expect(gone.status).toBe(404); + }); + + it('rejects polls with a wrong or missing poll token', async () => { + const pairing = await createPairing(); + + const missing = await telegramPairing.request(`/${pairing.pairingId}`); + expect(missing.status).toBe(404); + + const wrong = await telegramPairing.request(`/${pairing.pairingId}`, { + headers: { Authorization: 'Bearer nope' }, + }); + expect(wrong.status).toBe(404); + }); + + it('rejects manager webhook deliveries with a bad secret', async () => { + const response = await deliverManagerWebhook( + managedBotUpdate('roomote_x_bot'), + 'wrong-secret', + ); + expect(response.status).toBe(401); + }); + + it('acknowledges managed_bot updates without a matching pairing', async () => { + const response = await deliverManagerWebhook( + managedBotUpdate('roomote_unknown_bot'), + ); + expect(response.status).toBe(200); + }); + + it('returns 500 so Telegram retries when the token fetch fails', async () => { + const pairing = await createPairing(); + + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL) => { + if (String(input).includes('/getManagedBotToken')) { + return Response.json( + { ok: false, description: 'flaky' }, + { status: 500 }, + ); + } + return Response.json({ ok: true, result: true }); + }), + ); + + const webhook = await deliverManagerWebhook( + managedBotUpdate(pairing.suggestedUsername), + ); + expect(webhook.status).toBe(500); + + const stillPending = await telegramPairing.request( + `/${pairing.pairingId}`, + { headers: { Authorization: `Bearer ${pairing.pollToken}` } }, + ); + expect(await stillPending.json()).toEqual({ status: 'pending' }); + }); +}); diff --git a/apps/api/src/handlers/telegram-pairing/index.ts b/apps/api/src/handlers/telegram-pairing/index.ts new file mode 100644 index 000000000..2a52f89f1 --- /dev/null +++ b/apps/api/src/handlers/telegram-pairing/index.ts @@ -0,0 +1,254 @@ +import crypto from 'node:crypto'; + +import { Hono } from 'hono'; + +import { Env } from '@roomote/env'; +import { getRedis } from '@roomote/redis'; +import { + buildManagedBotDeepLink, + generateManagedBotUsername, + getManagedBotToken, + parseTelegramManagedBotUpdate, + registerManagerBotWebhook, +} from '@roomote/communication/telegram-managed-bot'; + +import { apiLogger } from '../../logging.js'; +import { + TELEGRAM_PAIRING_TTL_SECONDS, + createPairingRecord, + deletePairingRecord, + findPairingIdByUsername, + getPairingRecord, + hashPollToken, + markPairingReady, +} from './store.js'; + +/** + * Telegram managed-bot pairing service (Bot API 9.6 Managed Bots). + * + * This service runs on any deployment that configures a manager bot + * (`R_TELEGRAM_MANAGER_BOT_TOKEN` + `R_TELEGRAM_MANAGER_BOT_USERNAME` + + * `R_TELEGRAM_MANAGER_WEBHOOK_SECRET`). Roomote hosts one central instance; + * other deployments point `R_TELEGRAM_PAIRING_URL` at it and never need + * these env vars. Flow: + * + * 1. `POST /api/telegram-pairing` mints a pairing: a high-entropy + * suggested bot username (the correlation key), a secret poll token, + * and a `t.me/newbot//` deep link. + * 2. The admin opens the deep link and taps "Create Bot" in Telegram. + * 3. Telegram sends the manager bot a `managed_bot` update; the webhook + * handler matches the username, exports the child bot's token via + * `getManagedBotToken`, and marks the pairing ready. + * 4. The client deployment polls `GET /api/telegram-pairing/:id` with the + * poll token and receives the bot token exactly once. + */ + +const MANAGER_WEBHOOK_PATH = '/api/webhooks/telegram-manager'; +const MANAGER_WEBHOOK_REGISTERED_KEY = 'telegram-pairing:manager-webhook'; +const MANAGER_WEBHOOK_RECHECK_SECONDS = 60 * 60; +const MAX_BOT_NAME_LENGTH = 64; +const DEFAULT_BOT_NAME = 'Roomote'; + +type ManagerBotConfig = { + managerBotToken: string; + managerBotUsername: string; + webhookSecret: string; +}; + +function getManagerBotConfig(): ManagerBotConfig | null { + const managerBotToken = Env.R_TELEGRAM_MANAGER_BOT_TOKEN; + const managerBotUsername = Env.R_TELEGRAM_MANAGER_BOT_USERNAME; + const webhookSecret = Env.R_TELEGRAM_MANAGER_WEBHOOK_SECRET; + if (!managerBotToken || !managerBotUsername || !webhookSecret) { + return null; + } + return { managerBotToken, managerBotUsername, webhookSecret }; +} + +/** + * Keep the manager bot's webhook pointed at this deployment, re-checked at + * most hourly. Best-effort: a Telegram outage must not block pairing + * creation (the webhook is usually already registered). + */ +async function ensureManagerWebhookRegistered( + config: ManagerBotConfig, +): Promise { + const redis = getRedis(); + const alreadyChecked = await redis.get(MANAGER_WEBHOOK_REGISTERED_KEY); + if (alreadyChecked) { + return; + } + + try { + await registerManagerBotWebhook({ + managerBotToken: config.managerBotToken, + webhookUrl: new URL(MANAGER_WEBHOOK_PATH, Env.R_APP_URL).toString(), + webhookSecret: config.webhookSecret, + }); + await redis.set( + MANAGER_WEBHOOK_REGISTERED_KEY, + '1', + 'EX', + MANAGER_WEBHOOK_RECHECK_SECONDS, + ); + } catch (error) { + apiLogger.warn( + { err: error }, + 'telegram-pairing: manager webhook registration failed', + ); + } +} + +function safeCompareSecret(expected: string, actual: string | null): boolean { + if (!actual) { + return false; + } + const expectedBuffer = Buffer.from(expected); + const actualBuffer = Buffer.from(actual); + return ( + expectedBuffer.length === actualBuffer.length && + crypto.timingSafeEqual(expectedBuffer, actualBuffer) + ); +} + +function readBearerToken(header: string | undefined): string | null { + if (!header?.startsWith('Bearer ')) { + return null; + } + const token = header.slice('Bearer '.length).trim(); + return token.length > 0 ? token : null; +} + +export const telegramPairing = new Hono(); + +telegramPairing.post('/', async (c) => { + const config = getManagerBotConfig(); + if (!config) { + return c.json({ error: 'Telegram pairing is not enabled here.' }, 404); + } + + await ensureManagerWebhookRegistered(config); + + const body = (await c.req.json().catch(() => ({}))) as { + botName?: unknown; + }; + const botName = + typeof body.botName === 'string' && body.botName.trim().length > 0 + ? body.botName.trim().slice(0, MAX_BOT_NAME_LENGTH) + : DEFAULT_BOT_NAME; + + const suggestedUsername = generateManagedBotUsername(); + const { record, pollToken } = await createPairingRecord({ + suggestedUsername, + }); + + return c.json( + { + pairingId: record.pairingId, + pollToken, + suggestedUsername, + deepLink: buildManagedBotDeepLink({ + managerBotUsername: config.managerBotUsername, + suggestedUsername, + botName, + }), + expiresInSeconds: TELEGRAM_PAIRING_TTL_SECONDS, + }, + 201, + ); +}); + +telegramPairing.get('/:pairingId', async (c) => { + if (!getManagerBotConfig()) { + return c.json({ error: 'Telegram pairing is not enabled here.' }, 404); + } + + const pairingId = c.req.param('pairingId'); + const record = await getPairingRecord(pairingId); + const pollToken = readBearerToken(c.req.header('authorization')); + + // A missing record and a bad token look identical to the caller so the + // endpoint leaks nothing about which pairing ids exist. + if ( + !record || + !pollToken || + !safeCompareSecret(record.pollTokenHash, hashPollToken(pollToken)) + ) { + return c.json({ error: 'Unknown pairing.' }, 404); + } + + if (record.status !== 'ready' || !record.token) { + return c.json({ status: 'pending' }); + } + + // One-shot: the token is handed out exactly once, then forgotten. + await deletePairingRecord(record); + + return c.json({ + status: 'ready', + token: record.token, + botUsername: record.botUsername ?? null, + ownerTelegramUserId: record.ownerTelegramUserId ?? null, + ownerTelegramUsername: record.ownerTelegramUsername ?? null, + }); +}); + +export const telegramManagerWebhook = new Hono(); + +telegramManagerWebhook.post('/', async (c) => { + const config = getManagerBotConfig(); + if (!config) { + return c.json({ error: 'Telegram pairing is not enabled here.' }, 404); + } + + if ( + !safeCompareSecret( + config.webhookSecret, + c.req.header('x-telegram-bot-api-secret-token') ?? null, + ) + ) { + return c.json({ error: 'Invalid webhook secret.' }, 401); + } + + const update = await c.req.json().catch(() => null); + const managedBot = parseTelegramManagedBotUpdate(update); + if (!managedBot) { + // Not a managed_bot update; acknowledge so Telegram does not retry. + return c.json({ ok: true }); + } + + const pairingId = managedBot.botUsername + ? await findPairingIdByUsername(managedBot.botUsername) + : null; + if (!pairingId) { + apiLogger.warn( + { botUsername: managedBot.botUsername }, + 'telegram-pairing: managed_bot update without a matching pairing', + ); + return c.json({ ok: true }); + } + + try { + const token = await getManagedBotToken({ + managerBotToken: config.managerBotToken, + botUserId: managedBot.botUserId, + }); + await markPairingReady({ + pairingId, + token, + botUsername: managedBot.botUsername, + ownerTelegramUserId: managedBot.ownerTelegramUserId, + ownerTelegramUsername: managedBot.ownerTelegramUsername, + }); + } catch (error) { + // Leave the pairing pending; Telegram retries the update delivery, so a + // transient getManagedBotToken failure heals on the next attempt. + apiLogger.error( + { err: error, pairingId }, + 'telegram-pairing: failed to fetch managed bot token', + ); + return c.json({ error: 'Failed to fetch managed bot token.' }, 500); + } + + return c.json({ ok: true }); +}); diff --git a/apps/api/src/handlers/telegram-pairing/store.ts b/apps/api/src/handlers/telegram-pairing/store.ts new file mode 100644 index 000000000..b877a7b7f --- /dev/null +++ b/apps/api/src/handlers/telegram-pairing/store.ts @@ -0,0 +1,124 @@ +import crypto from 'node:crypto'; + +import { getRedis } from '@roomote/redis'; + +/** + * Redis-backed store for Telegram managed-bot pairings. A pairing lives for + * the few minutes between "admin clicked Create bot automatically" and "the + * manager bot received the managed_bot update and exported the child token". + * The suggested username is the only correlation key between the two halves, + * so records are looked up both by pairing id (client polling) and by + * username (manager webhook). + */ + +const PAIRING_ID_PREFIX = 'telegram-pairing:id:'; +const PAIRING_USERNAME_PREFIX = 'telegram-pairing:username:'; + +export const TELEGRAM_PAIRING_TTL_SECONDS = 15 * 60; + +type TelegramPairingRecord = { + pairingId: string; + /** SHA-256 hex of the bearer poll token; the raw token is never stored. */ + pollTokenHash: string; + suggestedUsername: string; + status: 'pending' | 'ready'; + token?: string; + botUsername?: string; + ownerTelegramUserId?: string; + ownerTelegramUsername?: string; +}; + +export function hashPollToken(pollToken: string): string { + return crypto.createHash('sha256').update(pollToken).digest('hex'); +} + +export async function createPairingRecord(input: { + suggestedUsername: string; +}): Promise<{ record: TelegramPairingRecord; pollToken: string }> { + const pairingId = crypto.randomUUID(); + const pollToken = crypto.randomBytes(32).toString('hex'); + const record: TelegramPairingRecord = { + pairingId, + pollTokenHash: hashPollToken(pollToken), + suggestedUsername: input.suggestedUsername, + status: 'pending', + }; + + const redis = getRedis(); + await redis.set( + `${PAIRING_ID_PREFIX}${pairingId}`, + JSON.stringify(record), + 'EX', + TELEGRAM_PAIRING_TTL_SECONDS, + ); + await redis.set( + `${PAIRING_USERNAME_PREFIX}${input.suggestedUsername}`, + pairingId, + 'EX', + TELEGRAM_PAIRING_TTL_SECONDS, + ); + + return { record, pollToken }; +} + +export async function getPairingRecord( + pairingId: string, +): Promise { + const raw = await getRedis().get(`${PAIRING_ID_PREFIX}${pairingId}`); + if (!raw) { + return null; + } + try { + return JSON.parse(raw) as TelegramPairingRecord; + } catch { + return null; + } +} + +export async function findPairingIdByUsername( + suggestedUsername: string, +): Promise { + return getRedis().get(`${PAIRING_USERNAME_PREFIX}${suggestedUsername}`); +} + +export async function markPairingReady(input: { + pairingId: string; + token: string; + botUsername: string | null; + ownerTelegramUserId: string; + ownerTelegramUsername: string | null; +}): Promise { + const record = await getPairingRecord(input.pairingId); + if (!record || record.status === 'ready') { + return; + } + + const next: TelegramPairingRecord = { + ...record, + status: 'ready', + token: input.token, + ...(input.botUsername ? { botUsername: input.botUsername } : {}), + ownerTelegramUserId: input.ownerTelegramUserId, + ...(input.ownerTelegramUsername + ? { ownerTelegramUsername: input.ownerTelegramUsername } + : {}), + }; + + // Refresh the TTL so the client's poll loop has the full window to pick + // the token up even when the user took a while to confirm in Telegram. + await getRedis().set( + `${PAIRING_ID_PREFIX}${input.pairingId}`, + JSON.stringify(next), + 'EX', + TELEGRAM_PAIRING_TTL_SECONDS, + ); +} + +/** Delete a pairing after its token was handed out (one-shot retrieval). */ +export async function deletePairingRecord( + record: TelegramPairingRecord, +): Promise { + const redis = getRedis(); + await redis.del(`${PAIRING_ID_PREFIX}${record.pairingId}`); + await redis.del(`${PAIRING_USERNAME_PREFIX}${record.suggestedUsername}`); +} diff --git a/apps/api/src/route-policies.ts b/apps/api/src/route-policies.ts index 2f05e80aa..cc440711b 100644 --- a/apps/api/src/route-policies.ts +++ b/apps/api/src/route-policies.ts @@ -207,6 +207,30 @@ export const ROUTE_POLICY_RULES: readonly RoutePolicyRule[] = [ policy: 'webhook', rateLimits: WEBHOOK_RATE_LIMITS, }, + // Manager-bot webhook for the managed-bot pairing service; the handler + // verifies Telegram's secret-token header before acting. + { + name: 'webhook-telegram-manager', + match: { type: 'prefix', path: '/api/webhooks/telegram-manager' }, + policy: 'webhook', + rateLimits: WEBHOOK_RATE_LIMITS, + }, + // Managed-bot pairing service. Creation is unauthenticated by design + // (other deployments call it before they have any credentials), so it gets + // a tight client-keyed ceiling; polling authenticates per-pairing with a + // bearer poll token and needs headroom for 2s poll loops. + { + name: 'telegram-pairing-create', + match: { type: 'exact', path: '/api/webhooks/telegram-pairing' }, + policy: 'webhook', + rateLimits: [{ keySource: 'client', limit: 30, windowSeconds: 60 }], + }, + { + name: 'telegram-pairing-poll', + match: { type: 'prefix', path: '/api/webhooks/telegram-pairing' }, + policy: 'webhook', + rateLimits: [{ keySource: 'client', limit: 600, windowSeconds: 60 }], + }, { name: 'internal-discord-events', match: { type: 'exact', path: '/api/internal/discord/events' }, diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 0a51a1980..2b8745f33 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -44,6 +44,8 @@ import { linear, teams, telegram, + telegramManagerWebhook, + telegramPairing, discord, inference, mcp, @@ -71,9 +73,15 @@ const PUBLIC_OIDC_PATHS = new Set([ const SELF_AUTHENTICATING_WEBHOOK_PATHS = new Set([ '/api/webhooks/teams', '/api/webhooks/telegram', + '/api/webhooks/telegram-manager', '/api/internal/discord/events', ]); +// The pairing poll endpoint authenticates with its own bearer poll token, +// which the Roomote token middleware must not try to parse. Mounted under +// /api/webhooks/ so the web app's existing webhook proxy exposes it. +const TELEGRAM_PAIRING_PATH_PREFIX = '/api/webhooks/telegram-pairing'; + type ListenOptions = { port: number; hostname?: string; @@ -84,7 +92,12 @@ function isPublicOidcPath(path: string): boolean { } function isPublicMiddlewareBypassPath(path: string): boolean { - return isPublicOidcPath(path) || SELF_AUTHENTICATING_WEBHOOK_PATHS.has(path); + return ( + isPublicOidcPath(path) || + SELF_AUTHENTICATING_WEBHOOK_PATHS.has(path) || + path === TELEGRAM_PAIRING_PATH_PREFIX || + path.startsWith(`${TELEGRAM_PAIRING_PATH_PREFIX}/`) + ); } function observedFetchImpl( @@ -192,6 +205,8 @@ export function createApiApp(): ApiApp { app.route('/api/webhooks/linear', linear); app.route('/api/webhooks/teams', teams); app.route('/api/webhooks/telegram', telegram); + app.route('/api/webhooks/telegram-manager', telegramManagerWebhook); + app.route('/api/webhooks/telegram-pairing', telegramPairing); app.route('/api/internal/discord', discord); app.route('/api/inference', inference); app.route('/api/mcp', mcp); diff --git a/apps/docs/providers/communications/telegram.mdx b/apps/docs/providers/communications/telegram.mdx index 3498a11c2..f21e248e0 100644 --- a/apps/docs/providers/communications/telegram.mdx +++ b/apps/docs/providers/communications/telegram.mdx @@ -16,6 +16,37 @@ Use `` below for your stable public Roomote URL. ## Create a Telegram bot +### Automatic setup (recommended) + +When the deployment has `R_TELEGRAM_PAIRING_URL` configured, the setup wizard +offers **Create my Telegram bot**: Roomote shows a QR code and deep link that +open Telegram, you tap **Create Bot** once, and Telegram's Managed Bots +feature (Bot API 9.6) hands the new bot's token straight to Roomote's pairing +service. No BotFather conversation and no token copy-paste; the token never +passes through your browser. Roomote then registers the webhook and command +menu exactly as in the manual flow. + +```sh +# On the deployment that should offer automatic setup: +R_TELEGRAM_PAIRING_URL= +``` + +To host the pairing service yourself, create a manager bot in BotFather, +enable **Bot Management Mode** for it, and set on the hosting deployment: + +```sh +R_TELEGRAM_MANAGER_BOT_TOKEN= +R_TELEGRAM_MANAGER_BOT_USERNAME= +R_TELEGRAM_MANAGER_WEBHOOK_SECRET= +``` + +The service exposes `POST /api/webhooks/telegram-pairing` (create a pairing) +and `GET /api/webhooks/telegram-pairing/` (poll, bearer-token +authenticated, single retrieval), and receives `managed_bot` updates at +`/api/webhooks/telegram-manager`. + +### Manual setup + Message `@BotFather` in Telegram and create or select a bot. In the Roomote UI (**Settings > Communications > Telegram**), enter the bot token, then save. Roomote reads the bot identity from Telegram, generates a webhook secret, diff --git a/apps/web/package.json b/apps/web/package.json index b152e25fd..98967395f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -93,6 +93,7 @@ "next-themes": "^0.4.6", "pino": "^9.7.0", "pino-pretty": "^13.1.3", + "qrcode.react": "^4.2.0", "react": "^19.2.4", "react-dom": "^19.2.4", "react-hook-form": "^7.60.0", diff --git a/apps/web/src/app/(onboarding)/setup/StepTelegramSetup.tsx b/apps/web/src/app/(onboarding)/setup/StepTelegramSetup.tsx index 1e03176b1..f45de2a24 100644 --- a/apps/web/src/app/(onboarding)/setup/StepTelegramSetup.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepTelegramSetup.tsx @@ -17,6 +17,10 @@ import { } from './ProviderSetupExperience'; import { StepTitle } from './StepTitle'; import { SetupFooter } from './SetupFooter'; +import { + TelegramManagedBotPairing, + type TelegramPairingSuccess, +} from './TelegramManagedBotPairing'; export function StepTelegramSetup({ onContinue, @@ -30,6 +34,7 @@ export function StepTelegramSetup({ const status = useQuery(trpc.comms.status.queryOptions()); const telegramAccount = useTelegramLinkedAccount({ refetchInterval: 2_000 }); const [credentialsSaved, setCredentialsSaved] = useState(false); + const [useManualSetup, setUseManualSetup] = useState(false); const [values, setValues] = useState>({}); const [editingSavedValues, setEditingSavedValues] = useState< Record @@ -64,6 +69,31 @@ export function StepTelegramSetup({ }), ); const isConfigured = credentialsSaved || provider?.setupSatisfied === true; + const pairingAvailable = provider?.telegramPairingAvailable === true; + const showAutomaticSetup = + pairingAvailable && !isConfigured && !useManualSetup; + const handlePaired = async (result: TelegramPairingSuccess) => { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: trpc.comms.status.queryKey(), + }), + queryClient.invalidateQueries({ + queryKey: trpc.linkedAccounts.telegram.queryKey(), + }), + ]); + if (result.webhookWarning) { + toast.warning( + `Your bot was created, but Roomote could not connect it: ${result.webhookWarning}`, + ); + } else { + toast.success( + result.botUsername + ? `@${result.botUsername} is connected.` + : 'Your Telegram bot is connected.', + ); + } + setCredentialsSaved(true); + }; const isActionDisabled = save.isPending || status.isLoading || @@ -84,36 +114,62 @@ export function StepTelegramSetup({ return (
- {provider && !isConfigured ? ( - - setValues((current) => ({ ...current, [envVarName]: value })) - } - onEditingSavedValueChange={(envVarName, editing) => - setEditingSavedValues((current) => ({ - ...current, - [envVarName]: editing, - })) - } - onClearedSavedValueChange={(envVarName, cleared) => - setClearedSavedValues((current) => ({ - ...current, - [envVarName]: cleared, - })) - } - /> + {provider && showAutomaticSetup ? ( +
+ + + +
+ ) : provider && !isConfigured ? ( +
+ {pairingAvailable && ( + + )} + + setValues((current) => ({ ...current, [envVarName]: value })) + } + onEditingSavedValueChange={(envVarName, editing) => + setEditingSavedValues((current) => ({ + ...current, + [envVarName]: editing, + })) + } + onClearedSavedValueChange={(envVarName, cleared) => + setClearedSavedValues((current) => ({ + ...current, + [envVarName]: cleared, + })) + } + /> +
) : provider && isConfigured ? (
@@ -132,30 +188,36 @@ export function StepTelegramSetup({ )} - + ) : ( + + onClick={() => { + if (isConfigured) { + onContinue(); + return; + } + if (!provider) return; + save.mutate({ + provider: 'telegram', + values: getSetupSubmitValues({ provider, values }), + }); + }} + > + {save.isPending + ? 'Saving...' + : isConfigured + ? 'Continue' + : 'Save and link account'} + {save.isPending ? : } + + )}
); diff --git a/apps/web/src/app/(onboarding)/setup/TelegramManagedBotPairing.tsx b/apps/web/src/app/(onboarding)/setup/TelegramManagedBotPairing.tsx new file mode 100644 index 000000000..7b57569b1 --- /dev/null +++ b/apps/web/src/app/(onboarding)/setup/TelegramManagedBotPairing.tsx @@ -0,0 +1,156 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useMutation } from '@tanstack/react-query'; +import { QRCodeSVG } from 'qrcode.react'; +import { toast } from 'sonner'; + +import { useTRPC } from '@/trpc/client'; +import { Button, ExternalLink, Spinner } from '@/components/system'; + +const PAIRING_POLL_INTERVAL_MS = 2_000; + +export type TelegramPairingSuccess = { + botUsername: string | null; + webhookWarning: string | null; +}; + +/** + * Automatic Telegram bot creation via Telegram's Managed Bots feature: one + * tap in Telegram creates a deployment-owned bot, and the pairing service + * hands the token straight to the server, so the admin never touches + * BotFather or copies a token. + */ +export function TelegramManagedBotPairing({ + onPaired, + disabled, +}: { + onPaired: (result: TelegramPairingSuccess) => void; + disabled?: boolean; +}) { + const trpc = useTRPC(); + const [pairing, setPairing] = useState<{ + pairingId: string; + deepLink: string; + } | null>(null); + const [expired, setExpired] = useState(false); + const pollBusyRef = useRef(false); + + const start = useMutation( + trpc.comms.startTelegramPairing.mutationOptions({ + onSuccess: (result) => { + setExpired(false); + setPairing({ pairingId: result.pairingId, deepLink: result.deepLink }); + }, + onError: (error) => toast.error(error.message), + }), + ); + + const check = useMutation(trpc.comms.checkTelegramPairing.mutationOptions()); + const checkMutateAsync = check.mutateAsync; + + const stopPairing = useCallback(() => { + setPairing(null); + }, []); + + useEffect(() => { + if (!pairing) { + return; + } + + const interval = setInterval(async () => { + if (pollBusyRef.current) { + return; + } + pollBusyRef.current = true; + try { + const result = await checkMutateAsync({ + pairingId: pairing.pairingId, + }); + if (result.status === 'ready') { + setPairing(null); + onPaired({ + botUsername: result.botUsername, + webhookWarning: + result.telegramWebhook && !result.telegramWebhook.registered + ? (result.telegramWebhook.error ?? 'unknown error') + : null, + }); + } else if (result.status === 'expired') { + setPairing(null); + setExpired(true); + } + } catch (error) { + setPairing(null); + toast.error( + error instanceof Error + ? error.message + : 'Telegram pairing failed. Try again.', + ); + } finally { + pollBusyRef.current = false; + } + }, PAIRING_POLL_INTERVAL_MS); + + return () => clearInterval(interval); + }, [pairing, checkMutateAsync, onPaired]); + + if (pairing) { + return ( +
+

+ Scan this QR code with your phone, or open the link on this device. + When Telegram opens, tap{' '} + Create Bot to confirm. +

+
+
+ +
+
+ +
+ + Waiting for you to confirm in Telegram… +
+ +
+
+
+ ); + } + + return ( +
+

+ Roomote can create your Telegram bot for you — no BotFather, no token + copy-paste. You confirm the new bot with one tap in Telegram and Roomote + connects it automatically. +

+ {expired && ( +

+ That pairing expired before the bot was created. Start again when + you're ready. +

+ )} + +
+ ); +} diff --git a/apps/web/src/trpc/commands/comms/index.ts b/apps/web/src/trpc/commands/comms/index.ts index 860eacd1e..404d5ff94 100644 --- a/apps/web/src/trpc/commands/comms/index.ts +++ b/apps/web/src/trpc/commands/comms/index.ts @@ -133,6 +133,11 @@ function createTelegramWebhookSecret() { return crypto.randomUUID(); } +/** Whether the automatic managed-bot Telegram setup path is configured. */ +function isTelegramPairingAvailable(): boolean { + return Boolean(Env.R_TELEGRAM_PAIRING_URL); +} + function createDiscordGatewaySecret() { return crypto.randomUUID(); } @@ -144,6 +149,7 @@ export type CommsProviderStatus = Omit< id: CommsProviderId; telegramWebhook?: TelegramWebhookStatus | null; telegramBotUsername?: string | null; + telegramPairingAvailable?: boolean; discord?: DiscordCommsStatus | null; }; @@ -854,7 +860,11 @@ function withAdditionalCommsProviders( isSatisfied(field.envVarName), ), ...(definition.id === 'telegram' - ? { telegramWebhook, telegramBotUsername } + ? { + telegramWebhook, + telegramBotUsername, + telegramPairingAvailable: isTelegramPairingAvailable(), + } : {}), ...(definition.id === 'discord' ? { discord } : {}), }; diff --git a/apps/web/src/trpc/commands/comms/telegram-pairing.test.ts b/apps/web/src/trpc/commands/comms/telegram-pairing.test.ts new file mode 100644 index 000000000..87176b857 --- /dev/null +++ b/apps/web/src/trpc/commands/comms/telegram-pairing.test.ts @@ -0,0 +1,213 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { UserAuthSuccess } from '@/types'; + +const { envMock, redisStore, redisMock, saveCommsAuthConfigMock } = vi.hoisted( + () => { + const store = new Map(); + return { + envMock: { + R_TELEGRAM_PAIRING_URL: 'https://pairing.example.com' as + | string + | undefined, + }, + redisStore: store, + redisMock: { + get: vi.fn(async (key: string) => store.get(key) ?? null), + set: vi.fn(async (key: string, value: string, ..._args: unknown[]) => { + store.set(key, value); + return 'OK'; + }), + del: vi.fn(async (key: string) => { + store.delete(key); + return 1; + }), + }, + saveCommsAuthConfigMock: vi.fn(async () => ({ + telegramWebhook: { registered: true, error: null }, + })), + }; + }, +); + +vi.mock('@/lib/server/env', () => ({ Env: envMock })); +vi.mock('@roomote/redis', () => ({ getRedis: () => redisMock })); +vi.mock('../environment-variables', () => ({ + assertAdmin: (auth: UserAuthSuccess) => { + if (!auth.isAdmin) { + throw new Error('Admin required'); + } + }, +})); +vi.mock('./index', () => ({ + isTelegramPairingAvailable: () => Boolean(envMock.R_TELEGRAM_PAIRING_URL), + saveCommsAuthConfigCommand: saveCommsAuthConfigMock, +})); + +import { + checkTelegramPairingCommand, + startTelegramPairingCommand, +} from './telegram-pairing'; + +const CHILD_BOT_TOKEN = '7000000003:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; +const PAIRING_ID = '0b7ab291-5faf-4c4e-9f5d-1a2b3c4d5e6f'; + +function buildMockAuth( + overrides: Partial = {}, +): UserAuthSuccess { + return { + success: true, + userType: 'user', + userId: 'pairing-test-user', + isAdmin: true, + ...overrides, + } as UserAuthSuccess; +} + +function stubFetchJson(status: number, payload: unknown) { + const fetchMock = vi.fn(async () => + Response.json(payload as Record, { status }), + ); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} + +describe('telegram pairing commands', () => { + beforeEach(() => { + redisStore.clear(); + envMock.R_TELEGRAM_PAIRING_URL = 'https://pairing.example.com'; + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + describe('startTelegramPairingCommand', () => { + it('rejects non-admin users', async () => { + await expect( + startTelegramPairingCommand(buildMockAuth({ isAdmin: false })), + ).rejects.toThrow('Admin required'); + }); + + it('throws a friendly error when pairing is not configured', async () => { + envMock.R_TELEGRAM_PAIRING_URL = undefined; + + await expect( + startTelegramPairingCommand(buildMockAuth()), + ).rejects.toThrow(/not configured/); + }); + + it('creates a pairing and stores the poll token server-side', async () => { + const fetchMock = stubFetchJson(201, { + pairingId: PAIRING_ID, + pollToken: 'secret-poll-token', + suggestedUsername: 'roomote_abc_bot', + deepLink: 'https://t.me/newbot/RoomoteSetupBot/roomote_abc_bot', + expiresInSeconds: 900, + }); + + const result = await startTelegramPairingCommand(buildMockAuth()); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://pairing.example.com/api/webhooks/telegram-pairing', + expect.objectContaining({ method: 'POST' }), + ); + expect(result).toEqual({ + pairingId: PAIRING_ID, + deepLink: 'https://t.me/newbot/RoomoteSetupBot/roomote_abc_bot', + suggestedUsername: 'roomote_abc_bot', + expiresInSeconds: 900, + }); + // The poll token stays server-side and is never returned. + expect(redisStore.get(`telegram-pairing-client:${PAIRING_ID}`)).toBe( + 'secret-poll-token', + ); + }); + + it('surfaces a friendly error when the service is unreachable', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new Error('network down'); + }), + ); + + await expect( + startTelegramPairingCommand(buildMockAuth()), + ).rejects.toThrow(/Could not reach the Telegram setup service/); + }); + }); + + describe('checkTelegramPairingCommand', () => { + it('reports expired when no poll token is stored', async () => { + await expect( + checkTelegramPairingCommand(buildMockAuth(), { + pairingId: PAIRING_ID, + }), + ).resolves.toEqual({ status: 'expired' }); + }); + + it('reports pending while the pairing is not ready', async () => { + redisStore.set(`telegram-pairing-client:${PAIRING_ID}`, 'token'); + stubFetchJson(200, { status: 'pending' }); + + await expect( + checkTelegramPairingCommand(buildMockAuth(), { + pairingId: PAIRING_ID, + }), + ).resolves.toEqual({ status: 'pending' }); + expect(saveCommsAuthConfigMock).not.toHaveBeenCalled(); + }); + + it('saves the bot token through the shared save path when ready', async () => { + redisStore.set(`telegram-pairing-client:${PAIRING_ID}`, 'token'); + stubFetchJson(200, { + status: 'ready', + token: CHILD_BOT_TOKEN, + botUsername: 'roomote_abc_bot', + }); + + const auth = buildMockAuth(); + const result = await checkTelegramPairingCommand(auth, { + pairingId: PAIRING_ID, + }); + + expect(saveCommsAuthConfigMock).toHaveBeenCalledWith(auth, { + provider: 'telegram', + values: { R_TELEGRAM_BOT_TOKEN: CHILD_BOT_TOKEN }, + }); + expect(result).toEqual({ + status: 'ready', + botUsername: 'roomote_abc_bot', + telegramWebhook: { registered: true, error: null }, + }); + expect(redisStore.has(`telegram-pairing-client:${PAIRING_ID}`)).toBe( + false, + ); + }); + + it('treats a 404 from the service as an expired pairing', async () => { + redisStore.set(`telegram-pairing-client:${PAIRING_ID}`, 'token'); + stubFetchJson(404, { error: 'Unknown pairing.' }); + + await expect( + checkTelegramPairingCommand(buildMockAuth(), { + pairingId: PAIRING_ID, + }), + ).resolves.toEqual({ status: 'expired' }); + expect(redisStore.has(`telegram-pairing-client:${PAIRING_ID}`)).toBe( + false, + ); + }); + + it('rejects a malformed token without saving it', async () => { + redisStore.set(`telegram-pairing-client:${PAIRING_ID}`, 'token'); + stubFetchJson(200, { status: 'ready', token: 'not-a-bot-token' }); + + await expect( + checkTelegramPairingCommand(buildMockAuth(), { + pairingId: PAIRING_ID, + }), + ).rejects.toThrow(/invalid bot token/); + expect(saveCommsAuthConfigMock).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/web/src/trpc/commands/comms/telegram-pairing.ts b/apps/web/src/trpc/commands/comms/telegram-pairing.ts new file mode 100644 index 000000000..e697f40c2 --- /dev/null +++ b/apps/web/src/trpc/commands/comms/telegram-pairing.ts @@ -0,0 +1,172 @@ +import { getRedis } from '@roomote/redis'; +import { isValidTelegramBotToken } from '@roomote/communication/telegram-managed-bot'; + +import { Env } from '@/lib/server/env'; +import type { UserAuthSuccess } from '@/types'; + +import { assertAdmin } from '../environment-variables'; +import { saveCommsAuthConfigCommand } from './index'; + +/** + * Client half of Telegram managed-bot pairing: talks to the pairing service + * at `R_TELEGRAM_PAIRING_URL` (Roomote's hosted instance by default, or a + * self-hosted one). The secret poll token stays server-side in Redis so the + * bot token never passes through the admin's browser; when the pairing + * completes we feed the token straight into the regular Telegram save path, + * which persists it, generates the webhook secret, and registers the + * webhook. + */ + +const PAIRING_SERVICE_PATH = '/api/webhooks/telegram-pairing'; +const CLIENT_PAIRING_KEY_PREFIX = 'telegram-pairing-client:'; +const PAIRING_REQUEST_TIMEOUT_MS = 10_000; +const DEFAULT_PAIRING_TTL_SECONDS = 15 * 60; + +function getPairingServiceBaseUrl(): string { + const base = Env.R_TELEGRAM_PAIRING_URL; + if (!base) { + throw new Error( + 'Automatic Telegram setup is not configured for this deployment.', + ); + } + return new URL(PAIRING_SERVICE_PATH, base).toString(); +} + +type TelegramPairingStartResult = { + pairingId: string; + deepLink: string; + suggestedUsername: string; + expiresInSeconds: number; +}; + +export async function startTelegramPairingCommand( + auth: UserAuthSuccess, +): Promise { + assertAdmin(auth); + + const response = await fetch(getPairingServiceBaseUrl(), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ botName: 'Roomote' }), + signal: AbortSignal.timeout(PAIRING_REQUEST_TIMEOUT_MS), + }).catch(() => null); + + const payload = response?.ok + ? ((await response.json().catch(() => null)) as { + pairingId?: unknown; + pollToken?: unknown; + suggestedUsername?: unknown; + deepLink?: unknown; + expiresInSeconds?: unknown; + } | null) + : null; + + if ( + !payload || + typeof payload.pairingId !== 'string' || + typeof payload.pollToken !== 'string' || + typeof payload.suggestedUsername !== 'string' || + typeof payload.deepLink !== 'string' + ) { + throw new Error( + 'Could not reach the Telegram setup service. Try again, or enter a bot token manually.', + ); + } + + const expiresInSeconds = + typeof payload.expiresInSeconds === 'number' && + Number.isSafeInteger(payload.expiresInSeconds) && + payload.expiresInSeconds > 0 + ? payload.expiresInSeconds + : DEFAULT_PAIRING_TTL_SECONDS; + + await getRedis().set( + `${CLIENT_PAIRING_KEY_PREFIX}${payload.pairingId}`, + payload.pollToken, + 'EX', + expiresInSeconds, + ); + + return { + pairingId: payload.pairingId, + deepLink: payload.deepLink, + suggestedUsername: payload.suggestedUsername, + expiresInSeconds, + }; +} + +type TelegramPairingCheckResult = + | { status: 'pending' } + | { status: 'expired' } + | { + status: 'ready'; + botUsername: string | null; + telegramWebhook: { registered: boolean; error: string | null } | null; + }; + +export async function checkTelegramPairingCommand( + auth: UserAuthSuccess, + input: { pairingId: string }, +): Promise { + assertAdmin(auth); + + const redisKey = `${CLIENT_PAIRING_KEY_PREFIX}${input.pairingId}`; + const pollToken = await getRedis().get(redisKey); + if (!pollToken) { + return { status: 'expired' }; + } + + const response = await fetch( + `${getPairingServiceBaseUrl()}/${encodeURIComponent(input.pairingId)}`, + { + headers: { Authorization: `Bearer ${pollToken}` }, + signal: AbortSignal.timeout(PAIRING_REQUEST_TIMEOUT_MS), + }, + ).catch(() => null); + + if (!response) { + return { status: 'pending' }; + } + + if (response.status === 404) { + // The service forgot the pairing (expired or already consumed). + await getRedis().del(redisKey); + return { status: 'expired' }; + } + + const payload = (await response.json().catch(() => null)) as { + status?: unknown; + token?: unknown; + botUsername?: unknown; + } | null; + + if (payload?.status !== 'ready') { + return { status: 'pending' }; + } + + if (!isValidTelegramBotToken(payload.token)) { + await getRedis().del(redisKey); + throw new Error( + 'The Telegram setup service returned an invalid bot token. Try again, or enter a bot token manually.', + ); + } + + // The token is single-retrieval on the service side, so persist it before + // anything else can fail; the shared save path also generates the webhook + // secret and registers the webhook. + const saved = await saveCommsAuthConfigCommand(auth, { + provider: 'telegram', + values: { R_TELEGRAM_BOT_TOKEN: payload.token }, + }); + + await getRedis().del(redisKey); + + return { + status: 'ready', + botUsername: + typeof payload.botUsername === 'string' && payload.botUsername.length > 0 + ? payload.botUsername + : null, + telegramWebhook: saved.telegramWebhook ?? null, + }; +} diff --git a/apps/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts index 7616591f2..11286b5f8 100644 --- a/apps/web/src/trpc/routers/_app.ts +++ b/apps/web/src/trpc/routers/_app.ts @@ -260,6 +260,10 @@ import { repairTelegramWebhookCommand, selectDiscordDestinationCommand, } from '../commands/comms'; +import { + checkTelegramPairingCommand, + startTelegramPairingCommand, +} from '../commands/comms/telegram-pairing'; import { getComputeStatusCommand, saveComputeConfigCommand, @@ -1484,6 +1488,16 @@ export const appRouter = createRouter({ repairTelegramWebhookCommand(auth), ), + startTelegramPairing: protectedProcedure.mutation(({ ctx: { auth } }) => + startTelegramPairingCommand(auth), + ), + + checkTelegramPairing: protectedProcedure + .input(z.object({ pairingId: z.string().uuid() })) + .mutation(({ ctx: { auth }, input }) => + checkTelegramPairingCommand(auth, input), + ), + listDiscordGuilds: protectedProcedure.query(({ ctx: { auth } }) => listDiscordGuildsCommand(auth), ), diff --git a/packages/communication/package.json b/packages/communication/package.json index fb99c7354..b8c26b149 100644 --- a/packages/communication/package.json +++ b/packages/communication/package.json @@ -18,6 +18,7 @@ "./teams-bot-framework-client": "./src/teams-bot-framework-client.ts", "./teams-graph-client": "./src/teams-graph-client.ts", "./teams-provider": "./src/teams-provider.ts", + "./telegram-managed-bot": "./src/telegram-managed-bot.ts", "./telegram-provider": "./src/telegram-provider.ts", "./telegram-update": "./src/telegram-update.ts", "./thread-reply-footer-state": "./src/thread-reply-footer-state.ts" diff --git a/packages/communication/src/__tests__/telegram-managed-bot.test.ts b/packages/communication/src/__tests__/telegram-managed-bot.test.ts new file mode 100644 index 000000000..b480139ad --- /dev/null +++ b/packages/communication/src/__tests__/telegram-managed-bot.test.ts @@ -0,0 +1,161 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { + MockTelegramServer, + type MockTelegramState, +} from '../mock-telegram-server'; +import { + buildManagedBotDeepLink, + generateManagedBotUsername, + getManagedBotToken, + isManagedBotUsername, + isValidTelegramBotToken, + parseTelegramManagedBotUpdate, + registerManagerBotWebhook, +} from '../telegram-managed-bot'; + +const MANAGER_BOT_TOKEN = '7000000002:mock-manager-token'; +const CHILD_BOT_TOKEN = '7000000003:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; + +function managerState(): MockTelegramState { + return { + chats: [], + users: [], + managedBotTokens: { '7000000003': CHILD_BOT_TOKEN }, + }; +} + +describe('telegram-managed-bot', () => { + const cleanups: Array<() => Promise> = []; + + afterEach(async () => { + while (cleanups.length > 0) { + await cleanups.pop()?.(); + } + }); + + describe('generateManagedBotUsername', () => { + it('produces distinct usernames within Telegram limits', () => { + const first = generateManagedBotUsername(); + const second = generateManagedBotUsername(); + + expect(first).not.toEqual(second); + expect(first).toMatch(/^roomote_[a-z2-7]{16}_bot$/); + expect(first.length).toBeLessThanOrEqual(32); + expect(isManagedBotUsername(first)).toBe(true); + }); + }); + + describe('isValidTelegramBotToken', () => { + it('accepts the bot token shape and rejects everything else', () => { + expect(isValidTelegramBotToken(CHILD_BOT_TOKEN)).toBe(true); + expect(isValidTelegramBotToken('not-a-token')).toBe(false); + expect(isValidTelegramBotToken('123:short')).toBe(false); + expect(isValidTelegramBotToken(null)).toBe(false); + expect(isValidTelegramBotToken(42)).toBe(false); + }); + }); + + describe('buildManagedBotDeepLink', () => { + it('builds the t.me/newbot deep link with an encoded display name', () => { + expect( + buildManagedBotDeepLink({ + managerBotUsername: '@RoomoteSetupBot', + suggestedUsername: 'roomote_abc_bot', + botName: 'Roomote (Acme)', + }), + ).toBe( + 'https://t.me/newbot/RoomoteSetupBot/roomote_abc_bot?name=Roomote+%28Acme%29', + ); + }); + + it('omits the name parameter when no display name is given', () => { + expect( + buildManagedBotDeepLink({ + managerBotUsername: 'RoomoteSetupBot', + suggestedUsername: 'roomote_abc_bot', + }), + ).toBe('https://t.me/newbot/RoomoteSetupBot/roomote_abc_bot'); + }); + }); + + describe('parseTelegramManagedBotUpdate', () => { + it('extracts owner and bot identity from a managed_bot update', () => { + const parsed = parseTelegramManagedBotUpdate({ + update_id: 1, + managed_bot: { + user: { id: 111000111, username: 'grace_mock' }, + bot: { id: 7000000003, is_bot: true, username: 'roomote_abc_bot' }, + }, + }); + + expect(parsed).toEqual({ + ownerTelegramUserId: '111000111', + ownerTelegramUsername: 'grace_mock', + botUserId: '7000000003', + botUsername: 'roomote_abc_bot', + }); + }); + + it('returns null for other update types and malformed payloads', () => { + expect( + parseTelegramManagedBotUpdate({ update_id: 1, message: {} }), + ).toBeNull(); + expect(parseTelegramManagedBotUpdate(null)).toBeNull(); + expect( + parseTelegramManagedBotUpdate({ managed_bot: { user: {} } }), + ).toBeNull(); + }); + }); + + describe('getManagedBotToken', () => { + it('fetches the child bot token from the Bot API', async () => { + const server = new MockTelegramServer({ state: managerState() }); + const baseUrl = await server.start(); + cleanups.push(() => server.stop()); + + await expect( + getManagedBotToken({ + managerBotToken: MANAGER_BOT_TOKEN, + botUserId: '7000000003', + apiBaseUrl: baseUrl, + }), + ).resolves.toBe(CHILD_BOT_TOKEN); + }); + + it('throws when the bot is unknown to the manager', async () => { + const server = new MockTelegramServer({ state: managerState() }); + const baseUrl = await server.start(); + cleanups.push(() => server.stop()); + + await expect( + getManagedBotToken({ + managerBotToken: MANAGER_BOT_TOKEN, + botUserId: '999', + apiBaseUrl: baseUrl, + }), + ).rejects.toThrow(/getManagedBotToken failed/); + }); + }); + + describe('registerManagerBotWebhook', () => { + it('registers a webhook restricted to managed_bot updates', async () => { + const server = new MockTelegramServer({ state: managerState() }); + const baseUrl = await server.start(); + cleanups.push(() => server.stop()); + + await registerManagerBotWebhook({ + managerBotToken: MANAGER_BOT_TOKEN, + webhookUrl: 'https://example.com/api/webhooks/telegram-manager', + webhookSecret: 'shh', + apiBaseUrl: baseUrl, + }); + + expect(server.getState().webhook).toEqual({ + url: 'https://example.com/api/webhooks/telegram-manager', + secretToken: 'shh', + allowedUpdates: ['managed_bot'], + }); + }); + }); +}); diff --git a/packages/communication/src/mock-telegram-server.ts b/packages/communication/src/mock-telegram-server.ts index 5eae287de..1f47256d4 100644 --- a/packages/communication/src/mock-telegram-server.ts +++ b/packages/communication/src/mock-telegram-server.ts @@ -95,6 +95,11 @@ export type MockTelegramState = { callbackAnswers?: MockTelegramCallbackAnswer[]; chatActions?: MockTelegramChatAction[]; botCommands?: Array<{ command: string; description: string }>; + /** + * Managed Bots support (Bot API 9.6): child-bot tokens returned by + * `getManagedBotToken`, keyed by the child bot's user id. + */ + managedBotTokens?: Record; behavior?: MockTelegramBehavior; }; @@ -866,6 +871,19 @@ export class MockTelegramServer { return; } + case 'getManagedBotToken': { + const botUserId = String(body.bot_user_id ?? ''); + const token = this.state.managedBotTokens?.[botUserId]; + + if (!token) { + apiError(response, 400, 'Bad Request: managed bot not found'); + return; + } + + apiResult(response, { token }); + return; + } + case 'deleteWebhook': { delete this.state.webhook; apiResult(response, true); diff --git a/packages/communication/src/telegram-managed-bot.ts b/packages/communication/src/telegram-managed-bot.ts new file mode 100644 index 000000000..4efe9f4bc --- /dev/null +++ b/packages/communication/src/telegram-managed-bot.ts @@ -0,0 +1,210 @@ +import { randomInt } from 'node:crypto'; + +import { getTelegramApiBaseUrl } from './telegram-api-base-url'; + +/** + * Helpers for Telegram's Managed Bots feature (Bot API 9.6): a "manager bot" + * with Bot Management Mode enabled can receive `managed_bot` updates when a + * user confirms creation via a `t.me/newbot//` deep link, + * then fetch the child bot's token with `getManagedBotToken`. Roomote uses + * this to create a deployment's bot without manual BotFather token + * copy-paste. + */ + +// Telegram bot usernames allow letters, digits, and underscores. The slug is +// the only correlation key between the deep link we hand out and the +// `managed_bot` update we later receive, so it has to be unguessable: +// 16 chars from a 32-symbol alphabet gives 80 bits of entropy while keeping +// `roomote__bot` within Telegram's 32-character username limit. +const USERNAME_SLUG_ALPHABET = 'abcdefghijklmnopqrstuvwxyz234567'; +const USERNAME_SLUG_LENGTH = 16; + +const MANAGED_BOT_USERNAME_PREFIX = 'roomote_'; +const MANAGED_BOT_USERNAME_SUFFIX = '_bot'; + +const TELEGRAM_BOT_TOKEN_RE = /^\d+:[A-Za-z0-9_-]{30,}$/; + +const MANAGED_BOT_API_TIMEOUT_MS = 10_000; + +export function generateManagedBotUsername(): string { + let slug = ''; + for (let index = 0; index < USERNAME_SLUG_LENGTH; index += 1) { + slug += USERNAME_SLUG_ALPHABET[randomInt(USERNAME_SLUG_ALPHABET.length)]; + } + return `${MANAGED_BOT_USERNAME_PREFIX}${slug}${MANAGED_BOT_USERNAME_SUFFIX}`; +} + +export function isManagedBotUsername(value: string): boolean { + return ( + value.startsWith(MANAGED_BOT_USERNAME_PREFIX) && + value.endsWith(MANAGED_BOT_USERNAME_SUFFIX) && + value.length <= 32 + ); +} + +export function isValidTelegramBotToken(value: unknown): value is string { + return typeof value === 'string' && TELEGRAM_BOT_TOKEN_RE.test(value); +} + +export function buildManagedBotDeepLink(input: { + managerBotUsername: string; + suggestedUsername: string; + botName?: string; +}): string { + const manager = input.managerBotUsername.replace(/^@/, ''); + const base = `https://t.me/newbot/${encodeURIComponent(manager)}/${encodeURIComponent(input.suggestedUsername)}`; + if (!input.botName) { + return base; + } + return `${base}?${new URLSearchParams({ name: input.botName }).toString()}`; +} + +export type TelegramManagedBotUpdate = { + /** Telegram user id of the person who created (owns) the bot. */ + ownerTelegramUserId: string; + ownerTelegramUsername: string | null; + /** Telegram user id of the newly created bot. */ + botUserId: string; + botUsername: string | null; +}; + +/** + * Extract the `managed_bot` payload from a raw webhook update body. Returns + * null for every other update type so callers can cheaply ignore them. + */ +export function parseTelegramManagedBotUpdate( + update: unknown, +): TelegramManagedBotUpdate | null { + if (typeof update !== 'object' || update === null) { + return null; + } + + const managedBot = (update as { managed_bot?: unknown }).managed_bot; + if (typeof managedBot !== 'object' || managedBot === null) { + return null; + } + + const { user, bot } = managedBot as { user?: unknown; bot?: unknown }; + const ownerId = readTelegramUserId(user); + const botId = readTelegramUserId(bot); + if (!ownerId || !botId) { + return null; + } + + return { + ownerTelegramUserId: ownerId, + ownerTelegramUsername: readTelegramUsername(user), + botUserId: botId, + botUsername: readTelegramUsername(bot), + }; +} + +function readTelegramUserId(value: unknown): string | null { + if (typeof value !== 'object' || value === null) { + return null; + } + const id = (value as { id?: unknown }).id; + if (typeof id === 'number' && Number.isSafeInteger(id) && id > 0) { + return String(id); + } + if (typeof id === 'string' && /^\d+$/.test(id)) { + return id; + } + return null; +} + +function readTelegramUsername(value: unknown): string | null { + if (typeof value !== 'object' || value === null) { + return null; + } + const username = (value as { username?: unknown }).username; + return typeof username === 'string' && username.length > 0 ? username : null; +} + +async function callManagerBotApi(input: { + managerBotToken: string; + method: string; + body: Record; + apiBaseUrl?: string; + fetchImpl?: typeof fetch; +}): Promise { + const fetchImpl = input.fetchImpl ?? fetch; + const response = await fetchImpl( + `${input.apiBaseUrl ?? getTelegramApiBaseUrl()}/bot${input.managerBotToken}/${input.method}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input.body), + signal: AbortSignal.timeout(MANAGED_BOT_API_TIMEOUT_MS), + }, + ); + + const payload = (await response.json().catch(() => null)) as { + ok?: boolean; + result?: unknown; + description?: string; + } | null; + + if (!response.ok || !payload?.ok) { + throw new Error( + `Telegram ${input.method} failed (${response.status}): ${payload?.description ?? 'unknown error'}`, + ); + } + + return payload.result; +} + +/** Fetch a managed child bot's token via the manager bot. */ +export async function getManagedBotToken(input: { + managerBotToken: string; + botUserId: string; + apiBaseUrl?: string; + fetchImpl?: typeof fetch; +}): Promise { + const result = await callManagerBotApi({ + managerBotToken: input.managerBotToken, + method: 'getManagedBotToken', + body: { bot_user_id: Number(input.botUserId) }, + apiBaseUrl: input.apiBaseUrl, + fetchImpl: input.fetchImpl, + }); + + const token = + typeof result === 'string' + ? result + : typeof result === 'object' && result !== null + ? (result as { token?: unknown }).token + : null; + + if (!isValidTelegramBotToken(token)) { + throw new Error( + 'Telegram getManagedBotToken returned an unrecognized token shape.', + ); + } + + return token; +} + +/** + * Point the manager bot's webhook at the pairing service so `managed_bot` + * updates arrive there. Idempotent; callers may re-run it best-effort. + */ +export async function registerManagerBotWebhook(input: { + managerBotToken: string; + webhookUrl: string; + webhookSecret: string; + apiBaseUrl?: string; + fetchImpl?: typeof fetch; +}): Promise { + await callManagerBotApi({ + managerBotToken: input.managerBotToken, + method: 'setWebhook', + body: { + url: input.webhookUrl, + secret_token: input.webhookSecret, + allowed_updates: ['managed_bot'], + }, + apiBaseUrl: input.apiBaseUrl, + fetchImpl: input.fetchImpl, + }); +} diff --git a/packages/env/src/index.ts b/packages/env/src/index.ts index 5c4fb4bb8..41ecf602f 100644 --- a/packages/env/src/index.ts +++ b/packages/env/src/index.ts @@ -180,6 +180,14 @@ const serverSchema = { R_TEAMS_BOT_OAUTH_SCOPE: z.string().min(1).optional(), R_TELEGRAM_BOT_TOKEN: z.string().min(1).optional(), R_TELEGRAM_WEBHOOK_SECRET: z.string().min(1).optional(), + // Managed-bot pairing: set on the deployment that hosts the pairing + // service (Roomote's central one, or a self-hoster's own manager bot). + R_TELEGRAM_MANAGER_BOT_TOKEN: z.string().min(1).optional(), + R_TELEGRAM_MANAGER_BOT_USERNAME: z.string().min(1).optional(), + R_TELEGRAM_MANAGER_WEBHOOK_SECRET: z.string().min(1).optional(), + // Managed-bot pairing: set on client deployments to enable the automatic + // Telegram setup path; points at a deployment running the pairing service. + R_TELEGRAM_PAIRING_URL: z.string().url().optional(), TELEGRAM_API_BASE_URL: z.string().url().default('https://api.telegram.org'), R_DISCORD_BOT_TOKEN: z.string().min(1).optional(), R_DISCORD_GATEWAY_SECRET: z.string().min(1).optional(), @@ -415,6 +423,10 @@ const OPTIONAL_NON_EMPTY_KEYS = new Set([ 'R_TEAMS_BOT_OAUTH_SCOPE', 'R_TELEGRAM_BOT_TOKEN', 'R_TELEGRAM_WEBHOOK_SECRET', + 'R_TELEGRAM_MANAGER_BOT_TOKEN', + 'R_TELEGRAM_MANAGER_BOT_USERNAME', + 'R_TELEGRAM_MANAGER_WEBHOOK_SECRET', + 'R_TELEGRAM_PAIRING_URL', 'R_DISCORD_BOT_TOKEN', 'R_DISCORD_GATEWAY_SECRET', 'DISCORD_API_BASE_URL', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b70925e19..0568bebf2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -226,7 +226,7 @@ importers: version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.20.4)(typescript@5.9.3)(yaml@2.9.0) vitest: specifier: ^4.1.1 - version: 4.1.1(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(vite@7.3.5(@types/node@24.12.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.20.4)(yaml@2.9.0)) + version: 4.1.1(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.0)(@types/node@24.12.4)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(vite@7.3.5(@types/node@24.12.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.20.4)(yaml@2.9.0)) apps/bullmq: dependencies: @@ -720,6 +720,9 @@ importers: pino-pretty: specifier: ^13.1.3 version: 13.1.3 + qrcode.react: + specifier: ^4.2.0 + version: 4.2.0(react@19.2.4) react: specifier: ^19.2.4 version: 19.2.4 @@ -988,7 +991,7 @@ importers: version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.20.4)(typescript@5.9.3)(yaml@2.9.0) vitest: specifier: ^4.1.1 - version: 4.1.1(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.0)(@types/node@24.12.4)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(vite@7.3.5(@types/node@24.12.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.20.4)(yaml@2.9.0)) + version: 4.1.1(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(vite@7.3.5(@types/node@24.12.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.20.4)(yaml@2.9.0)) packages/ado: dependencies: @@ -9908,6 +9911,11 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qrcode.react@4.2.0: + resolution: {integrity: sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + qs@6.15.2: resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} engines: {node: '>=0.6'} @@ -20736,6 +20744,10 @@ snapshots: punycode@2.3.1: {} + qrcode.react@4.2.0(react@19.2.4): + dependencies: + react: 19.2.4 + qs@6.15.2: dependencies: side-channel: 1.1.0 From 494338cc23f00432e46385d6336c4413cb1cff29 Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:22:17 -0400 Subject: [PATCH 2/6] fix: address managed-bot pairing review feedback - Reserve the manager-bot token and webhook secret (and the manager username) as control-plane env vars so they never reach task sandboxes or the generic env editor; the manager token can export tokens for every managed child bot. - Stash the one-shot pairing token in Redis before persisting, so a failed save no longer strands a freshly created bot; the wizard keeps polling and the retry recovers from the stash. --- .../setup/TelegramManagedBotPairing.tsx | 15 ++- .../commands/comms/telegram-pairing.test.ts | 39 ++++++++ .../trpc/commands/comms/telegram-pairing.ts | 94 +++++++++++++++---- packages/types/src/control-plane-env-vars.ts | 6 ++ 4 files changed, 131 insertions(+), 23 deletions(-) diff --git a/apps/web/src/app/(onboarding)/setup/TelegramManagedBotPairing.tsx b/apps/web/src/app/(onboarding)/setup/TelegramManagedBotPairing.tsx index 7b57569b1..36d513573 100644 --- a/apps/web/src/app/(onboarding)/setup/TelegramManagedBotPairing.tsx +++ b/apps/web/src/app/(onboarding)/setup/TelegramManagedBotPairing.tsx @@ -35,6 +35,7 @@ export function TelegramManagedBotPairing({ } | null>(null); const [expired, setExpired] = useState(false); const pollBusyRef = useRef(false); + const lastErrorRef = useRef(null); const start = useMutation( trpc.comms.startTelegramPairing.mutationOptions({ @@ -68,6 +69,7 @@ export function TelegramManagedBotPairing({ pairingId: pairing.pairingId, }); if (result.status === 'ready') { + lastErrorRef.current = null; setPairing(null); onPaired({ botUsername: result.botUsername, @@ -81,12 +83,17 @@ export function TelegramManagedBotPairing({ setExpired(true); } } catch (error) { - setPairing(null); - toast.error( + // Keep polling: the server stashes a retrieved token, so a failed + // save attempt is retried on the next tick. Toast once per distinct + // error so a stuck save is visible without spamming. + const message = error instanceof Error ? error.message - : 'Telegram pairing failed. Try again.', - ); + : 'Telegram pairing failed. Retrying…'; + if (lastErrorRef.current !== message) { + lastErrorRef.current = message; + toast.error(message); + } } finally { pollBusyRef.current = false; } diff --git a/apps/web/src/trpc/commands/comms/telegram-pairing.test.ts b/apps/web/src/trpc/commands/comms/telegram-pairing.test.ts index 87176b857..59c49d81d 100644 --- a/apps/web/src/trpc/commands/comms/telegram-pairing.test.ts +++ b/apps/web/src/trpc/commands/comms/telegram-pairing.test.ts @@ -184,6 +184,45 @@ describe('telegram pairing commands', () => { ); }); + it('recovers the one-shot token from the stash when persistence fails', async () => { + redisStore.set(`telegram-pairing-client:${PAIRING_ID}`, 'token'); + stubFetchJson(200, { + status: 'ready', + token: CHILD_BOT_TOKEN, + botUsername: 'roomote_abc_bot', + }); + saveCommsAuthConfigMock.mockRejectedValueOnce(new Error('db down')); + + await expect( + checkTelegramPairingCommand(buildMockAuth(), { + pairingId: PAIRING_ID, + }), + ).rejects.toThrow('db down'); + expect(redisStore.has(`telegram-pairing-result:${PAIRING_ID}`)).toBe( + true, + ); + + // The service has already consumed the pairing; the retry must succeed + // from the stash without reaching the service again. + const fetchMock = stubFetchJson(404, { error: 'Unknown pairing.' }); + const result = await checkTelegramPairingCommand(buildMockAuth(), { + pairingId: PAIRING_ID, + }); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(result).toEqual({ + status: 'ready', + botUsername: 'roomote_abc_bot', + telegramWebhook: { registered: true, error: null }, + }); + expect(redisStore.has(`telegram-pairing-result:${PAIRING_ID}`)).toBe( + false, + ); + expect(redisStore.has(`telegram-pairing-client:${PAIRING_ID}`)).toBe( + false, + ); + }); + it('treats a 404 from the service as an expired pairing', async () => { redisStore.set(`telegram-pairing-client:${PAIRING_ID}`, 'token'); stubFetchJson(404, { error: 'Unknown pairing.' }); diff --git a/apps/web/src/trpc/commands/comms/telegram-pairing.ts b/apps/web/src/trpc/commands/comms/telegram-pairing.ts index e697f40c2..c92bb3e9b 100644 --- a/apps/web/src/trpc/commands/comms/telegram-pairing.ts +++ b/apps/web/src/trpc/commands/comms/telegram-pairing.ts @@ -19,6 +19,7 @@ import { saveCommsAuthConfigCommand } from './index'; const PAIRING_SERVICE_PATH = '/api/webhooks/telegram-pairing'; const CLIENT_PAIRING_KEY_PREFIX = 'telegram-pairing-client:'; +const CLIENT_PAIRING_RESULT_KEY_PREFIX = 'telegram-pairing-result:'; const PAIRING_REQUEST_TIMEOUT_MS = 10_000; const DEFAULT_PAIRING_TTL_SECONDS = 15 * 60; @@ -104,20 +105,47 @@ type TelegramPairingCheckResult = telegramWebhook: { registered: boolean; error: string | null } | null; }; -export async function checkTelegramPairingCommand( - auth: UserAuthSuccess, - input: { pairingId: string }, -): Promise { - assertAdmin(auth); +type StashedPairingResult = { + token: string; + botUsername: string | null; +}; + +/** + * Retrieve the pairing result, tolerating retries. The service hands the + * token out exactly once, so the result is stashed in Redis the moment it + * arrives — if persisting the configuration fails afterwards, the next poll + * recovers the token from the stash instead of finding a consumed pairing. + */ +async function fetchPairingResult( + pairingId: string, +): Promise< + | { status: 'pending' } + | { status: 'expired' } + | { status: 'ready'; result: StashedPairingResult } +> { + const redis = getRedis(); + const pollTokenKey = `${CLIENT_PAIRING_KEY_PREFIX}${pairingId}`; + const resultKey = `${CLIENT_PAIRING_RESULT_KEY_PREFIX}${pairingId}`; + + const stashedRaw = await redis.get(resultKey); + if (stashedRaw) { + try { + const stashed = JSON.parse(stashedRaw) as StashedPairingResult; + if (isValidTelegramBotToken(stashed.token)) { + return { status: 'ready', result: stashed }; + } + } catch { + // Fall through to the service poll. + } + } - const redisKey = `${CLIENT_PAIRING_KEY_PREFIX}${input.pairingId}`; - const pollToken = await getRedis().get(redisKey); + const pollToken = await redis.get(pollTokenKey); if (!pollToken) { return { status: 'expired' }; } const response = await fetch( - `${getPairingServiceBaseUrl()}/${encodeURIComponent(input.pairingId)}`, + `${getPairingServiceBaseUrl()}/${encodeURIComponent(pairingId)}`, { headers: { Authorization: `Bearer ${pollToken}` }, signal: AbortSignal.timeout(PAIRING_REQUEST_TIMEOUT_MS), @@ -130,7 +158,7 @@ export async function checkTelegramPairingCommand( if (response.status === 404) { // The service forgot the pairing (expired or already consumed). - await getRedis().del(redisKey); + await redis.del(pollTokenKey); return { status: 'expired' }; } @@ -145,28 +173,56 @@ export async function checkTelegramPairingCommand( } if (!isValidTelegramBotToken(payload.token)) { - await getRedis().del(redisKey); + await redis.del(pollTokenKey); throw new Error( 'The Telegram setup service returned an invalid bot token. Try again, or enter a bot token manually.', ); } - // The token is single-retrieval on the service side, so persist it before - // anything else can fail; the shared save path also generates the webhook - // secret and registers the webhook. + const result: StashedPairingResult = { + token: payload.token, + botUsername: + typeof payload.botUsername === 'string' && payload.botUsername.length > 0 + ? payload.botUsername + : null, + }; + + await redis.set( + resultKey, + JSON.stringify(result), + 'EX', + DEFAULT_PAIRING_TTL_SECONDS, + ); + + return { status: 'ready', result }; +} + +export async function checkTelegramPairingCommand( + auth: UserAuthSuccess, + input: { pairingId: string }, +): Promise { + assertAdmin(auth); + + const fetched = await fetchPairingResult(input.pairingId); + if (fetched.status !== 'ready') { + return fetched; + } + + // The shared save path also generates the webhook secret and registers the + // webhook. If it throws, the stash above keeps the token recoverable and + // the caller can simply poll again. const saved = await saveCommsAuthConfigCommand(auth, { provider: 'telegram', - values: { R_TELEGRAM_BOT_TOKEN: payload.token }, + values: { R_TELEGRAM_BOT_TOKEN: fetched.result.token }, }); - await getRedis().del(redisKey); + const redis = getRedis(); + await redis.del(`${CLIENT_PAIRING_RESULT_KEY_PREFIX}${input.pairingId}`); + await redis.del(`${CLIENT_PAIRING_KEY_PREFIX}${input.pairingId}`); return { status: 'ready', - botUsername: - typeof payload.botUsername === 'string' && payload.botUsername.length > 0 - ? payload.botUsername - : null, + botUsername: fetched.result.botUsername, telegramWebhook: saved.telegramWebhook ?? null, }; } diff --git a/packages/types/src/control-plane-env-vars.ts b/packages/types/src/control-plane-env-vars.ts index 99f6cd182..2ab7f185a 100644 --- a/packages/types/src/control-plane-env-vars.ts +++ b/packages/types/src/control-plane-env-vars.ts @@ -40,6 +40,11 @@ export const INTEGRATION_BOT_SECRET_ENV_VAR_NAMES: ReadonlySet = new Set([ 'R_TELEGRAM_BOT_TOKEN', 'R_TELEGRAM_WEBHOOK_SECRET', + // Manager-bot credentials for the managed-bot pairing service. The + // manager token can export tokens for every managed child bot, so it + // must never reach a task sandbox or the generic env editor. + 'R_TELEGRAM_MANAGER_BOT_TOKEN', + 'R_TELEGRAM_MANAGER_WEBHOOK_SECRET', 'R_DISCORD_BOT_TOKEN', 'R_DISCORD_GATEWAY_SECRET', 'R_TEAMS_BOT_APP_ID', @@ -60,6 +65,7 @@ export const INTEGRATION_BOT_SECRET_ENV_VAR_NAMES: ReadonlySet = * explicitly rather than derived. */ export const PROVIDER_IDENTIFIER_ENV_VAR_NAMES: ReadonlySet = new Set([ + 'R_TELEGRAM_MANAGER_BOT_USERNAME', 'R_GITHUB_APP_ID', 'R_GITHUB_CLIENT_ID', 'GITLAB_CLIENT_ID', From 23d6824836b2828c7b58689035bf022ff7c40a68 Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Thu, 23 Jul 2026 23:22:50 -0400 Subject: [PATCH 3/6] Improve managed Telegram setup --- apps/web/public/roomote-logo.jpg | Bin 0 -> 56356 bytes .../(onboarding)/setup/StepTelegramSetup.tsx | 8 +- .../setup/TelegramManagedBotPairing.tsx | 6 + .../settings/CommsProviderSection.test.tsx | 70 ++++++- .../settings/CommsProviderSection.tsx | 181 +++++++++++++----- .../commands/comms/telegram-pairing.test.ts | 100 +++++++--- .../trpc/commands/comms/telegram-pairing.ts | 7 + .../comms/telegram-profile-photo.test.ts | 93 +++++++++ .../commands/comms/telegram-profile-photo.ts | 79 ++++++++ 9 files changed, 464 insertions(+), 80 deletions(-) create mode 100644 apps/web/public/roomote-logo.jpg create mode 100644 apps/web/src/trpc/commands/comms/telegram-profile-photo.test.ts create mode 100644 apps/web/src/trpc/commands/comms/telegram-profile-photo.ts diff --git a/apps/web/public/roomote-logo.jpg b/apps/web/public/roomote-logo.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d2a2ddab2bc248df046364d44420b1e55512b8b0 GIT binary patch literal 56356 zcmdqJ2Ut_>mNp!kbdcT&(vc?8YixjsfT(mKDqW;kB_t{W(gg$rLl9oBEqN=8@p{aHGioSv2RikUSZd+JdS=-n;Iyt+zy175_4+snj z4texA{CPxV)QjktFn$`{M2sJhi4zG$Kq$&mY@BsA3Y{LnN_7b8+E+FxUb`GC&8v|Lb*H&C0C&}rMQYp}pn?d7lz!7XQUWOr;ypKxIT#tTB^)^8 zpB9AVh2(LV+~bTtl}qikHKQ8e+U3u`65n_F)_CSoxePWm#_yULTjtWfdrMd$ZBLU^ z6*R;ie0M;f>!_|ZM*1D)Ke)4=jQzhc{Tfd|T_+%JI5$js$g~n?+&*a)Svq2zv2Qmn zhi1_F{Ah-U>Pw$zRNHWgs_CmcS*xkz!cX<#w_)bM*v_4RT4dm-`y^_k$*QYXgN9RA zc=!iDi;2jty`0WH-u}9+4-X)koPfehQS;ewHpB3*{5)|wc0PKT%Z}czUT~zpUvgqA z2efF>feQE5*)_aZR^_ctfln?<+*fmr>Q4 zmv2#zXJ-eLrt+7YS@99TjJXZmjSt<~QdjqyY0=B0;Y(8D!xzJ$ zE+~iY=I0I~=+gCt1tSDJA92v7rVxP0+u|bI2x|c*9U@xn^h9{&(SF}=3 z>JE^@emN6!?aH-dpMTvWb7_)&pETt`M$9l)TXO^+_>Xv1RObmO2F`;W85V5HUM_s7 z2$@$aUJg0|J?Zq=V@0PE1&dBV)bH?qPEnU}@DdC?T3qF^WKw{ip>y)Sr|83jvx}i) zN12t%pM%9eYl`n0eHh!mqfO8aad~E@+-XswxV_t|xYVnD0!rc7V%7qWH0J$fIYK)-1uZ{%=q$UeK?m4@slAIig_n35doE_bQQ; zj-%{t`-HNt>fTo(7GFDF@r1u&=VqW{Gs?7UxLnOI%cc!M7rmrDdG>O&4 z@P^?as>XYKhs?r>zf3TI4?tK|nv4&;KcVnTXmi(vFO60n@^Q)>GHgA)ff6P5`Ysgi zwqml>CT+`|W=GDyl8(WRdTJm1+jxUkud%<%<-2tD6~?u1r;faB-ZU5-J`!3JUFw8z zzHP3L$dZWQG`~Re5A5Lo{Y0Q*zsz4BJDa6Yi5B=KCpRyU|8`kFMqIdb`_j{W)f?d{ z<}t&}*JSs=7(*hrBbh!N%h^Yp09OuyuP-x2)eg9Ly4?LbpKGt_rS?hpvZD|VngkYr z%x}((_qQw?L!LHM;UMu|Vp8n00%5{{m$_=Yjws&<>}rBnw+U1FzV&xaU#fO`+t<+w z!E+ut;$v3HC9BB2S^NaxXYRqL;i~Qr$qXToRx)jsPm6el)ClsHE?t^c!@jQh>P>fZ)iXz=)#LV)BRz!77(xz*Y@=%X9KXh0G!XxUL&S@Sf*={0p*N)AN^}jk<+14qjb>b#CaYduzaoH1#01smhz(9Q9rr z2@H)^C@<%2th~Oo7^G{H5iOo`^Fb=&GPZvc_swn6rbe&8YLY=^46_BE{@IjE8iY9m zPQ0O+&Am*uaFc*VSdCt!HuOg`Ut-pf!7zDl-Td`wqw7u6mn~>2lovI>){mD_Z)ZI} zwa`=>L8PS>)l?yAA{8#^s_W&-x62tmyzqV)h|2ni3qErL>TSe|A8+mXlkFd9-bVG9 zbZy5-^yTD2xB+-k3c=p=PSCp#yMiA<7u|i$l|0)@`qk^o{3h~Q+a~VZO73;57Ol?e z(vT?gbnCJ0<>rR$SR>u`c)5hGWSi+U@#7SmS616L>dZ?M0lIIYQ4!Jy&mFlA_0QY^ z((i-koaXwV|8`$;F<^NfNh^{-r!px`MPf$HkC*`+$_^KYcj#%xqNQG_%0CK7VVq!KzukaY}M*KJSu!lRb`~A$MJXQE~;M)O9@#;HaXf(S?&*sERMb z``>a~z_CVscv)6c@Gzgi#`XO|3iUku^Ou(me#DdCfh!cZ01 zD5lqCw=hmCJsP{tXGA4h70=hBVB}W2OH~ZCaPBWY_g{VQfAI=A`G1^YH(Fs>x5Nm# zf^K>&HL_ozHpB9pwQ)~(>9ZN%yae4alHx5d6+TrXnfE>_{MFlT$*Et) zH*||p*+9fW61jf|-p^KBKnOw|T*Rv{G--EA*(XegBo8hvMFyl>)+aFQ$$Gr#rE{^c zN@{C4TVX5=(n%=6pooG^Kt!&`>qu2%BT*%=i_KB8i+F_TOZ^5usx6+*QEsSZd=5^P zcozP1t~Rm~Crf6~FGjRng3-DxpMc2hvEJ-!mXBBMo>Gmgm^f3nj<<>@Okd`gre=RZ z#jWW=o;_9#<5AY>0^9MmPr%+^QYq}_k8XSW-ZExQJlV@?b zeXLFthw>Ald3b|1>ezUofllRwI`bG`j@Q*xT7 zFpB!|`LpV`_a3InMmhKry6+^lNTTFm`e-N{9%Y@??jS#`5NL8~aXW2!+v=@59dB>< z85hUITTtItL}i0gk^4%<^E0GLUw;Ii^9%b4=&N;8E;$1%1ewPq;w4;LZppt*#~Aku z&Rt>gY*f9c`mO)rooU8U%YK5gnl>86Pu2vc|15t>)hM{qtYsBVrDt&%U406M>)eo4 zxqkQ9{!QXFvYa$>ErIix1uJate&5}UO=2{Erg>FbG3ATSCaE0{U{6|3pczX&m8#K$ zw>sf1LCV~H$jDNWDh7dfcF|W3J*s?^^ummE61%}MP@!gZnBQ<`HLj#nYc6(&ikrj3 zHlmOC-4yBtV}tf*{AvN- zKPF{6(Za$lF5_o2f*g%*7ns|chpLjpZdtmoXWq>qx6FM;ZDn8u4mR;;3So~(In~a? zDh;2+>T6OMg9~ROm?a9PFXSk=9WuqGI-eQH;Ik%KKo?tp`3vmNqG6`M(F;DL<=qLU zGm@W+Qk<2AaJa3{H4Lk49r;ik>9=btdP`Zr!gsYx5G6a$b*vUnK*Awr#<;HM+m?IR zMmRj*Nk_y{S z8%X^TgNn^b0CPh;pifKC;}B7~pDay9G! z3GxA-{Ew-%Mnw!{3qsI<|9I>>0@uawUBCutt0NCObG==5S$voB%aLa^G;T8)@q#ip zzRt^{9~5i2X?AbsWqB`FQ_V;tS1K_pM8Pp&_*(J0E8}$vC%f$VaT333J=wg&U zVm|3D#fY9b;Bifxsqw*vmhPU7t_2TDIh~GduWx+34u7T{BsGcR#jUhWr$83IU;44O zXAmcKQ(;S^=iK8N)jiX-eSQ6PM41raPWPrC{Z;%<{j^?eF9QAPAl}J={>Fd zyw-K^4(82SjxFet6)v32AVUZy!i+qd6^ITvmr8fYTxHnJWo_l0V@ z0CGc*pOk$oD9tycU{+-TLCiEktWgs#lf;{UhvI$+_Bu2|)D9YTczoDyTy=EYt=^;M z6cOB{mOq-K5F*cSv`L5TKqW%4!L(a6Xt1^!DGScC`j+?k(#;+FFF6)lJ39~wG+^;^==0d-R^X0{y^D-f8={@da#01=A)VX zWJ51w?ay-UhvLg0&7p&uv<%LVEdF&B@%~;#96kU(o%@Gl_MeUQ$jucp^EJ|QRmL55 zda4_h!8UMUEhK(A^=~Uc=CFrO=M>Kax^^qoxHcmiKQNaVo z-|EDMKmM*i3dLXg0ujRRv>=#tgoVDMk=gF(wQvt+Pr)W!0YN_jMX#wZGjAk-Q#Wb2XnS?!59dx-#r+|D@p*G;RH0 zcE&SI(ep`d{)1z)7b;oyp%>~Sk2{Y!>uSE#2Byj6rC!cYs%I}x$Uk3^OeYc^Gk{Eo zK}+EMs82kb{b&f-&bnm*K^Y=tRzD)#+Arfx?HhjPSvS{1Rl+J5*`ZJU*>&f4(-c;yZL<>>1 zJ{FpbCR&7fe{ipdDf$Km2cNx6&1Rskll*PTob7@{#tV?2$!(Fv;rr#|{QES0ebuQ| z>FLRG=UNM9rXwsY_~hELAi?CvR=fKI7^3ATwBqoqOgW5(0AAa`*|q!b#P5rVO}PaP zQariR0GpZ?&J#-dwB))4?L=**0N%`yREO{)Y&<8ugZV!4&ohxu)1~~Hr7J35C{2@p zMJ@sov3%ZWeHrx$cJ`PH2R+@XQQ8uAGkinkXmYpkJ%Hz@b;^x1AwOvkDuA^A8Dc&Y zQjuS)bs85AyEsTBtd|%hsJ>#9Wy-}yM)ZHKdY8x}LL zcVz;PjEsk))WY_0y<~~0Y%*~<0eCn+8=hq$D6muEOx9C11zlgu617hpO6LWn| z(ErXJ1ZTwi;~3GV1*CZF&c)$CLfZ*QPG!T!EB^%4e8$pyu%gVC6TM=dpKg^$6_T3~HePvL^tih$QViByDn79HF8!|uk{hkIvwEaFeOQaf5NvSBl*t-n? z{3o0JiL2$YWHwfAfXVbmIM0xxyiPz>kJumCA(~KL)1k{>CXZj=j%()e1pu%mg+Awv z|FAXlRRf{@iK1b1reD?t<6B$Q4LFD!%tw-DB%U`GV+=M>2(F+2xC4Ze_5k zn8)Yy%7ehz>kzzX;wI4V)P2bap%4f3M#l;0wBi=L+aE1tWUliu>(td!&lFj2!HA3Z zZH=z-p7%WRIJgueOVmKmp8LMH8L%feTUlah*8_cK?38{b**H(_D!>mNz59);^sGpq zPe6`y&vR-aEdW>m7@UNg51`n1`lDs^3qmepcl4tAA#zBsl>@y0FtHAZC9k z)@)}&Z|Fdnlo|k;2sLg{pF~6k3I$djz21H~KIfrcJL(~O*X4F?UA%+{=#CNe<|QLjciT5r$;sXeUO&{K->=s|er(4KjQlDg(OTC3+_(O& zZt1VXVKB{=tai31tcBV#5K-@&{9v^mF#qoV6gB?!^|qM`QDq@1H)9R3`)>{pQ?fs| zcnTQH=ICFci9Tk?=+oCUT0LdYb*zi^C(|z*^;zak7apUvt>YUU$<2m( z(_>4G#+XFfB_5kd=WFr-w{E7BskoQ$%=$UsRk=uqFfO1WqZ5!bE2D}!LWnFLlGw2) z(%bQ&&HMe-mtSY+Jk!D3J%zKV`+BE<{^xen zP4f>a)xPy_w}Kv1Kz~f%`gJhh$c0JoEI;#WDN!7LRM(A|mn%4hNILf>eSx4b(JpFVpXV6V4KzHs#i!&K*@Z@+HIe(d}lQ2+(>h6BE3=}s?pOGnE29Q7T1 z)Q#%O&QR5QBl*dkJ1xgJ=-Ncy>SM*oU*lEYtTrw8TtQQU+ykZNBEN25;ADMQc8%*7h^BkG{v%Sj-Ch-t{wv z{Av}sGAnj$6Y?};=Jt1In#s&~Z5aAh^Fhy&;R>WR2QO|#A5QMj7~%KRARYX^O;Id!?wehP`lD0yA01on;XX7vX@nyUnVunm}3Ih%S-bq>A?L6 zA`carkpoLO(8n{D63>y+$x`DsFkgaw=9H~F)^5GR=1Mx9SI1-@b4MxDJ*Heq$~!MD z5HCV?XufI|vbhXb#FaCTyh&~TXvvI3YtlO%gd?_cJ|_xE zGzXk}&D6O2&#J*%5$-bvY&@F5_t|LWE-a%$5c4kqdiMf27F^rZl-QM+SUD3SwV?ee z^by;o^~aOp&*aiBSn_I!%v_BF%Bm^LPGlmm_IAd14jz!=8Yy_%yeBGK)r5J6Qg2^B zSgT)Odxa>i{}MI?1l6spTM6M*l&E-^-?6jP8+pq|E;is&^!xWllc&Q9V?#cW%Ut7%8WE+q7j3*j~If#Q-DeX zod3!kig9Jfu_&s2mvsqQ^S3CGl7XmglX#p5+#Ud(67uA4d*;vl8*%cVXjx7`vWj@Q zl}!_z_kjWFOY@lr4iea<Q!}Nz;Aj zwAo?-0tky+iUbuHAE}(ov;m=j>$(kWI0%hmCgjX^yP0Ci<>*~AW zJy0?O2Y~|sKV(L2?R@7`a%7&w^EZoXol8MN%+UM67PgC?ALW|VQ;sAsSs5n{Ey+O`@WN+tw<347-gc>X-7L^Oa@Hua zL&@)WzuZJ<2UzWg&YytrD&YtH%}#o{_|*9(d84D|)R%%J#b(#$cqQE zBtAG0i&{!@fV_yN(MnIX8Hb1y=RI$TD7x%P#Ws9)e9Eq;IE*QuFt&o449J4``Og}t_C=}J`bKAoLu@L8Y*b{sL>Neb* zAX_+aVqN2O9+gNwoez`Tvq7b7Pg%D}wDTsRqM_pwuRIwtKWJ1eivw?pKJ;&TYr-E1>(AxB{aW7uxSiLY=wNKRvP&!5r+ zhn(kZOn2Ypb`~4nN3O5O5NThKU(brTsK;~?q?=!1cuzo!qyB*Tk?#_}L7yYA7yI!N zJS`nYDv0M=LDhZ@!6wN5z!DzW>5fNoFPRSM*~8bMAD}F7X5tyVcA*e>0Sriy7;U*M z&bf1|9Vn$ba6YWPa)+f&&??^?`rMW1pY2lCIK+3e7QZ3`_e{MDIKibS0OI+-D&acR z3lv);JvR1hJ+gf+da}AaVu8dyv%RykBhGe_S}CrwL0$N3p!gFCafZKqhP9!!>WwTqOD<87H_rf?5rnkKBPV~6m(-$Zcw7IxFDYFLo11T(T|twA`1Ej zBD1Xy?f|0~w7%ngpK)?SSfkn#sj=h~e$}f8>=<&cuRke*QTg`()4TyL+s6|QmQg6u zTa=4k7K)iD5+7%E{`B2u9L24}ZA5~sDJ0ml1<|BuI*ulL$&xINmBW~2=2LY}K(T8@ z76YxZBZie9>wBxz5+@r5HG9GmYjx;>rUgmWWFB~5Fr>n&&HIJchw%B>UP8Pd+-U=OVmcX<3q&r#O zFQ>%a^h?>0Ohfj~WK09yy8k!o>*fqV_~n>0X?HM&4_9nG0ZoLr20O@(dd?Fp+I<>t z=zE`9GCS`P8x_O*suHAL*FEo2=P%dK{47__@_fGgP)x(C%($D|Qx(R;tQjT82|&-V zAzB!b5RX1XGg3LiLrBlqJ7V}D=bo$aW4RpdeE;hCc78W;ttNOnp_oCJmfBk4JmD|;0D(lm9*Xq^h z8o3qbbOOqDOK+VDPDqOK8B*y}*B|oy6(&5ZZESpXkXIyv+P6dS?paaMC&k6@fOaUJ zTDj$+dX0=~gWQ0SBl2hRkiT~g%e&@6MjV-_o!{ zzqp>VTK~L=^W7GoccrQ0E7#}og4|snfk@a{+|tLUpG4zAdw1K2 zhJduuUCBtNe$Q`-2(h4|sRosH(nWP2&VKHQ)&seXxRW9sm|=EriG( zeDH%5spE=<;LRGDOQ!K+#;CfOt`2T&SfN>61$)KR9b?tcY1%HC=gOA8n-6CfTtuUk zJ;;&x;OGkC9HXF@jjgJCuPb*ZQ7vp}Bv8o>m}`gB|80m~n&jo7E`J1?$87Ye#Q>5g z=p5jfy*H<@IS1FV3P}XXwR3B5+Tkh>Au}_l+Z^^?kGus-0s}=UR_=T+Et88+wi^fO zH6SAa$wUT}bs29DKo2ZlVm`K7#@BtmjBES!BG=ncjg^c?;>3!pZP-;Ut7!m?sWv{C z`k@s3aUH%3|CPO@_=wPV7+U!bxap&pe{b*@WP6)6JfIvzb*xJkPO*G+)W7Kmpm~1E3_NP&*3oNIc~>ONTT1 z8Y(1U;d%2sXleSjsI`%A z0jE`0PR-ob2tgas0%>NNhPjh+_Yt3MmvV>E{hkfmYIBhX}09qB?f|!6T2$$fxFxySu3v3K2+7`r}7arfDcGRv;aCuw?w>5VuwF^E`~?S7+an$mou)%U+wpBQ}q6Y z;I)+*KzCS{-Dx&+A!+O4Q2}%_Y9M_zD`Mg5WzBx}qJ&U+jB$_d%B;OwLb_kiEnrws z)i3_z;2;|icl*Zy)!^11F3JRNH*aFlrXRWMnLO*DY<#ve>pGG-j$*rTcjT!n-6w-< zM?;rN`5WKt>9#t*zV>=EOrOEW%JY{o4jy~sD!#3CmJ6qzpkw4XzeBTjs8v%|^doIF z$kJEmkcZ>yrF`EjPZ}E7K^D|0(BH};YC6EwhaLdlHbYK`h8dy{GHbx>aFILZ@5@?k zgpIY`E=$D6YO&|H$L)TjIC9Py5|>upfnrT_@M49V2%rJOn8gIi7KH?N zaC5BHM>Mb(EMzmNjS9Xm4;D+z;5|isT9ydl>-oYttT8}1A+Xm^K=CPf^)Ac72YSpS zUXtES_-IaERmR(zw^O{?>^t-6b040DJI(M}LDo?}8QV9@oU7|BOK!CL*C(xQaTsX_ zzj_o1?R7~@_RuUZT($tpaBK9h|Ahn%50&mvAzdMU9~WgiO22F! zz^pfC(ghB5sSn)*pfcMFS3(|&675|O1#&iNEqCPW4){=T3m4-{SiBhyi2xw4Vk6ZF z=+qA&T1vqLygqx7IY(i)*WW$LEUylUyUIJQA3iDR|3eL<`?&)lHV5E$yeObF1zaTE zeS9CfpKo%*$Af$>>&k!%;p&}=wfnL~fTlA0fWohv^ImR(n!a3Dp9W>)4huYO`b_&o z;(;ZDe}v`Wvp1eUX2`-;^2rU$OEca-eOwo~fZiN(0+b^Erl1UbONosvz|nSTDLx7C z#uLXBx2o_O{X1UH)+upu#P8Z;QqVjw)(8>>#a2V+KR~F6q65*6!iq!QO9p(*+*#7i zFWII!pM-BY@Nl|pDE2Dx9s=dSTuoq7D0%fl1uUvze#VAe;^V=6H zLdI=wNyY{1X=^$|U2;8`$9FF6si(?4wo{Lg zs()PaTL)GLU@FvWGrSy_D5#3vIMBDZ1B3&K%hfn&`!80{&|+AndnVJnt-Ech^^K;! zr@x!)=?r$*avZ{i@SbmcO&tZ15_v1?4U3p9Ga=22sE$?G%@snZ@$ci4i1$_cGXng+^J39hDu$D{1;HV&v=VD}vcS zY^HX4jRoZOl$oE)P;%6jKHzj6qe5{#K((WU;jg}o2~LG%6Zh&n`NJx zY^x2uWg!667NFm0fa3+4Tf=g4`*!rdnl4h;94b}a%>Blc!0-t}`_i}LPTm`*p?a!z zJzDsWIR=C)m?eh}CR)?{Y4C|f!r;p7;Xt~37QBp<&^tdqm1|$&6yD$ZWp1xA9SAeA zv1;%8;T<&Pd0blplze4h{h1ox-(JJ|U+EKNG5u5h2da2~!eer8vm{Xe7B?soE~7{{ zZJOjaX1;dXq-@%i@07ZRk5aQ*NOxN%fhFcw#X}57I(iNB$=|2Qq)5MP$aL)~St*AH z&Ka^ZZ`A=l4dch>+&y-E0aHTgWqq`R=-oNkHpkBJ(eoMJDJf#gf5!Z11d-=TOT+4LcfR;}_oxV*`{xby`F4hUxwq<_2a+H$L%}hwEeBALx!lis5B^;7qHnDMFMS2hMVCBi7g#XZE z8iCy_dUs3iaR9TpkE|eV-D|Y;>9^`V>tA-DLy4QYfS1mM1>!<#aI9O=mpbIjEY(YMqqeZzUQcS|m;RP1Tv|BT2Ubb7>KBT9% z`AReAMzi?PIp5DdQO;<&=_V?n@!D~yudt)$jfF|y*1ytJ zYt;PHIqY?_i6>JJ9tXGK8{JE%P8$@UyT~JcZSTV}MjpNj@2QxIq<#6=@j$?Mu&RmG zG|F(mGq19$=Az)a3$cQ}c~~wWp`!Xk#RZQcE2H5meS5`o)_pkTGKIaA)Fy>r4%fal z{GR0JD4^X^M4^EqUX-(UgqDFF2fiHr5W%`#>f{i*a`*a{w=d}k35 z#ybrC33b5YA|p(m{k^A}>Jp|`)e2&+(tQ%9$Lc7GRwFnGG{?Y-k{{Vr0RPwxA4 zDCoTPJjjT$nIE{{2;k;#LY%$vk~>dXns7)XG1l=W<>D;yZsgOSUw7x@ZCl2zZSQi~h)Xg1`&Ydj z`lj5?R_YeSx(=p*$J)&C-%e1-3pf`%`rfv;{zLQ78Y03YX!5gyB?JmY)bbpx?@l7b z?OrjwAM&Z7Z!V&d`AjH)Y1!|+8&P#5J2}@Zsutuw34bHs7I$j*soT_%!EY^vGmr|k zNi1Cd;`~LPi*7%($NQ*2eu~K5Es>iN{2QxZIQ_SEW{Wbgnap$N9NHyvr4Nj}MjQ!a@pC zpm(4xbp@TZQ@%@Lyj}Bpnk3Qlrl(rxxJe&GuVD*#b|9LNN`d_cI}W!ux2~x}=uz`& zbJ&O8ad6Ribqs>YdF4(>@a3>{?pfwqf9`^S=U(8+^GG$f5N1knmEg-hDkkt2W&zQP z0#Z>L9r_Q%b9lqti4gy`$t#xxbRlLDcb|z1W`BIb^tszb)8)QRw0P#uM83gepw_nbmsKj&}E9b@GVp@0FFr^lxm~zp`Tg*z5mPyeW(St%7LTnbcK0 zfDM5(GNGJ3rXk-w@{vF>0D<)GL{&|ag@OLZGWL;G@yvx=U8&A!8Fg1=hU6QiM9-Wu z#0?VDnm;}%U2k^e1hmBeyx$Su3oInkn9^S*a%6zeoF9ZnzQ00>Zx$n3r3Uq)!92{m z{zP;4v~yaYgp``M^^e21a^_i3!#rf>dAvKg%^NR{Y*P=A2}E-b?`I{@b$=O_vNnVK z+D-}!^@VoinCa}TH!|-7bWgk`+mV7h*+;+4|KLZAG!o}F{MUWje1Qa*>a{ZwbwH1I zo8v&aL)b6pUlj3myeBL*QK67FV^!qT%U>? z%r@cA%SNL%Fi;NiI;?3r<@1}u@ttOA*lm0g4%ziMy&YgBGnaN=5HFYXxHJL=XFgi; z|2n;G)EY-)9$`Uo8w4sp1q_>D+rrvlA^+{X_nf841L8GC0R}*_Y5DcvyXN2LK@e85 zFn$k>iYwI^FLv=IuOkW^WrDFt|k)+MasUDZ6%78o4K?^c|7Qd?9un$_P7;E7*AwSn#E~p=RXayT}b@kMn(t{Y-6#HA4?4blk;F zSFc5La^yJ1_U)$s=p-^vJ$7x@Eh!EV`PiA4>E~G}h}ZG_F*@{XxXv%qK$kEo>M#+y z0t^(bLrA;F9M(LMIpjV!X*OcsQ%~wncywViDm$^pF$Axdu>&1LUHr~bf~KKIer8BK z0ZA+YG-dSLu05XL;XmlF!#}wd^_jMf1}{tCC(7WBKZ(B;!luRFQ)BU0Q8SnOZdPLS zB|)Pbjp_y;An;))AVv>N|1&t(UEryaNpe>H{IiP$&tabc!4nYJ%Y&3=VZkX1;$#A8 zfUJ&E2?okFMN5wY9Jcf4dXtrii|*G00YAL701W28h^KBzq-lt`f4KYNNu{zNi1w!0 zUD0)e@fzj}4?RLqp$X5v}HY?-T1dFL7c zvXcUey}5%|WZm)vmiMW&y|4UoJJ`d8(l`qkq~HhHb1@86RsPlB%Sw)ng@~YwnllUH zy$<)W5)RDwLv{7?_`>6n%pvl$UEgtN?LasClH;)>04{QXTL3t7YLrXiFq{S65$%fO z>BP}K3v>Ds#%TIVPzcF)>eymsDSGs@&-K#FhR{~UEn0#h(T`M2mYgNtC*|sxgrbpb z&^Njxm_<>iiVmg+)IOK;vy-kA@h@(fJ4omwKIyUIY1$gOq85AB{4F00mdLv*=1e3W zIyzTx?<@QLPP+*=WZtQjJ!b*m<Cmx6KlSDL?smrFEM;y0CNYL}X0Ggv*g} zN?w8V?6t9_nS!S3!2|eJ{?Fvb9IV+~J>cs_=WYL(0~kelsu%1f_{15h`K zDJp9VKTd%{ErgU!s|Ho3!X#UQE_$$E=06q%6+=L0_Yps~12fD7;>zNRnvAZQjhdi_ zf7fdE{-##**NgA3BiNsN@9%3j|MSnv66+E33#>2B!vK8stcGZjYOUHFFPW9!N+Dlm z>pt2mvQoGc7X`ZO`lTDPE)QKa0Vc8%&M^c-W{|-3wok>1leNbNMIRsCnCs(oKz)sc zuCi|K_KcWHbYl4N(z}2QzXcG9Q&-=1{Z>98%9k(xyGcX4h9t6OeeK1u0)j}a_629w z0|ck-dWZ=4h$a%%uiKTBwCX{+yFvVkn4jpUt_2vb(zhIA7DmGS1JzAe-?&m5OMUKl zfM9lnj>nFnR`@-_#Y!`ej`2HZA76S7KQcw+8GITCI+DyRCtb{6UQ)1X>)csgHr?Us z>Mmfcvsd56Y5uNPyWwMX#g*mabJ44@mr2Fn zyB7#nd`HheUP*5kj-|NzS}+#jIPHbe3 zfAM-|T(`Hot1CypuTF)axUW91Yp~;2fOr3S`-gAbo&3$0ys5`B1WH7U0>lH*XO6`t zBw+k-89! z8IkkR;!v4@(?K)C=Pb|m;z!`gbaTO0x(#otM{nYHJ6qmECez#Iq8upkCi8ueg|Rkf z{{FUOmK25-*TOYyK~p0eQvWK3;=*>w;_f3=A}t0v6t2*txjV~Q{dmLBeB&DMnZv}P z>Akd%b!}hpTYz5%oY6BTawquH6*XbC;fFkEo_6E=z->K`wTZYkWehMC!rV@cmMvzo zXZED{AWfZ|@RM?XbH>r8&b8&cKSg^;tl-yi1R3H;2iP2V;kxK zvX*n~J-mVEAZ2ff4MW^mDaWs0H6mXAV9@b+p&U&syiYnnw2UEk;}cmJ+R zDszBGr0f>@BZ-Pwu`VQbteqbmT}AQ@9Dk>NzJn|8yLi0(VV=oyw3>?Fr}&qjeA<@0 zadQ$aH`>v;mpLqMhClB6FbtcF@kCykb2?Df8_FwM!#T9vqt<%W>+6$L+_|#PuZ|09vJGp<3$FV``fxKu3tyuYAsD7kirZ@D2Ex~+s+mh;owMK(?=#g+ z!}}igzaOME$xd75bFQzgua|hAY;%`UY}*=h9acxRAKC05vn%bKkqT?hb{mb?R4F`F zKwnUmH}s&-qd6l7f=K^udp--W0NmlWRjhIp!SfpJZP%!m;<^h1a09V@-N{q z2OgvhPq-%W7U{iC;JXk78T3!NO+@w1)|PXfh5GItI?<%(6R!| z`PD?c)LcufN4DbpbuDCvy^C%yc>G?Nwh7Do{Pf@#6`uVRxwWZg;(~W`aF{idg?@G% zI0ga_k~m(7ALbuR3L>%;f$^;+krPx$zf@3*AyrXy6pUW6MBXLW9Rq z2MPq}Q#j9^!VYhtwpUv4cpucnK{8X~uO@xVtKqVg{L)t{D9@pF!l9j3TR;Vgrk&J) z@XiX&p~a)@&FJuFgYE$W%AKg%$*J#ABJztJaff#t{jQRY#QfSnpoBN}5p?h;0Y!C~ zkj`4=x$z;dxdY-a@4id3x9K2)Suk?G){~_(W!L~NH5G(RvMp8$jvvgw~Q7ZillWo2DD}?>qt7Z1D@OA@Vy>#O* z;cc&r4OcGpU*WB#yK7e-gg45_?Eth_0246^tVq;}N!gX$B zmAFLLfP-7$5ud=+V?F+NEJP`M z5awqal%|=rkf4OO&Nais)kdrzUfUh*d+2h8Bep{vFU2_ zlOG|!kr1K3lVy88>w}N>Jx5?lHT+y3jcc}plw`N^7gV9z69w4~P#yHksVD=nJn21% zsKk57p(XUz+%tGL>MZIDjkatwD~rSFCF_!qo*1i|fNuVDN)WwGhqAL=rt+gf=)QFwUMrF$%~Qf0&`s7&HdCXc(} z?Y6;pS}%b)vmkhlo7DxjGB#HY@sS{YGfVMMd9JKx(<7zH#nm(@ZuIk&iA;atm z=S7nlzH6&4tGys!@ha`Z1Y}2ISmjO^IHVaHt&N%{W$TaESLtrGTx#Hl**KY-6jyBc z9g9c0iv9R87tMI#W-Ncz=hd^Xqs~c7Dv+Ij3mpM{?Xx7>bYdye$>~}v4{Ur$q62>B zvx$DyBTa5-Q%uaFvgVQNsWtP}OTx=Hm(oasNNW^cfO``}PzvJ62lz{?OX1LquL(`@ zmz>kxWt}2fp5E)HIE(7${M605owoYvEGA(dUD2A>a(9u*AllH{aGQBU z_y>)YvoFB9;XqR`&5a|&cw!1SXBQs&%%;CO7gRczU9*vz=ikue*<7J{Tc=7ur9|9i z>lhR(P)b9R?^8qq<~qtsj8ZBLT%X;obDtlIvskTK4{RxSD(DzZRIPE;)W4Jdmc(&#@$X_ zYa5lWBR=a=m$R}KI7#3HC4{R}m>pvLu>&dnK#9ZxvDObsi`Zg6u?JKMU*T=LU-+r( zlWz^$;YTe)>340zRON+mg&CD)Nd{~$*7m*#y!PuvN81xsN7R*(y zIEk;Zuqcr+x!qP2E0QhS$LE|vDn3>w3P`nbpzm~E?FxDqWj`0kht-I7W% zUQ9SxA*~t4gDBVm3b@;+0Sf#d9p>n_mq;}7fPq^jeO6T_wzEgpke7qeR~{4dPjXC&qEkG28@9>Pt zoJ>u)1f@G&erO-tW4x3=V`9P)LPOz$z!a{lMK3*L-R3*hH5HYZFTB?wpITYlGOv** z!P2BGS{PKfJ(%kjo)4DTK>i=s1NWP3j5!1^SWydriX1U{lf;atGN>9RKo?q!Wn+1N z-ru9FPB{N&&{ec6QHg(m$F)C3y(i;h8i`%?EI9Zm6SJL}|O zqN9%+u#~U71`2zf>(A~D(G@za+$!TS_4yKoMqf9~fEW2P5oH%7MzSFb*K#T}B3?P) zPfV4N9s3|Gwm7Nx(>|+7)=w|?*Q~A>g#6!GrGF2%gK)t{RF&}V{MofyFH_q{A|IR5 z#uMjpBe)R~h0XH3MY_|9GB0C_MTH705r=tN%CO!~aYj^{-o_)rMFA7WmGz3r-GGh3~|@$M&Qyh?*IvQCIx< z?(1D&R9Ex*M%S|cMa`+5sGR|+=6iC@9t}!f#RleAyA^eg1^n7bK@jv7@=`Qp3ci4P zMP4LNQi$4d$6l`;b-$mmN!vEm$V7sT6Tz9;?vF$R@zhy3x4K>J%jTq;F%Pl~Hn~@{Kev zqiZK+G7&D*yVJA2)z!l`LPa2W5qY)$h8_Q;m(+BA5SnZn5*OSIk9nLomW=?hKaKj(v|S3WI+e?nPLlQbEe(veo1_+g2da zvcsh^2X`e4;1%2i24F2Fd!h($*J`~GABj4>^TmJSmcl_M-C7uORGk0OtM^p2>=)*Z z`jXy|hp?KN?wEtNZ~bW-TowN4gqdJf2^29(_>kx1kE)J13I{32C_``5jtMQNQHjX& z(NUkzw=|A8R)Mz*10YcccKjrPG5%&~yV8IfdMGLdd$|d-)ioWrDd;ViF1och>Ntmm zmq%x|zX^;bDH8c%kqRWnVwG8edlgjv6F$xP^EV1doy?|NY(%r`d_X0HIq841)c$|o z3;#1s$KRJJ67Z+4zs_t>7Gc=I@qrY!BMlh|Uy#Elzq3=K566A)NreEYlTdV^u8^wT}Yk7lY|#wDr=cV`Fp^#*I2J61QlVL`CzY#=$Ca>vourl4@4 z*zWSWhRUM^N45a@nVLpcHQYn5C!kb%@5$@m$zaH^o!KA~ojS1v={!}@%!jGihP;AZ ze*gM1i91DqOULHM<>&Xek}d)xw>~%Oh0wDk_`xqXH(#2A=T8>(nwnCb?9&{iboeR< zq?;~%>Dtv%6htC}#fjMs{e*@rv92IMQVe}^ReYxFs(?80)f>OCIz=i#x&dcYcU(Gt zz&!{JpW3?>y$}~E?D#ub?Y5ajZKI7M`&&_*%dP&vv(-vj29E;~E9#4ouANA3mSCta zmVEImvIw7U*~&BL%vS!T-d}2|;K<&=i|Qs#8^_s&BE-Jl3l*V-dFBtBQYzP+{F4## zB;lsMV|lw_KDli$udr~QoWCc{AU*$clcjQ3Dyzw0v2t_#+fL@{^_ke1(i^qoWwGbK z^+$N=aJ*LMD`q{Pci|aZUrvFC!96L{JM>%XBpPraQ_1T;O>=L9@0RiKSV&o32%QEzS^(AvHntQ5T?MJ70&1!*r@R1jKdgS|KymW`h(M?>9VoHP z-D#Z&xwna4U!n|dQN4?Qp>?#i^gG$NXsY*nD|0-_BD0??472e{YDdBJCrxRZ&xZ~) za$2tt!{F!-kZK76BV^$rKKn%ZC&V#vigzRJ#{5+64Jk9}I^_nYbVt|ns@o1xcR1rb zJaGr`q@#JQm;q1kwiU)qrH_6|wq>Jx(D8|amw!jK|DO8#3+nxU|9`4K=hl(w2)qce zHPr!X(`(rVfVPs;a=|hz+O@7Z*uANX&Fhz<{*)$oX3a3M;w9~vCi+u2pkd8LJtX+PZ?(Ggvu zj>=2C3Cv)@27rLpdjr@^mMrMT9p#c43x43knKeHjC}(EwM#fZA9q-!m34Q*^v)HXn z?~?w2F)0F5zqy&eX|1v<8GLLdv#?#Uu6u81GWpqs4elC9y+46D2!|s!i2Kr?p9_d> zK8KDj$BMsPm`?A3)J(^=5r=8l(lSUTL4UYN~}g`f6iB?$)Ks5MDBTCA1a z_+;eFnx(<=ZVUl#e63IF}p7_I(jbO zZ*U?;w&K3)uZn=j9s8HA&Ob)>AotC^6RqH<#H3b{brh&gJM3Ke9^lZGX3Z*qCHgZ|i_=e*8O_ujpJU(~g7?VVLxD*2*NkQP%`AQxQeF3P;DoFt|Co&CMv1xbZ;+uA(m5Is zVHtygLss!mXtn(26JKs}yxhFbQ`x)DwuwXLV_#gWw1-)))m>f=VWku2@Dxfp*9A`k zDbFJaHy|&N=qx0Ys^kVYcdI}!FJ`4>vE^Z|)pyiPMP_-0WS_6B-S+!K`*1*EUDj=Cq7lE7Y_K z5aqu^y^M`JI7&Uy20^|RDDe?9;vL@1o%K$Yn4vsWu7tI^J>08SbwVLNyD{e4$ZtUL#n#GZH6qzRwf1<;T&hnBthv;M+S0YWHNNVtkciiCMOVd!aR{gBQl*Q`d5$h&*b4 zAQk@@>Zx`n3G9mgob?2zw;di$O!91U^=xslC)YBuy;X0A3d`a7bCp^uuIKwXIhgFM z->7X+Z_~ox^%B_Ou%`eaBC=`4Iz@;jqgK&Wr8b()Z^=>1L*Kv_zE|=)8F+D|RJD@J z6~_la2w5dWTI))J2N+KqIC_o~(o0_q8--x*ZHcqfK8lAJVKf@F#VR~#0{5Gfwe!&o zE*%g--tyNde9&YKr+>%H_+&Ui-%d5NXsZ9)qnYoT-v-YY`89iKqNS7)Lds526(Mjr zh|lzRVQ#`j3!Das5OGtF0@Ay1aLnuATe}?=_)^_S*tb~^Cilm4kpVAga|6woZqkU* ziLS%qA$MamA5hy8%zv1KRK*&Xp__Ev6)M1IAmJh}y(B#xGo6QG9w4U}7KI25{12B?scczsn?bsL0}ht5Z-|gu}J{6pAi2Ix$LtrK4NYU(D4c+ z(a1z{un%5Srk8S+GhHL2NH?`Um$4-&EuG!&EV|q<`_ij|cVIPnmC;z&VC_&pH+o*m zlC7G}4>Hw>vm%zlPQ#|5G*4l&q_6W=>alcT0jHa8d&-ub7|+aXFzmG}ong=Qprhs! zQG?tzJvv3|fz(5`gigo_4ckE= zBK9AG2eyE>yF%=<@xpY`?&vIBdp?YD#Z0BFd-mtw5ABhq%FNvD1?v$3m} zDT%dIgn1luWmOM3c>4@%c)Ho6aHS8;zC(Qy5bsy`k@q&B~V8VURSg(5ROz zKfu(SS*N?PwMI2l^FSl;@vrq6%zknk-hLg9211)!BSQ#Sl3{FT3WHR)j>Bp{ejKB5 zTfu$q^za-z^Ckv|SV_lqfU=n0e&dHmtk$ON(rUP|iSM^y6Y|oJth1MMU0Fomc9hj^ zjkzU}-Gw{(m~LjA0uY%QGqyxbjm_pgxfG1q|B*a$5S#rj!w}M&ipwX)z?}efi|8d^ z*x6D<1K#PZ^QDl9Esx83E=h(VxXJs~?MiVgitJ7c9|I8Y0Ke8MMYxF9I#DO6w&IPk zot<^6S25c;4OzYSU+b|d@F=Y&C3yLLex;OU6bfC%g82t&Um@$K+!)`wW{G5ci%lQAee`c(6_uH93*nHVh8S2ugbg*F5LH+16A;Lz+SiAMc|1>UYvGv0tdS(u3Es+$1vk1DKS z(&D6XU96r|VR~(Dnymek%12rv!uBE`;#__y<3iHGemaA6M&X4TTJWh0YqN{=oh+H@ zCqZ|^nop=3MTv#Z@-k$nLY0_973S8y6hqg?jSgs4J54`k>yAH;+nCspi*mHPkL_a* zDT^zkwgXrx=3+Zyo&Jn!5{F*<_?279`mSLXBk7;?PT~U~`hLd^Rgc`@QJGA{4tTp2 z96d8DaY+Yse1gHb7tOM{lm6w?STkLNw!KZ`@>?DI$CdK=?>o5OR9Qc9UlcMNl@(() zXc#_-4ippL;ty~Lv6bv&Oh~MViy;>kJ#Rayi&8l^7&c>{mWSm4`;@ zNc5E!4zF#!HMt6q+VO8-MF5cJRiq>U^WRCXeTi- zKj7IM6+T))Y_WF&50BLt!VSAc6xGJ%nTq7%wp2O)Id(rXc03NXO7^oA>crsI7MF zYOa_%xmo6VvFN^Rb;J|bb6V@E7Vkmm-(r-K&(g0KP^{`|=&xtt zC`!q+$V~~m5^R7S#)@Tk$5ZN~2gox(VA3)zaVPg~;jRs!CmV16Cz1X?dC#AzI7E-W;mZLJFiq;`1_G$3 zeMlF2>HuVy6?t7_lcnXpJDrNj5M70URPA{F&q%ImRt(9YOIP)dTa+>Lu|!VmXsF8V zuMN-g%^SsejWV@&`zNa7?bBR_w`;4%Lp5%wj&EM$sZ4YdH)k0?U-icJO%b_w@>zL# z!xv|soiYW+icDI+C0kIzqGt{gn4DDH>mDt{}vS5pXS*z(z_=V)hW3ED#pW<(r zxVXHEdpO5MZO4_%6Y@O6f!@Z^TG(b|)HzvicObXg31Kwx^$T5AT7N}8$LYMsp`Up( zH3Hh;?}4p*eUU8a@GkA~`=Ti(lSy|aG>a~qG2<9WW`E8j$$#QHNpyl^J^A2B2*WpA z3>9ej_Q6W>&_@j7u{u0py7(ME%>B~mc{ZgTsLvHWlh`S|{P^&fD8%@W8TzT?Uuh%>>}%zVB|*8bN|Q(uIHNHJ z@zX-UU3MW>uZz1{4k>W(HDVcb?>nVV>^MEQcOz%{lAtitKl_k|H;`{G&+Rj!u?z2% zJ<_+(#dQ7caUQPNr59#tK07~bH5p{J*m#%>CkMJL_@)^e`3lJnV1re#^5{rgum-b< z(s9S>p5x5KF6^UZ^3!^+EnmHQzC^dTCw-ZOXSro>f}7O)Zwex9kNL4MdcMVD zvvBLUIIiS;R$*atXPtcZZkcjX-CZZaH+eN+h(%A%fsq0SeEX-Y8yLtY3Nx^1x(V)A z307Z3oDYDF>R@{k3bqo1PKvBig2gVZB72|hB{&i^*)-KJ-afJwI->-Ha5=N- zTX(KU71}ioUi8~`*6bYAdEyS}I?Y&aA3>(eDgZDc-Vey6jcQA;HcG6M(b znOWqH4E>#;x4$RPn9JKe-wX%9*y~@#MSnsN{nG^hal%jiZ=@)q9k9~@HcLCNd|bWt z@V}ESK8JKq8n~FIDj5@F6Gr=;QqK#7Ot?JXy&7aNboMj9H@|btYM}F>vuH(%)csr~ zVZnUY5m)mQWlQ&xdqX9DCiJ`YU{CBg(-wnmVNQ~nYkXpATob*bs1R|lHU`Em!4(=s zh)0Y7(&SoPUK%JJ@GT{e1#|?I>~h_(p1nx$Y6iD9g~5j-!i;XDp8HXAazC$i!}EGj z7+_?d{jus#z6O~Vdc?&7boZG|JyUrImS7MNxiAMg2p0vzVs~Ya_3ty*#%AjrbF);EE41 z0cr%j1(4?)4P8WI15?g&H*|_SCqBQtll;7prBH~eJk{@Q-4Hbx*(orrSAQXKKtpd0 zFUHT1cVwN8?jkXzejeGU-~4X!xwbXk@m-kJx#ETQY^F~3Z$uc)_^q2*hM?dOxdHyd zgI^N^v7w*UQpayCL8;9H4BIE~-z^LXN8O(gW7BIPxMtVreNFnBw`Ntz>Anyn zNm*jsn%t+_xClEt#P@RI{&me%dA$o^4#`GTZ*H@`vYx$mL2@?3c1gAzTJc0RHSlC1 z`#^7FOck+7Og<8VS$Lt$X4A`${3j-v#p{0^aY<*-;vH~<;DOvpz0mslu!3(8M--L0 zGxLgI+@wYA1J}~&#Ovjhi}ccGJ#KrshrMQBvhIF9sPU{(FerAwQ?keI!0uk2Uy{Y$ z;BVY3F5ry41O6EGf5CPA{8l^yb5MYPiK*hXtaWjG-8_2#?aN||0zV$~^AFM&6~6yS zWF%2fo;;kJwXmAiaR_7^Jm3FdxXsSaI;4!A;$k~hIh+fHF@@mes&8SB#vzW`cp=ve zwtIEza~rDX=6GwGRH|E326x+~xsUw3KGqYe9WfV?V~||b=(7~bPN5~mt41@jburB) z0(sEG)TZy6lY#x5@e5W_YXm5;sbC^Nu)oSo!M+7^YsWpQ(iZb(R$Z7!Y9bs`I_xj{ z+YTs96`^ZeB9$3N=Y^TwRfWPQxDhSyONxQ z<9VS0*F#kTPquR>uEC7sWi{M0QSACrS*#|rG9wyHag`haZMzjbM&Y+wJZ0_-efz8w zzj7}MZ>5YGy0Z8bGG6`Vnsa`TT+12?W+tjDKWbm6ey{gyTze>o3-W5L`f2hf zyco(11RbW-7Ve$Aa$Mr@FH-rF&x8BtN^QKJc1776)7gBUV@xE!I7tz53MWf=lyH0h z|HH2eEb$kA{r7LZtNwdrUUe|YdZxGj%6wYd(sYc-^ zdtdiw`eVpvAQ8}CjCN;(x2vlv2Bh^EH`IN?uD-r=Dff(Nm@n&upn(F7flUR$CyvWQhQu91=#nJ#CrGO)7O=_z13<@$QSKU%J8~mXElea~ z;Ll{HF5k=E3MG-}!_!eUP|CqnyMXK;nP9uT)RX{>yG+4zmqjS;nyM6L<--C!lerc= zf1_?>dfwtrQ*Ii~x87@Rg(aUP4U|KnorRmq`2a@e!@k z)e}CkhWA?szvky#SZaCjXp1E}QS_W|m&oEZIC(ALp?`Q+15So_S<>|HXb84v7WG{2 z>P>Ohd+%$iyKXgD?pB7InHcW?rH;N|M>yGE|H1CS+W}@r$04hb1jfS7OB+^FRZ!Ys zwYmuV?8QMP0cMvPvN!WW{q-qJWE5|9VjTJd4&`?C8i`2oTklY{z+^VQ7m0}p_8X4* zB(PkOX8O8JU9SLIvU*B@hvmoYfI5#EbH&+(_~VJmtY8zo9%f*vu#>2_0PPVw15gsj z_|h&yhIor9i^k|ezwSo`+sCXN)(J1{uDswou5WYC4-ae~38!LshKDS>Ka@E37pjMu zZX*S^@8o(Do)jT|0>e$Pv(JnMtfF(Ap`75UvOmt_OFKLx8k#WaDUIW0%!&|eH`|sl zwYHd*A4YTz{2xiWKcckx9qkR`UX_ZTzW}Zwd?HGGjhXCLc^mC@@NPLdX$hAy61sJD zI~#p?xBO1p$9`}QsQ+8wYO)iqqQ9t5fsgC$;i4N8mnpZ<%Tg{?9?&soj}BaXj2&PE z%zZAIaZ(8N=Q0ry5yP0pB>|r{(g7SD2gP_6hO0_z<-xvW*N+p}Bo)Ky3v~Zf(ee~sHZk4FEfz$=*BC!__>0Cx#hE&A~*D;8m zomV6wyu?yX;$8ag;udk`h@UAb7!&mtE% zv1e)v6B2rky0>p2mwu`oPht>TeFJgA_mUq`u*=A}1T zqLy|4DnDrK-#6W`x`3~J?_uv&9aE`kP#qnE7`WPGsVsABsl3)TK7_TH*uUY@snk6b z(X8X3nhm$(Q}=ZBX4jCBy3)y?&~U!2Foc?dZ2)obtAvpU04NXeX~7FaJDW;ltw!kS zgLNzG?S_LNP}u6m{l*w_a6i6gR_^p0bo0%W*TwwuN7=B^>?%ZW{M6xMYca+J_X2b9 zHLT3ZyGAjAWp=J)x7vIt_U)|=b{22?oCe(80Z^l7l5D}q2TPxRl1SgNzX~lZbvdd1~>MIHeQ~2AA3&6vp{<}<&m&o zy_elxLJdtkEY;K>MYnjeAP%=gNksg~wO&pdd}_H7z8D~T?*{qeZ}-+uj{ zDeL}DZjpGvp58%3M7u8{K%JOrYgBN1)MCWJUBCK99wxqbD0LUHM2Gw)O`@B?T>8lk zI@^CIdtIq%Wl7(YFe6Qm2G`?my~}pRVdGk~mqrPMpTxoA%l0U9%pTR5?e$Lyp!*~- zk-#70@To8of{ogR`ryl8dLBf%AB=G>$>(Q> z9Hy=sF1MQ3?-~`%B#z7G>i4}QT!6Pbf{~l&^hTTF;y#Nz%~W^Tzez5dU7ijUPQ1Zrn`xe*a(_U zU#fF@;2#f!^-UVQW6lQerqKHrGN$p z@l5L3=dbrS;Tg}F4jjN0MvSl#_(w$79wr@^f?1xqoX^WzE=Zy06hx*R{L|u9zwUc9 zdfTwGBun&%63_#9l}(+EC>DJn5cYqqFXMe+7|Gb5vBej->x5mY4 zTQ03wD9h3{6%6s?&EjMllt_L^5F$FRwkkwyj=;7Ugv&|I_s0p9R{!ON$_8C z6+g`z-Oc6{<2N6F3&Vz!|sxnxRiEsESxvVbXAorQao_-^6h0{oHA!Q_b zhgHlpZF<^Lve1T@2(d@aNG~vNxnT6wh#y)hoL$QTOuIb$ai;p`>o$Alox}=EqHJ}T za?^zQo>tHp7$x&$dmwfRVBDVpWfH)+5_qAZ&>DECDkmE>x|P#$M>6#0*w%#LOK~@@ zm8H$ex&&Z=L(rx3YrcX)dr0KM$_cr~z zfv#}zZta&NBk}C!gp%Z;wZ$kx1@XhLCTJj(=+I}trvZaUt-! zy0nOP;?4eD;rIc6)%(;#HLI+UA^beq7f1AE+eRUMyH2-AbYPd;h(KU2KE_d?*CjGK5#oZo`?Ps(L#Rkz#J;-ud{7=aLR>bH#8Q z{x2^MI@x9UeU7wh;S|24c_Q`o2J>KY%>uT=jFshdWOKGeQ%;q$@bvs(%hSKKZv3nQ zObDO{8yyN!8|Mu=XoN(hZaY7Gx%0v~Q$Qey%;@m)q+B9(MXuw!k_Ni7i4`A`KL$y? zQQM~Giz4cP)dkX$b~(bYY6%0eTqZv3aH9S)jCRD&IZF^Jf_=TeHfPU*B0roIJ1N3= zNevCtKI0}ZuP$)KV;o3li?Mv2Tr8FG_lCD^MkKC@EvRwmod0B5WEZP-%EUO~CJQ+S zndshUd}^wG6!TewR9QP+V><^-{#8xkqk;GR-!CDJlzky$t+c@cz&HmHZN-Kc!pwhn z1GTKn&VHZnWivFyvMp{KN8Q*CxP88@nv2Ox|1@$mmqZPEs43dV;Qw`b39J-N&;C>H z88|5k#>KIQCQTn>CuVrw6`Q>0o>5ez1 zU8JfX;Wiq#rBt^rmzhm{wM)&CJ)pQXn_+q!X?k>Wa#KbBi|~^ok7OEwTOP4yr7l84 z0*olTfgtg^*6wy+fTxjPMsNO4i1`P6mKi6Fr#kT^FpR+{NZ-{Shl%AxF8^BL~u7r zk~qh_)_A#|O5s#opSGafQz^u8#-#EhiW+axJI{i|n8xn^bcJe-^8~pJh<;9S6be_1 zh|`_}!mDTPqeJh&}fjrbl$Uou_n*+>$TF~tGK{Z|n! z)Ovd>+Yk?|lPDIy3^ND}qC&*;*(WmxiOHTF-0c)TJUHI>8f5T?5R9G#UNp4}GV@al z3cRegc05Z0dOIPEXgICYyik4SnffPg_e(TXM){(XuBn>k(_gbC%sX2f9*cr)Ul`|QAhD3@ z?APT4jSrUGV)NI^CPu?dT!P*`qzbn^FVFYFLygsd=2!&zxvW>L>+#Q_bJ3w;+R6%j zGNTp+S;cADS3qZFef%a!fdVFb;PeoOcqVv_C)6k$Hw5MJv&%0$SHp7E;A!VSyG4kA<;acPnM>>C^ z5YKO(P=m?P=z;HSMkS5idj*@BGu56osap9XTMpGX0Zj1tBU|!kU!VGSy4Js*3;+B| zatBBPc@B&m*g72%CbLZ^=X#Xx;yg2(7Mg0l2EluKq<7*DNS?B!-{2TeNH}vB5~Hmj zRVA{Wde`+bPPLHBs?xS105m~ACCxYdg#I{!ZcctX$GsK6n=i49kUi|lKpF~fGn$Ta z1N~_!RvFidO{vE$q4&96v1gKhL;ak(BakwO;v%yI)eAs za&gh9jC=4&LEJt~G&Rxk73(ePI!WO~qe{{Xh%F$Dcn=8-Z*ekMF+@DCQlKsVjRM1P~-s+9n8HLx#=~W$HO+IC!zrpGQX4c8ku6a zK?3bSkaMRe&A}+}>8HaYx?ps1)31BXpR3}Q6%|;2Co@*z%h*TkGa;6#NOU`RBT#39 zhN9SQVwThuyFA9!A zZQbLc!Y267G5F6x$k!*~AfqtF>Hx~%UoYnS+s|OOKw$$%mt=!JEr#^YR!*#Y_tgp& zHHR`6=+xw!HLqFypvUvKs{=+NOYUU>BWZIC7bw^bUJBFAFHk(+*>baI}^XlK?JzrF?@!e zM6-mgTS6myTNL;doN8K3rj#E#r~LMEb*8_+vFBz2H9fmWn=J#@7>?mdnva4Gq|(SeO3r}9e;2FI+Dj) z&k$fE{G&UY%kEWHB9+x+t}lLlTDhgGU`nZVV!Us2th>-KOMwaasm^&f zXn6WAT!opG%w0uDlT3OVoDZh%!1K!Ars-d&kZKxaxm&0cP!MldZI$fArsMp_TYEh= zK5i&IZoc#Y(NR)nCEbO zqw;RCU+i7ZrCGg-SkYuwuzsOYqDMR6nE-)d3j=w6yFMRw=vAxY+6+}VL1%$tl)u(e z(8-205_h--3+i?9zE*X${zS=LlrMfhW@`!ZEesyb5!CiRmDV0yj4*Rnws>Y;X0kcy z?On&wyD)vDr|cij?%=mB^2PUPqXpKuQaZhKQpr12zX>g%awn;31C}HoB z>d}-$M#8(T%9Z|hP5K}4gbS;HnZP#TnCkJTN|>p znPcfIPmqp5eJb5^T{`T1mkec3hBSQb2=K^`<%XDedg`7`UwX#_w|flU(M@i%yEWyP z6a7~1);#f<(;SARAZ4PeT02&o{;X_yT)`7tF4P#j78!)l_IvZ2p1xBh{Yd>2w5lx ztp6D-$#)RaX`Wm(-mIojH&W9lVMB9%G?6ClR{w3epI_n!o(LqQ?w(r&i#gU8`Z|qN zh!x0#Uwi~;_KotZ#&71Ezf4kgy!a54U&Tm9e@}E@6sHV6oEmACyFR`x77j|ko1;0? zE=ukZ2YmUm8HWke-z^_q6IKZsn|u1Q%Zb+=CB^aaEXyi)W~&NDBD$3&VCf;=b?Jmg zrp3SWu%68=*FjEYAgI)gO*kj`l009rVTh*I8DtgSuhk41DS7a5D$TGg_a4J51KgYC z71+s&Yr$&Hk0>j*W!VpXllvf9r`QA!M9)2oLX+N~+$Mbm9#lR-7T=Wu!ao!Y<=@3^ z@!gNVL|v$+NMY+1b@QxtbDX-!?_>(Tq(&&_Zv3=2x^Of7gagmF0CoT;r_6vJQDn#n z`ECJNAXjWHCAy@)_c)&@sldr}L4?Q1_yIm*^jyy!IQ8;Wpq(s-B$NHfq4m{r zbb4G0e_0Y=+!$MZn{I-1jSWH4!O@GT&><|i&r*Hqq*NjJWQcmsX`ZK^&H||=hkXrpSaQn4$IqTOqJOiQ5X`}^#tcM?aJwlZ4 zzuGMvqVPHcsZk<@A3w!vQpqd@8VzjpwBJioZBB_hD(Qj_y^pl?9ZR*ZSkDnZKm{L5Q+i`KRn^@YjETgq4KO@tJuvlINF*Y9i6-EX3nQvOD$9;iV0+PqWUC5d@xl209c8M4`eh-re@Pg=Hz!-j^E3T8*42x*c^f0M zpKz9p{HB_N93P^DB|Kuc=-dca?!v#?=*U>_&uARUFihnS9I0wnw8^W8nX?>PXC~2r zctQ!m_!AiUI)#hZp>)ko=%8V;_p|0B;_xcF8``zwOYxgk3%YOkuYYcDHR9obWrND* zI}W|LwkMu%%TpG$#Y~1Zi5VHkA&ao##{(&RMjSGPXRtlno!)@KvcG{jTbI`F-v_#8xiA ztA((y&`W&HSm>tNqD6c}TBW9AjjM6~m`^1CS7Q@DvB0NvflnrR9=pbPL%U43luzV? z4Tx_@%J_Jk7k<>05)s)fq2e(wP&433v-d&ob(SJSxEXH(hr@Yt>*R+QAX6md*Crg1 zn6AnJoBG8r#sLvKG%`z|0TWgqgOK@oPRyLzU|Fq*^Yq}>Wo^#fy-QB_)oU&3))}{d zH8b5&jmXx{0PXZqJ&zUQFa5N}>GbC;Cozl%jXtV!8`Qm z$;oP_%@cp$<@g5Jmi{0v^+RfhHXDo9N#w*LKw|g4in56@2Xt9V}}VVnglER%xQTCpkeGXV6*r65 zbHe80+_8*GxL6OrJHN4Py9#kXJ6Gy|THDTWI-JG&TNRVd{viAf)u#1{2WdF_qgKy3 zI8ZWX)y=rBO>-V3{nlqwG%I>WZt{&S4W9}+r0R-tuFuo!zmox(EM6d+a2bws2OlCC z?@WJg`g1D-KGVS+`2aIG7wXe$9xBk2Df~NGd^#*IP9UD@8Rg6l3G)0(You8rwQgXF z*)u%%%DfMugujk-Y_grR^&H4Xs=zS~=l+kq2;hGvuKwo~^4IsGM_0h}g7QoId6iR3 z+^?9kxgd|}!{`IC@(-}L(}p6=d+#?Tx|E8(tH^JX!^z9vb+fF<651`#G3~kSQZE88 z-w@r`KOd|=7@#jxm}Y0p=2YQ0uPd#$IjB)#w~TuhnY*X=e~SC=peDb5Pf!#Q5CH*^ zrWENQARUQ_bP+)y6d@?ROOFbPqVy&pAT>%A>Ae%_O+TTGan|U95JxS5Q5&GuiFGglY{~lBbh6z?nf% zgzF<()$QQ!h7(3Cn&SkMx>m`!h^MDTer`W~l%}m#;<1pw7Ip7e-U zT%`MM?A(SIi;t&`s;XCca?SW{c1Qw%+`uS|N?4c{>){sVG`Gp7AlTcsxKm}MWB7)|rd6|G#k-L>==c4PhFaHmJ zolgDuyzFExuGe&Dq2OLx&5`bc4cb6g`CyqKmIOC2OU6j$tt{$vp0Mz=Qy5)#En{1V zoR;MZ!RktJ+TY|htbJuBqU=Wbisks8mSgwJN=Zt*r`T?BqChO@jelTGVRBGP@}^03 z6QE4O*eWT!(`#P^a1T-y=?jJ)RLd8@ z3nrolN;RjP$!{eN!5TukM9w~xjzTiEdJDBSx5r>S&+ZeY{LZ#M(v@;%w(b$g+Y9uT zu`~;MKi3GJk88XThog3_`>LzkuS*7`Y^{<_y`3sbrxH?rX>X4>t(E1yp*#_(*u77o z2f-~3hMd_d4HUNp$%WUA!Y&2v9^s_>madPHR4ZH)Vd)2Dva0ij8q325k34T_<8iuU z)}5sS{bIvCNLju*^e&93N9jVbt`iK125_gfuH$q@q0+J_Pw98}6V%5x=;B>~F;&nN zRU5Mo;{)|e@SZm~1$MT|k=ZYIU2mDhc#gXo_Vk*>Vlr(Htj%4&Ww4s7Tm7lL`kZ1r z9NFc!by4IeSv8*x-x!s;+=Mm{9lR|o7ax8!?snQK;|<;Jk8GVsanHLl zaL5KMcr;mtY$hg5@qxAZn6|wN_+I-k%$i-z97F^z|JL33p9H9eV4$hN0vGNM{D-E7 zPl4Oq1Kd9}HNGxbkFe7I(bVANxp<;9|Gc)=Lt|r;EN2c*@j_ZW=-JW~f}k?9miVD| z|2ar^IX|;LPn}X%3YF-c;WBmmsx6zJm{51SK=1NkZi6}qvJ&}q8^rMRT61+DVt+E591?MAXn9WR zAL>7+sB@VsFUZz?7lamKa=|E$G)z%w-+ljWjXC2a*P_|NKW>{(-d%u#DfljAUi?0} z1?^~f68~bF#P<;{V1kB;{pd2T&$yVM!e;Qm!a%HIPGaNBqZGeIuFd1wX(- z!_@BQ!j@fC3e!v7=K@hdY*X0*l3DRn)2#Vb`NJI9=QfUV{q6K$8{u|9WzL8&4-mDz zz(?kEy9$XJcQ$zJJgn4DJ{6V}K&$MSIN zE~PtN5}4ft9PjljGlJSwfh`!2*Z5jbGQ-- z=#>?}63EpM=QhX+fc5A6MTJL#aZQ4c_cT|$*gF(@4$oSoFEe%Fkf|D%8r=$q&t5T) zeV}zg)wJ)r&j&jypj_8X8U&lX0u!?k(t(d4`oPL74|m*muLgHI%2TKiRrc*S#Hlru zj_Ij738UH?5AmiNvcZ8n4)?fFwvb67i&AVNo6zJeW|Go&95mbVY-(A1 z+($QE9JL{jDZ+N=2v6i=ZwKA`Ru&*rX|Sayl{(S%(>y53MB$B2tt!7*>cClAwwEwV z#MCm)q0vkqz8lxj5yJB$J(vk2J|wBKV^V+bxA{~W6E_DFL*%2xtG5}nr{X3OLyKtf z(!dZs3b}$>##->59#{qJ=A6qhD>{30&UA{ry!KlJKO2{c8XJIvyOy(vq8Xu)ZJvK_ zrNp3_fR}^dgsIL0+b>9|BG1I*KNR70h%H7@f7x4nqjY;_)JDlCIRP(>_3D%rx8V-O1P zJGOA`(NU%-e^;#^>BK&ZykFd>GM0nmKcHQrB|qPMFukz>e_+#<&Jp=Q{o6yI;E4i} zL3vT2u=qGAzPY6EFs5cYZ-Ui@+ztGmS3;3>MSB`+OfgIJgokBt#M1B8tEF=fu-kqk ztlPE@zJ{*ynHn^>i$8L;;EU^K;{4ou$`##ZQ2mJmNt3PHQ)TP5tu-kt0NI+KXu!qh zAD|>Q_$id>a`dq`xXsx_1H_0e>m=f0@KxMq%PJoOA>+qfh?nH)wcQ}7IF-!v1`(Q} z$n-ZNk0T)0_|f?xIuyosf*w#(Rz{O6k@o0VV3qBuX+Bywecj!lOhqt6dzCuLKrh|t z3Cmu(Eq^JUB?bD!y?Z!Vr>xUrT1uu@XZvvDjImFVysJn7#8oiMepq}^w2dFty1Q#d z)qmq{Fo|bB)~{$1j4$X*qA4nLAX zw1&Dh-VNgpR^aQJwzaIo@y3X@jm<9q5N*=B&+v-elWs3Psd&!)D6_BPj1nZqZEVQT zEio@X12O0}=(Q*TKyFj2CEwPbrLd12rstwZz}W-(0j(IxmYg|l1jL~p$1L*^_-|BN zyMI{nYASqp(fG>i7swN@Wd$9xsRi{e)3YH1mZ^{~BQJmgfpnLYb> zTH8yLGf23L7&?ajeh9NUSnBzhbLEj;d$(u>Dbe8YjK`mhSn$GsRKEUO-2A>I+owMAi`0Ut&=Jq}M9v*+0jlLFJ+)cWHe>#ygn(3*2YaYjTpaGf>idZXPA z%oL%HNV~&I_=`$w+>s;IxI<+Ip4x%&92~sR!_%en+}yxf&$4}&{<+aHy)mp2ZhR1d z@nS*@NtKLf?CT>A6+?E*v#cpQ&>bHokkYDwa>7bA?0maCVgv+dBvwTV{gvFEIh&l1 zsjty7rKN}=437$MT`Vtin7BUlD$Xm>R_GTejY+NDhC^b^^m@YHAN=nOVV|f3=N(y_ z4qvY$L|`MVl*fYoRy)0}&%Q}Iv8Y37?|qDbMsKjPoOtoJxbHX_F@~;zcJj?3SHnxN zL`+dwEsTX+_yrEK%zBU27K3L|0*>*fvI4apv6{4ZuJ!83A8~-=n1i|Ak}|%GH3TxN ze^IS60ca?dsG_-!UhK~CkLZ#+A$qDu*^-z}3i zES-d92P#K9hwsYfaAX|p_*v@vTSfa9CIL#>_0rspVkz3AcYh2_Sy(Cb)Ho=GE&+F= zwoUf|JYv-e{Wz}to7ksTqvvR^%B5RD?}(Y4B-sRXfW}usbaSCJr_D zCf88K=aKS}4o36#_!QuOBdmq9#=GO>d&7`F%NFZ{hs3{Wj3ftnEVrbqtUX?c*wi+s zjyR_JH+jt!rssSOhf|qYC}y!0K}D!~T*4j5@OjRuOsrVS|NF8K_nsu)a^x*FJ$Lf! z&ye8}&i1GUjSeN$9)&NtETTY2UJ(7nAZ1Zm=VM&O2+#G_H^#XsBU|>E1k$CKL?rzF z=VpQ{rh=AGy@=RFx;0ledlQ}It6nh?y<0y1bP>6ncDPXJZk{CGTAYjr5k zIpnMXH?FjU#jxbM1P(pGuQ=F89sbyz{APzBfkW*X5Y>aZz_{)QKOyiC-M=?`HGoSn zFXBmUI$cH8SXbE=+ACZ+@!GD{hlJh@{^jIWTPR8AH@#m^>Qek4Y55UB{q(RY=WM9Y46Om9aSAW2XoPne+&!*v9$rX^5Ocd9?ng-U-yd9em=|2=vHFZ6vf4)C^Z(UrnjQr*; zgYiT$15IMqo}-uh5-`GnV4=u4G=;7T6Ogc)LYNNb2)G8cX6p-gr{o++`(9nruF9O` z-3P1*GJ;fR_b9xSfxoCYf&K@iZn`fa22$g|{?vw)yq%*EqdoECOoo4Zn{)giXNp>$ zy2IH^n%oYDjn#gpm`{457_lB$E+$}^JD2(Lu%e##DH zB?)X8GQAKtgtns zRR~A0H#r3%*Mou4*}CP>;-+6oXVWAerKU|m?SZ#3nfh!hQ7ENE~FwfhDZ^~ub5~Ip0Dlhskcpb|Rb_g&0mp?w#H=Q8!$ZoZei=;_s`#D^@?=~% zp;}T9J!gV5;-u#;ofYzFWQKWsZ_ddqozeRE?Kkt{+=2+a5XdDq=fnclRi+Q}gl9y1 zWJf!)ifONdr~?{5?+IWLYaj2n6(;h{VaE1%S8HKamK~0KY25X4N}umt@#vp@r2Fae zxgoqHcqMr#!>CH<9#2(F#U~_R@a=?MMsb5Yo1Mqm{J)38R(m$`*&j=EC>}*}wp+4U z*C9qT*VPy$3R8kltC#xNev$Iu($kkJ65EV*%6eiy?4KE1TQ^e&xd#YSX9$`^EdmdP zJJ|iJXRAhDI;L5-XrbcNXIhyD*2A27o?Ed=_8A;{titXLS;yUa8@H?0>%X41PV!kG zj(@A@7mh6xGk!0sc&2`=rP^!S)g!YS{Y-pU21rE?wO?hR7JuzGn8kcZV@Dj{*{paD zsks_OR3$|8UFr)X8DSW$hWaiYZbjyg9lF1jlBnGozUAE5e95<<)wheGTGyg`tp9!Z z%E_9V5OJ$bQ<3|ntKr2Xj1qfZ*(lEj#6KW)&!KBYqzhb>Gf#*tKveU`k6<6nqOA8B z__l*L;lqLC>tVUI4aG{?w_eYchOSxalXA`2h#a7feby)3HE~TKVNiT9#cWDyE->WM zj||tto1+wabb~aBYnq5a^eHOl0AXWL)uz|%sPqb$Bq?+0`Ec9e@MYIi8>6ZQezp?c z1f;1!S104}WvgT>9s|P+cdi}IREvVB4AFKhgI4{%KUe6A#A|`+E{UJ5G&9Hr-eFuKiU2(uo}1 zr)kdyj?^EObvR5C+9yJ4twNz=hXTTbX}|QPM5U{pR%a+uKz6$f3FfZ&pi9o3`|QEU zse@-+_=vmieQhcZGQIPUn1cKsvfQ~(^`u$>FW{#P&oleA;Edo)!}ARI>yK;m=y8wh{}DY7IJLe;7_ zumI~W)8Fr;Fk{=s4OiD=yyp&!-!pow{f@?{p5j^!?EMbJ4P1EbDAjAWqWwycD`nlB zrr%5o282$X0R=*6DU1J{@7-ZNshmSh?f~@ZMO1YJ>CzD{0)yOb_(1X@7s&5v0u}m< zG0ZPrY(ZW0g6*5=t1c$Lf+HX01W}z~Gw99tzA|u|%!>u_M;K zb;*2U(XDwSnOtK;wCNkcD^>^539`RCG7yy26f9+kowv|NPs_7Wpgy$Y90@X$*;a70 zL(>uFU6Qfh_4hTK;myUrY-ZcyE-+KnKsby9(V6#50zM1pyxW9hp$wT$l7!V{41~A!ei}EuH-MYhH{ydw^o-4O8OoVQ3634iluR8vTV+G@Cs(!5{tZ%(+tCku-KlwGfe?s2i4>p+Mo}57OT4 zMHc~J|5k+32-C=$sXEgNG2iN9zBDuPxBAxo_fiT<(o|Y21_@lQ;N4^0Y^%o}q{)=$ zDP$xL1+leo>j^Rs4sbOyy%mrR?_C_qN|=Pq&Qs_--y;C_pq{UL1lSA96JEMkSiqrO zEbz3h<}dH*HQ39mhKjT$H_sSYwQx<*q}X%yRZRr<`1}!#p+P^~pHswtY#}HUgKQMi zRu?rjTbsx0`1a(-RvX%@=O1@IS%~cRbgRj#T1^ZwL;%cM$C>sslo50dNZi<4FbQ4z zv(GR>Y272IguAk}LsU&`maIh9r>0lTCDI6C-Z9?*hLY9nysgP|m^Lfz2tAY`*%x4ItB6HQ30( z#!+(OsoYg$uzpgiKGQW5wjVuN6G@9YU+mO+eONw1abN9Ovx1-JukWs}Q zkpVXpEU8XgtG=Vx_GL@Ic-@4}Si&ohjYJKu-T=9>jG7z5vxXR8jJn_jgf#5bjM|pj zxSe-{D}I~4YQkgOM=MTa(T5pcAGg2ZY)6^~;!pYeX9;0x1`$fB%z0KHH{J;Dokgvr z90W~j)d}tmZn($wlo`#9%%MRuLSvsG$zGC+7>&X;7!p+1G|$9XsAQ#PXl*RG^9n@c zc{J8S^;TBRF`NzzFNYTSBR?ZU|ENoi5cs`&6K0&drck`?7q+W{?-S%pG#Dp~q7Poj zSL*nlyycY_u4k&n|N2o=Tn;Tmo^Op9symv}ie3pvVr|f2sFUq%mXuDu20doDI<;hZ z7>4eVq5>X2EwLvQUc|Z3JMWhgtz~|uY@Y*nd;#e*LNB5*$mr0AS)Q0kt|106VY8T^)R5tb*gd>!~6B z{5jn!%htDF?u2uNpMm;>eWGVW+y**!SYy69ZwlQI&QtwLF#V*hxSpd%sf(gxrqM)h z>}&hR2m$x!x_b9L5_2MGULJAqhHzCgG5ewqi(XyEHl{B&`^CI+lqxQHR*jwyTNQ3^ za4ojhMwSCv>1l9ApyUyn8cg$#zZViP(9YssRE6sf_fx=@bbftfDb06fMTRg7!n~Ucj&(IHfS}+Qz(~#;U|ej!VKBs&<+V zrx^Q``JPQz#C1F@UUiu&u+H$?+>9$5aCR&^+_c&)tA7V5rEoM%0YNODK!N<7-^alw10}9cORK%&F`6iePJxqyvDBN01NL+Lw4*DO^S#cE zlWC6l0ITF!YlHM2SgPXE-KFjAgOk8a9)sW_>0c~a6sk!}#!+D36N_4G{ zpj_8w5Vw&cmBch?PMj&9YxixV8_O&(lNa0_A1!P!^mk$Eg%QLQNi6ey5Uh!JB$`Pg z039huc($4o*D4zy8#>wgs>?KIMJNqCU8q z!aG@~+A~r*erCl3IbFFmA5D3+BE^AOQXYEPH6vEL7T!5K=NqO}q^OX8u$hHwE6c1a z$FsWfEy}Y2mnf+j`PEsH`II#@2py>Ec8 zCF(A=Sp&djlGMRK9t@E$vNHG0x}Pw^HvJc_bLa!km>KR9Xx+is~7TzsZBQ z^!%)^U>|D^Y!0;Y=e%(Nx&p-WK*eqZOABU>wV37SzHo0#oKZJ*kiAZ7_p)&6?cDw zRs$VD_!-u3tvoeJw|}o-8=Ff;8#D{7=gi&j@8~5A%<_}U&wM8UQUwiIltO1o0pxnh zbwVcIiCh_AeZ!zzksUI(#MKr$wbb|7`hny|di*J^M7-<>=X@i-VdLuyOgm!jMR9ON z$jUi>i#xZ;Iakvp)|3>TLrimbGp&mPnqA)u`;pqwu4sbdJmst>Q4K!zTl1?Ltq+b~ zGhP#a!?{byYYe0Q(Q5i}bJ(X(0r2|perN!7!Zw=3ingD}zOgXwMKFf-%lRNPcb zCrY8%nx7IRHgNPXWJepa3bxlGzm=fI1aNU@8~TE{@;&cs0{R!!q>?z@4F9?II(bM< znjyB&uf-qIowf(25gHqEGvHEiN|7{hd|f)-wu}ve6;TINzcZfps>t`Bt+C>^V^wp> zjAvdhLw;)|_rxCXaggN8cFxKUUqJr18K(0cEeZd$%(efhKM!L<$=mNS&ns@*yCQ~pO~)8@PA05LdQJ^4 zf=N17B;*IPS(C^n8rRXrE$uZ^InSG zpsqSPc3Efc!LwxJl8jpyJ-*0{83&KHlad1O-rK2LnXj8XN=iwGkk5+Csyv|!mu$Xvi)nkPlUEYkmp~FO zAnbQ2V%?e*8eD-O%U(^?<)*r(ILC08Y+p{nxRjI#n)B`?1dglI>+rQiur6gbB&PO9n(;xn(z*ork-xsg`Hc$LNHP8V4zd!q<_@h@^kXUUqGMdC(7;Hz* z^ec4spmzI+_vGIo_fH z!%LirVX3_iW8;Hq!(A=!Ns+2xKKrqQSt~!RlnTxcv6bB z%%)XRSy^aucfxw-VoPIRUWg}`xcg%O8vBU@km8%Co}~36U!uSfTp(dje|NxQj!jjj z!&~)S@KC%TQ>yNAqre+~J6=T-P9C#~aH<5u+=fof$FC)q4jk4*YRB&m%YSq|aNYex zI7y)^18@ar3SA#jk#Basvw3eh(i5b80X+Bjpbnwz+pvY+s`dP5mvy^db=I~lp9}!K zP*97+oeqf^mZ|gO4nEqR@G=RRU;OU)`|=+bL1_p;%$XASFbV+Ic*+}a4@LD>!m)f| z2YV{klFH?F=tjDy{awRRuMAm3iVbuwJ6^fF;(kP!My_l?2x7fW*d_#50^4_UC_*>f z@aeRE=nWH%Sau0NkNB<<9j{oGV%iLV>b6`7-KMQ?(n?akQ1VBLD)zj_N~lUS=@Ypb zy^_jTJ+fyB?7ew;u*+RWr{{t-rmnm1gs|G8?naMUd+NJbSQ|+$mNNR(E7#YY7gVC% z@OX_Z59U3-{g^Bv#U-+Kr~G1yUhnno#?FJ)Tw~YXpL$D%O{&YDbgl=Gv}J+-EkL1B z&iY{Xc^BbKm?se!=Pqf+`GJpwqV9Hq#4-o$X4-jI;g&SBctzP$T7d>n@-i%=ccT(D z#L4uCVZ;q`GC~-!Y%4&nrSKreo23&xNII)w*hAE-a(Q~eG;KcDhQ`xlLP?_RqE=TI zs;#38vTCdSVWFT+94bv&$N_EXP}LdGmuo-ou6}xyWL&nB_B1U{-oF*C8z8QsYUJvD zp`?f|d)c$qw>J8PeT3aoxew_Zq6a+DH{APRSYt^|z_KC+4waBprsAW5KDsJi3Yy(a zxcAIi+q@xF3%=QqL@gJg*<>Xf%l1-)93kJY#1&nXxy2bMmBaW)@k^UIvshcw&-`(q zdxrjBRLvW1bbbw=#cPp;bx0|`h6>X8m*6-iOaxJU0d5Nu-mE5$EuYsJ+nqB|&?fzt72Bk0a5q7++hIWg_q9Xua9E{l z4brvDx6^E%=g{!d2D_Q;9gyESERh(J;ocH6k>4z26605EQ{H<%Oe- zhtwbHa#@WXWZU0t=8(-#tXJR1K%CftvJKawFc$6TG^XowIQdr?ZYdT#6!@V(> z5OqfF(YKyH+~2qyVkQM+laj7%@aX4gRM2+=8x%R)3w=`^_G4~YN6N{gOJY8Oi=tYK zHiC0WP@j}nKc0Gmc%@57C=!zco;kyHZ zHh7$6uQpYq$d1Hd&l;6j#TOgMdk_-J8iBN%egjc5p(w*Yox>y4(!w_|jFye8cW@6|#MiBMvxY%l%*^#6x1*Aor>LKw zrMNcqsX}?vef{57%E?t&L?(`ihZB~D3Mf-K=T*!LfOu!e1I*#u$EXN%l#{daz3 zy)}&e+})b2+r32u%R*==iI35Mh7*q_Vm=E&gaVq%}^ zdv;>L^G0{z(;+opt*QPlwR;PeHAm%<^Y~AF8xIopQ5Tfl+MgtCEO>T~mRxF7CR(iK z=QouJe$>z?5^2#M(8l_p#eSEsy5>RXAgdWjm*wO0YI@*EB_kAh*m56<*@$xXA+BXD z9t8pm?n%T6cXhUL1Rq?g&uBu;ta!=bf<(rREOtG9tAU5?wwq=TZVL;sMM=y*p}eH9 zk!%T;xRDNXG>bYTJmxO$?u(~Pi62~pWEQQPE-th?-1(%k#Zbgh6iQv8Rk3Eog-G!y zps6KF6bB~UZjs_9GyFFvGdJ`V-D_*xzrV@*!~7)FR_6XM(Y=#@S7+e=N1X8=?NfZD z)e@VxIuhS%yt>RqsL^R?n+g{46WUA&9*T)kb_(0ukkQXqvV4@7bo)ym?I!I>-j`aQ zn#?9|n!f85eH@+FE}b4$K>6>2#<}@rEw%GDH}TUV)jIqs{&r`p6GP9ao!2q8rNge_ zq2!$QbHTSJDI%mR*zAa~fQ0jx4b5s_DVYE6v8W#MbEh_NbvC&>yCipw;gkB59pZUz z%eA9;{y62a0^?pe^CZIpp|*^jipOU`p^PI~m|(d^oT-!Mg}dud{dj03rtH1D(&T?9 zb*@ZIhCb;oEvNi9HqsKZ15QyucEBj?ws7A*3f*+x4hunh?M|vqkTI5$!d{Hz`I_=P z;k5E*v<&<7@&$d3k$suh>gX=7^W9rbJrXr^kzsUSC}cffElZexX3MN#-BY@n?F51d z?JfuLo;yVhE8SN?5Soq*md94~fxKzz`c7lv*xuN(Y1NcfPqyGgn$aK6XHUloy?80- zpyuei{D34va3)%i?^Q0a zX_T$0e&-Yf=>eGlib9`2G$k>UD?BN@$PR6a$aW%0C6#Nmq$o+}UZ#(`h;PVg5ci7Nd0?g;;Dd9=JK*>Mn18C_=l7(=K5st{_U(&nDQmL< z7i1j-KlpOEE_U=Qk7G?*`}T_%GyR`)6*V)-QDKngrR(>Mjn_C)KK($!_L%=$P> zt-_Eie<+s48n6+-Y73$;?vi8BZe{eggfi^Qu5q|9=2C~x6BfA7ySm!R@)XM{x;^K4 zcP;=jkgYxH0{DR>->#5x2M5Q>pC8LQKeosdM^Dzr^+^D2CrSL=Kvs-2IoHM zFi5TQo+d^*8g;T&Suspa;JOteJKwcIRw7x)xxixn`&YFVb+aUkvMC)c3AUN%pHHt* zUO})n3G)Vc3-TKZGu)s9;0O~smD(l0sjK8r&XhSMUCj7mptO3`B|k|KzRvF=D~}fpdM+Z1iFe%|7Y{#`=W<6mEm-08Y;&vi2fcO&`l<*i z#LI|zSZ&0vJnrrYreb#=W8HQMUKq9SVXm#95-U?_@BPF(J6XF+yni8mA-41+(|#s} z4j_JMxhZ|i_3}SZ*pW5;gfMm0W_lLQ;xBRews#(%m|L#@6u&TxCu)%$T zs4$6{eiib`8|ZR`sf&T84Eq$4L!I+`v*+)qLvd|&PZBhVwYW<$b(6EF^tZ-Z_KSef z1+hw_By`NjCuuZ~bz;*4JO*RA#m2I}i&a%;uemEaO{@w|q$w0DB*!$`dFzjE9PSaADlK+Xr0ad1L^5u_~+dBcUkuT z-0r{QlkyPlzD&4`jz@d!6U5)})${d)KyPNQ91It#7vzQH?z&C87tRX8oLy39?U;?! z+^VlNGhA^JkN8xvjGpboTN9{>{DgDJa7Ya@Trybr-NMqqhaqQ|vFn}zVme%dcgput zbzk%P{EWX5avV+OiY7rv{IX7Si1$t-I$sIgy#_U|Rq!>?Ga!x_3j%=WzopJ6HxuR{ zcqL%c;65)Ye3x86;wXX-GSY1(b;zG`IwX<)FOmb3dncPg|D&>5?%W zsl_H1ZCwxh;$HDzH56`80;2(3giyD0OVJ!ANy#niS$RQ= zKkEWj`TleUv3HO&;&^jHB#IsBydQ1_4G-2~Ypt(Zo=?wD;eSibUz9BnCUKgcipy0% zNszDQLvHKtmU^F)S$ox6^AyXu>8z~m7F)xXC#OO|lSA;IO%A5NOA`I>c;^1jG5-}N j(BIkrfAw$w75w&p^*R2X ({ @@ -195,6 +196,12 @@ vi.mock('./TelegramLinkAccountStep', () => ({ TelegramLinkAccountStep: () =>
Telegram link step
, })); +vi.mock('@/app/(onboarding)/setup/TelegramManagedBotPairing', () => ({ + TelegramManagedBotPairing: () => ( + + ), +})); + vi.mock('@/trpc/client', () => ({ useTRPC: () => ({ slack: { @@ -779,6 +786,52 @@ describe('CommsProviderSection', () => { ).toBeInTheDocument(); }); + it('defaults to managed Telegram pairing when it is available', () => { + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Set it up' })); + + expect( + screen.getByRole('button', { name: 'Create my Telegram bot' }), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { + name: 'Enter a bot token manually instead', + }), + ).toBeInTheDocument(); + expect( + screen.queryByPlaceholderText('Telegram Bot Token'), + ).not.toBeInTheDocument(); + + fireEvent.click( + screen.getByRole('button', { + name: 'Enter a bot token manually instead', + }), + ); + + expect( + screen.getByPlaceholderText('Telegram Bot Token'), + ).toBeInTheDocument(); + fireEvent.click( + screen.getByRole('button', { + name: '← Use automatic setup instead', + }), + ); + expect( + screen.getByRole('button', { name: 'Create my Telegram bot' }), + ).toBeInTheDocument(); + }); + it('lets users save Telegram with only a bot token when the secret is auto-managed', () => { const onSave = vi.fn(); @@ -845,6 +898,8 @@ describe('CommsProviderSection', () => { { />, ); - fireEvent.click(screen.getByRole('button', { name: 'Set it up' })); - expect(screen.getByText('Connected to @RoomoteBot')).toBeInTheDocument(); expect(screen.queryByText('Webhook connected')).not.toBeInTheDocument(); + expect( + screen.queryByText(/Create a new Telegram bot/), + ).not.toBeInTheDocument(); + expect( + screen.queryByPlaceholderText('Telegram Bot Token'), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Save' }), + ).not.toBeInTheDocument(); + expect(screen.getByText('Telegram link step')).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Remove' }), + ).toBeInTheDocument(); }); }); diff --git a/apps/web/src/components/settings/CommsProviderSection.tsx b/apps/web/src/components/settings/CommsProviderSection.tsx index f1818fae0..75342e0e9 100644 --- a/apps/web/src/components/settings/CommsProviderSection.tsx +++ b/apps/web/src/components/settings/CommsProviderSection.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import Link from 'next/link'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; @@ -34,6 +34,7 @@ type CommsProviderStatus = Omit & { id: CommsProviderId; telegramWebhook?: TelegramWebhookStatus | null; telegramBotUsername?: string | null; + telegramPairingAvailable?: boolean; discord?: import('@/trpc/commands/comms').DiscordCommsStatus | null; }; @@ -87,6 +88,10 @@ import { } from '@/components/system'; import { Section } from './Section'; import { TelegramLinkAccountStep } from './TelegramLinkAccountStep'; +import { + TelegramManagedBotPairing, + type TelegramPairingSuccess, +} from '@/app/(onboarding)/setup/TelegramManagedBotPairing'; import { DiscordSetupStatus } from './DiscordSetupStatus'; const MICROSOFT_APP_ID_PATTERN = @@ -569,6 +574,7 @@ export function CommsProviderSection({ hasConfiguredValues: boolean; } | null>(null); const [removeDialogOpen, setRemoveDialogOpen] = useState(false); + const [useManualTelegramSetup, setUseManualTelegramSetup] = useState(false); useEffect(() => { setValues(nonSecretInitialValues); @@ -662,7 +668,14 @@ export function CommsProviderSection({ const hasEditableFields = visibleFields.some( (field) => !field.runtimeSatisfied, ); - const providerOwnsActions = provider.id === 'slack' && !hasConfiguredValues; + const showManagedTelegramSetup = + provider.id === 'telegram' && + provider.telegramPairingAvailable === true && + !hasConfiguredValues && + !useManualTelegramSetup; + const providerOwnsActions = + (provider.id === 'slack' && !hasConfiguredValues) || + showManagedTelegramSetup; const teamsBotAppIdField = provider.fields.find( (field) => field.envVarName === 'R_TEAMS_BOT_APP_ID', @@ -718,6 +731,41 @@ export function CommsProviderSection({ onClear(provider.id); }; + const handleTelegramPaired = useCallback( + async (result: TelegramPairingSuccess) => { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: trpc.comms.status.queryKey(), + }), + queryClient.invalidateQueries({ + queryKey: trpc.environmentVariables.list.queryKey(), + }), + queryClient.invalidateQueries({ + queryKey: trpc.linkedAccounts.telegram.queryKey(), + }), + ]); + + if (result.webhookWarning) { + toast.warning( + `Your bot was created, but Roomote could not connect it: ${result.webhookWarning}`, + ); + } + if (result.profilePhotoWarning) { + toast.warning( + `Your bot is connected, but Roomote could not set its profile photo: ${result.profilePhotoWarning}`, + ); + } + if (!result.webhookWarning && !result.profilePhotoWarning) { + toast.success( + result.botUsername + ? `@${result.botUsername} is connected.` + : 'Your Telegram bot is connected.', + ); + } + }, + [queryClient, trpc], + ); + return ( <>
) : (
- - createSlackApp.mutate({ configToken }) - } - onValueChange={(envVarName, value) => - setValues((current) => ({ ...current, [envVarName]: value })) - } - onEditingSavedValueChange={(envVarName, editing) => - setEditingSavedValues((current) => ({ - ...current, - [envVarName]: editing, - })) - } - onClearedSavedValueChange={(envVarName, cleared) => - setClearedSavedValues((current) => ({ - ...current, - [envVarName]: cleared, - })) - } - /> + {showManagedTelegramSetup ? ( +
+ + +
+ ) : provider.id === 'telegram' && hasConfiguredValues ? null : ( +
+ {provider.id === 'telegram' && + provider.telegramPairingAvailable === true && + !hasConfiguredValues && ( + + )} + + createSlackApp.mutate({ configToken }) + } + onValueChange={(envVarName, value) => + setValues((current) => ({ + ...current, + [envVarName]: value, + })) + } + onEditingSavedValueChange={(envVarName, editing) => + setEditingSavedValues((current) => ({ + ...current, + [envVarName]: editing, + })) + } + onClearedSavedValueChange={(envVarName, cleared) => + setClearedSavedValues((current) => ({ + ...current, + [envVarName]: cleared, + })) + } + /> +
+ )}
{provider.id === 'telegram' && provider.telegramWebhook && ( @@ -856,17 +936,18 @@ export function CommsProviderSection({ {clearPending ? : null} )} - {hasEditableFields && ( - - )} + {hasEditableFields && + !(provider.id === 'telegram' && hasConfiguredValues) && ( + + )}
)}
diff --git a/apps/web/src/trpc/commands/comms/telegram-pairing.test.ts b/apps/web/src/trpc/commands/comms/telegram-pairing.test.ts index 59c49d81d..8953abb0f 100644 --- a/apps/web/src/trpc/commands/comms/telegram-pairing.test.ts +++ b/apps/web/src/trpc/commands/comms/telegram-pairing.test.ts @@ -2,33 +2,39 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { UserAuthSuccess } from '@/types'; -const { envMock, redisStore, redisMock, saveCommsAuthConfigMock } = vi.hoisted( - () => { - const store = new Map(); - return { - envMock: { - R_TELEGRAM_PAIRING_URL: 'https://pairing.example.com' as - | string - | undefined, - }, - redisStore: store, - redisMock: { - get: vi.fn(async (key: string) => store.get(key) ?? null), - set: vi.fn(async (key: string, value: string, ..._args: unknown[]) => { - store.set(key, value); - return 'OK'; - }), - del: vi.fn(async (key: string) => { - store.delete(key); - return 1; - }), - }, - saveCommsAuthConfigMock: vi.fn(async () => ({ - telegramWebhook: { registered: true, error: null }, - })), - }; - }, -); +const { + envMock, + profilePhotoMock, + redisStore, + redisMock, + saveCommsAuthConfigMock, +} = vi.hoisted(() => { + const store = new Map(); + return { + envMock: { + R_TELEGRAM_PAIRING_URL: 'https://pairing.example.com' as + | string + | undefined, + TELEGRAM_API_BASE_URL: 'https://telegram.example.com', + }, + profilePhotoMock: vi.fn(), + redisStore: store, + redisMock: { + get: vi.fn(async (key: string) => store.get(key) ?? null), + set: vi.fn(async (key: string, value: string, ..._args: unknown[]) => { + store.set(key, value); + return 'OK'; + }), + del: vi.fn(async (key: string) => { + store.delete(key); + return 1; + }), + }, + saveCommsAuthConfigMock: vi.fn(async () => ({ + telegramWebhook: { registered: true, error: null }, + })), + }; +}); vi.mock('@/lib/server/env', () => ({ Env: envMock })); vi.mock('@roomote/redis', () => ({ getRedis: () => redisMock })); @@ -44,6 +50,10 @@ vi.mock('./index', () => ({ saveCommsAuthConfigCommand: saveCommsAuthConfigMock, })); +vi.mock('./telegram-profile-photo', () => ({ + setTelegramBotProfilePhotoBestEffort: profilePhotoMock, +})); + import { checkTelegramPairingCommand, startTelegramPairingCommand, @@ -78,6 +88,10 @@ describe('telegram pairing commands', () => { envMock.R_TELEGRAM_PAIRING_URL = 'https://pairing.example.com'; vi.unstubAllGlobals(); vi.clearAllMocks(); + profilePhotoMock.mockResolvedValue({ + updated: true, + error: null, + }); }); describe('startTelegramPairingCommand', () => { @@ -174,10 +188,14 @@ describe('telegram pairing commands', () => { provider: 'telegram', values: { R_TELEGRAM_BOT_TOKEN: CHILD_BOT_TOKEN }, }); + expect(profilePhotoMock).toHaveBeenCalledWith({ + botToken: CHILD_BOT_TOKEN, + }); expect(result).toEqual({ status: 'ready', botUsername: 'roomote_abc_bot', telegramWebhook: { registered: true, error: null }, + telegramProfilePhoto: { updated: true, error: null }, }); expect(redisStore.has(`telegram-pairing-client:${PAIRING_ID}`)).toBe( false, @@ -214,6 +232,7 @@ describe('telegram pairing commands', () => { status: 'ready', botUsername: 'roomote_abc_bot', telegramWebhook: { registered: true, error: null }, + telegramProfilePhoto: { updated: true, error: null }, }); expect(redisStore.has(`telegram-pairing-result:${PAIRING_ID}`)).toBe( false, @@ -223,6 +242,33 @@ describe('telegram pairing commands', () => { ); }); + it('keeps pairing successful when the profile photo cannot be set', async () => { + redisStore.set(`telegram-pairing-client:${PAIRING_ID}`, 'token'); + stubFetchJson(200, { + status: 'ready', + token: CHILD_BOT_TOKEN, + botUsername: 'roomote_abc_bot', + }); + profilePhotoMock.mockResolvedValue({ + updated: false, + error: 'Photo dimensions are invalid', + }); + + await expect( + checkTelegramPairingCommand(buildMockAuth(), { + pairingId: PAIRING_ID, + }), + ).resolves.toEqual({ + status: 'ready', + botUsername: 'roomote_abc_bot', + telegramWebhook: { registered: true, error: null }, + telegramProfilePhoto: { + updated: false, + error: 'Photo dimensions are invalid', + }, + }); + }); + it('treats a 404 from the service as an expired pairing', async () => { redisStore.set(`telegram-pairing-client:${PAIRING_ID}`, 'token'); stubFetchJson(404, { error: 'Unknown pairing.' }); diff --git a/apps/web/src/trpc/commands/comms/telegram-pairing.ts b/apps/web/src/trpc/commands/comms/telegram-pairing.ts index c92bb3e9b..a26c49e45 100644 --- a/apps/web/src/trpc/commands/comms/telegram-pairing.ts +++ b/apps/web/src/trpc/commands/comms/telegram-pairing.ts @@ -6,6 +6,7 @@ import type { UserAuthSuccess } from '@/types'; import { assertAdmin } from '../environment-variables'; import { saveCommsAuthConfigCommand } from './index'; +import { setTelegramBotProfilePhotoBestEffort } from './telegram-profile-photo'; /** * Client half of Telegram managed-bot pairing: talks to the pairing service @@ -103,6 +104,7 @@ type TelegramPairingCheckResult = status: 'ready'; botUsername: string | null; telegramWebhook: { registered: boolean; error: string | null } | null; + telegramProfilePhoto: { updated: boolean; error: string | null }; }; type StashedPairingResult = { @@ -216,6 +218,10 @@ export async function checkTelegramPairingCommand( values: { R_TELEGRAM_BOT_TOKEN: fetched.result.token }, }); + const telegramProfilePhoto = await setTelegramBotProfilePhotoBestEffort({ + botToken: fetched.result.token, + }); + const redis = getRedis(); await redis.del(`${CLIENT_PAIRING_RESULT_KEY_PREFIX}${input.pairingId}`); await redis.del(`${CLIENT_PAIRING_KEY_PREFIX}${input.pairingId}`); @@ -224,5 +230,6 @@ export async function checkTelegramPairingCommand( status: 'ready', botUsername: fetched.result.botUsername, telegramWebhook: saved.telegramWebhook ?? null, + telegramProfilePhoto, }; } diff --git a/apps/web/src/trpc/commands/comms/telegram-profile-photo.test.ts b/apps/web/src/trpc/commands/comms/telegram-profile-photo.test.ts new file mode 100644 index 000000000..e5c986ffa --- /dev/null +++ b/apps/web/src/trpc/commands/comms/telegram-profile-photo.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { envMock } = vi.hoisted(() => ({ + envMock: { + TELEGRAM_API_BASE_URL: 'https://telegram.example.com', + }, +})); + +vi.mock('@/lib/server/env', () => ({ Env: envMock })); + +import { setTelegramBotProfilePhotoBestEffort } from './telegram-profile-photo'; + +const BOT_TOKEN = '7000000003:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; +const JPEG_BYTES = new Uint8Array([0xff, 0xd8, 0xff, 0xd9]); + +describe('setTelegramBotProfilePhotoBestEffort', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('uploads the Roomote JPEG through setMyProfilePhoto', async () => { + const fetchMock = vi.fn( + async (_input: RequestInfo | URL, _init?: RequestInit) => + Response.json({ ok: true, result: true }), + ); + const readFileMock = vi.fn(async (_filePath: string) => JPEG_BYTES); + + await expect( + setTelegramBotProfilePhotoBestEffort({ + botToken: BOT_TOKEN, + fetchImpl: fetchMock, + readFileImpl: readFileMock, + }), + ).resolves.toEqual({ updated: true, error: null }); + + expect(readFileMock).toHaveBeenCalledWith( + expect.stringMatching(/public\/roomote-logo\.jpg$/), + ); + expect(fetchMock).toHaveBeenCalledWith( + `https://telegram.example.com/bot${BOT_TOKEN}/setMyProfilePhoto`, + expect.objectContaining({ method: 'POST' }), + ); + + const request = fetchMock.mock.calls[0]?.[1]; + expect(request?.body).toBeInstanceOf(FormData); + const form = request?.body as FormData; + expect(form.get('photo')).toBe( + JSON.stringify({ + type: 'static', + photo: 'attach://profile_photo', + }), + ); + const photo = form.get('profile_photo'); + expect(photo).toBeInstanceOf(File); + expect((photo as File).name).toBe('roomote-logo.jpg'); + expect((photo as File).type).toBe('image/jpeg'); + }); + + it('returns Telegram rejection details without throwing', async () => { + const fetchMock = vi.fn( + async (_input: RequestInfo | URL, _init?: RequestInit) => + Response.json( + { ok: false, description: 'Photo dimensions are invalid' }, + { status: 400 }, + ), + ); + + await expect( + setTelegramBotProfilePhotoBestEffort({ + botToken: BOT_TOKEN, + fetchImpl: fetchMock, + readFileImpl: vi.fn(async (_filePath: string) => JPEG_BYTES), + }), + ).resolves.toEqual({ + updated: false, + error: 'Photo dimensions are invalid', + }); + }); + + it('turns local or network failures into a generic warning', async () => { + await expect( + setTelegramBotProfilePhotoBestEffort({ + botToken: BOT_TOKEN, + readFileImpl: vi.fn(async (_filePath: string) => { + throw new Error('missing logo'); + }), + }), + ).resolves.toEqual({ + updated: false, + error: 'The Roomote profile photo could not be uploaded.', + }); + }); +}); diff --git a/apps/web/src/trpc/commands/comms/telegram-profile-photo.ts b/apps/web/src/trpc/commands/comms/telegram-profile-photo.ts new file mode 100644 index 000000000..c309af81e --- /dev/null +++ b/apps/web/src/trpc/commands/comms/telegram-profile-photo.ts @@ -0,0 +1,79 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; + +import { Env } from '@/lib/server/env'; + +const ROOMOTE_TELEGRAM_PROFILE_PHOTO = 'roomote-logo.jpg'; +const TELEGRAM_PROFILE_PHOTO_TIMEOUT_MS = 10_000; + +type TelegramProfilePhotoResult = { + updated: boolean; + error: string | null; +}; + +/** + * Set the newly created child bot's Roomote profile photo. Managed-bot + * creation links cannot carry a photo, so this runs after the child token has + * been retrieved. It is deliberately best-effort: a cosmetic failure must + * never undo working credentials or webhook registration. + */ +export async function setTelegramBotProfilePhotoBestEffort(input: { + botToken: string; + fetchImpl?: typeof fetch; + readFileImpl?: (filePath: string) => Promise; +}): Promise { + try { + const readLogo = + input.readFileImpl ?? + (async (filePath: string) => Uint8Array.from(await readFile(filePath))); + const logo = await readLogo( + path.join(process.cwd(), 'public', ROOMOTE_TELEGRAM_PROFILE_PHOTO), + ); + const form = new FormData(); + const attachmentName = 'profile_photo'; + form.set( + 'photo', + JSON.stringify({ + type: 'static', + photo: `attach://${attachmentName}`, + }), + ); + form.set( + attachmentName, + new Blob([Uint8Array.from(logo)], { type: 'image/jpeg' }), + ROOMOTE_TELEGRAM_PROFILE_PHOTO, + ); + + const apiBaseUrl = ( + Env.TELEGRAM_API_BASE_URL ?? 'https://api.telegram.org' + ).replace(/\/+$/, ''); + const response = await (input.fetchImpl ?? fetch)( + `${apiBaseUrl}/bot${input.botToken}/setMyProfilePhoto`, + { + method: 'POST', + body: form, + signal: AbortSignal.timeout(TELEGRAM_PROFILE_PHOTO_TIMEOUT_MS), + }, + ); + const payload = (await response.json().catch(() => null)) as { + ok?: boolean; + description?: string; + } | null; + + if (!response.ok || !payload?.ok) { + return { + updated: false, + error: + payload?.description ?? + `Telegram rejected the profile photo (${response.status}).`, + }; + } + + return { updated: true, error: null }; + } catch { + return { + updated: false, + error: 'The Roomote profile photo could not be uploaded.', + }; + } +} From b72df5b420254d8aea571ff6196c8e8a92af303d Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Thu, 23 Jul 2026 23:27:40 -0400 Subject: [PATCH 4/6] Silence Telegram profile photo failures --- apps/web/src/app/(onboarding)/setup/StepTelegramSetup.tsx | 8 +------- .../app/(onboarding)/setup/TelegramManagedBotPairing.tsx | 6 ------ apps/web/src/components/settings/CommsProviderSection.tsx | 8 +------- apps/web/src/trpc/commands/comms/telegram-pairing.test.ts | 6 ------ apps/web/src/trpc/commands/comms/telegram-pairing.ts | 4 +--- 5 files changed, 3 insertions(+), 29 deletions(-) diff --git a/apps/web/src/app/(onboarding)/setup/StepTelegramSetup.tsx b/apps/web/src/app/(onboarding)/setup/StepTelegramSetup.tsx index 93a7ca09b..f45de2a24 100644 --- a/apps/web/src/app/(onboarding)/setup/StepTelegramSetup.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepTelegramSetup.tsx @@ -85,13 +85,7 @@ export function StepTelegramSetup({ toast.warning( `Your bot was created, but Roomote could not connect it: ${result.webhookWarning}`, ); - } - if (result.profilePhotoWarning) { - toast.warning( - `Your bot is connected, but Roomote could not set its profile photo: ${result.profilePhotoWarning}`, - ); - } - if (!result.webhookWarning && !result.profilePhotoWarning) { + } else { toast.success( result.botUsername ? `@${result.botUsername} is connected.` diff --git a/apps/web/src/app/(onboarding)/setup/TelegramManagedBotPairing.tsx b/apps/web/src/app/(onboarding)/setup/TelegramManagedBotPairing.tsx index c43378d00..36d513573 100644 --- a/apps/web/src/app/(onboarding)/setup/TelegramManagedBotPairing.tsx +++ b/apps/web/src/app/(onboarding)/setup/TelegramManagedBotPairing.tsx @@ -13,7 +13,6 @@ const PAIRING_POLL_INTERVAL_MS = 2_000; export type TelegramPairingSuccess = { botUsername: string | null; webhookWarning: string | null; - profilePhotoWarning: string | null; }; /** @@ -78,11 +77,6 @@ export function TelegramManagedBotPairing({ result.telegramWebhook && !result.telegramWebhook.registered ? (result.telegramWebhook.error ?? 'unknown error') : null, - profilePhotoWarning: - result.telegramProfilePhoto && - !result.telegramProfilePhoto.updated - ? (result.telegramProfilePhoto.error ?? 'unknown error') - : null, }); } else if (result.status === 'expired') { setPairing(null); diff --git a/apps/web/src/components/settings/CommsProviderSection.tsx b/apps/web/src/components/settings/CommsProviderSection.tsx index 75342e0e9..57c8551d4 100644 --- a/apps/web/src/components/settings/CommsProviderSection.tsx +++ b/apps/web/src/components/settings/CommsProviderSection.tsx @@ -749,13 +749,7 @@ export function CommsProviderSection({ toast.warning( `Your bot was created, but Roomote could not connect it: ${result.webhookWarning}`, ); - } - if (result.profilePhotoWarning) { - toast.warning( - `Your bot is connected, but Roomote could not set its profile photo: ${result.profilePhotoWarning}`, - ); - } - if (!result.webhookWarning && !result.profilePhotoWarning) { + } else { toast.success( result.botUsername ? `@${result.botUsername} is connected.` diff --git a/apps/web/src/trpc/commands/comms/telegram-pairing.test.ts b/apps/web/src/trpc/commands/comms/telegram-pairing.test.ts index 8953abb0f..d5d3fd3bf 100644 --- a/apps/web/src/trpc/commands/comms/telegram-pairing.test.ts +++ b/apps/web/src/trpc/commands/comms/telegram-pairing.test.ts @@ -195,7 +195,6 @@ describe('telegram pairing commands', () => { status: 'ready', botUsername: 'roomote_abc_bot', telegramWebhook: { registered: true, error: null }, - telegramProfilePhoto: { updated: true, error: null }, }); expect(redisStore.has(`telegram-pairing-client:${PAIRING_ID}`)).toBe( false, @@ -232,7 +231,6 @@ describe('telegram pairing commands', () => { status: 'ready', botUsername: 'roomote_abc_bot', telegramWebhook: { registered: true, error: null }, - telegramProfilePhoto: { updated: true, error: null }, }); expect(redisStore.has(`telegram-pairing-result:${PAIRING_ID}`)).toBe( false, @@ -262,10 +260,6 @@ describe('telegram pairing commands', () => { status: 'ready', botUsername: 'roomote_abc_bot', telegramWebhook: { registered: true, error: null }, - telegramProfilePhoto: { - updated: false, - error: 'Photo dimensions are invalid', - }, }); }); diff --git a/apps/web/src/trpc/commands/comms/telegram-pairing.ts b/apps/web/src/trpc/commands/comms/telegram-pairing.ts index a26c49e45..e0e7e7348 100644 --- a/apps/web/src/trpc/commands/comms/telegram-pairing.ts +++ b/apps/web/src/trpc/commands/comms/telegram-pairing.ts @@ -104,7 +104,6 @@ type TelegramPairingCheckResult = status: 'ready'; botUsername: string | null; telegramWebhook: { registered: boolean; error: string | null } | null; - telegramProfilePhoto: { updated: boolean; error: string | null }; }; type StashedPairingResult = { @@ -218,7 +217,7 @@ export async function checkTelegramPairingCommand( values: { R_TELEGRAM_BOT_TOKEN: fetched.result.token }, }); - const telegramProfilePhoto = await setTelegramBotProfilePhotoBestEffort({ + await setTelegramBotProfilePhotoBestEffort({ botToken: fetched.result.token, }); @@ -230,6 +229,5 @@ export async function checkTelegramPairingCommand( status: 'ready', botUsername: fetched.result.botUsername, telegramWebhook: saved.telegramWebhook ?? null, - telegramProfilePhoto, }; } From 2f9c4100bed4e7921b06ced5b1f8dc8d6912eb52 Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Thu, 23 Jul 2026 23:39:46 -0400 Subject: [PATCH 5/6] Explain managed Telegram claim step --- .../setup/TelegramManagedBotPairing.tsx | 17 +++++++++-------- .../commands/comms/telegram-pairing.test.ts | 4 ++-- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/apps/web/src/app/(onboarding)/setup/TelegramManagedBotPairing.tsx b/apps/web/src/app/(onboarding)/setup/TelegramManagedBotPairing.tsx index 36d513573..dc85ca4ee 100644 --- a/apps/web/src/app/(onboarding)/setup/TelegramManagedBotPairing.tsx +++ b/apps/web/src/app/(onboarding)/setup/TelegramManagedBotPairing.tsx @@ -16,8 +16,8 @@ export type TelegramPairingSuccess = { }; /** - * Automatic Telegram bot creation via Telegram's Managed Bots feature: one - * tap in Telegram creates a deployment-owned bot, and the pairing service + * Automatic Telegram bot creation via Telegram's Managed Bots feature: the + * manager bot requests a deployment-owned bot, and the pairing service * hands the token straight to the server, so the admin never touches * BotFather or copies a token. */ @@ -106,9 +106,10 @@ export function TelegramManagedBotPairing({ return (

- Scan this QR code with your phone, or open the link on this device. - When Telegram opens, tap{' '} - Create Bot to confirm. + Scan this QR code with your phone, or open the link on this device. In + Telegram, tap Start, then{' '} + Create Bot. You can edit the + bot's name or username before confirming.

@@ -140,9 +141,9 @@ export function TelegramManagedBotPairing({ return (

- Roomote can create your Telegram bot for you — no BotFather, no token - copy-paste. You confirm the new bot with one tap in Telegram and Roomote - connects it automatically. + Roomote can create your Telegram bot for you — no BotFather or token + copy-paste. Follow the prompts in Telegram, choose the name and username + you want, and Roomote connects it automatically.

{expired && (

diff --git a/apps/web/src/trpc/commands/comms/telegram-pairing.test.ts b/apps/web/src/trpc/commands/comms/telegram-pairing.test.ts index d5d3fd3bf..124c5075e 100644 --- a/apps/web/src/trpc/commands/comms/telegram-pairing.test.ts +++ b/apps/web/src/trpc/commands/comms/telegram-pairing.test.ts @@ -114,7 +114,7 @@ describe('telegram pairing commands', () => { pairingId: PAIRING_ID, pollToken: 'secret-poll-token', suggestedUsername: 'roomote_abc_bot', - deepLink: 'https://t.me/newbot/RoomoteSetupBot/roomote_abc_bot', + deepLink: 'https://t.me/RoomoteSetupBot?start=opaque-claim-token', expiresInSeconds: 900, }); @@ -126,7 +126,7 @@ describe('telegram pairing commands', () => { ); expect(result).toEqual({ pairingId: PAIRING_ID, - deepLink: 'https://t.me/newbot/RoomoteSetupBot/roomote_abc_bot', + deepLink: 'https://t.me/RoomoteSetupBot?start=opaque-claim-token', suggestedUsername: 'roomote_abc_bot', expiresInSeconds: 900, }); From f261d5c00a016e89ade9e0648bf850cded6beece Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Thu, 23 Jul 2026 23:52:51 -0400 Subject: [PATCH 6/6] Start Telegram pairing automatically --- .../TelegramManagedBotPairing.client.test.tsx | 93 +++++++++++++++++++ .../setup/TelegramManagedBotPairing.tsx | 56 +++++++---- 2 files changed, 129 insertions(+), 20 deletions(-) create mode 100644 apps/web/src/app/(onboarding)/setup/TelegramManagedBotPairing.client.test.tsx diff --git a/apps/web/src/app/(onboarding)/setup/TelegramManagedBotPairing.client.test.tsx b/apps/web/src/app/(onboarding)/setup/TelegramManagedBotPairing.client.test.tsx new file mode 100644 index 000000000..b62dd0ac3 --- /dev/null +++ b/apps/web/src/app/(onboarding)/setup/TelegramManagedBotPairing.client.test.tsx @@ -0,0 +1,93 @@ +import { render, screen, waitFor } from '@testing-library/react'; + +const { startMutateMock } = vi.hoisted(() => ({ + startMutateMock: vi.fn(), +})); + +vi.mock('sonner', () => ({ + toast: { error: vi.fn() }, +})); + +vi.mock('qrcode.react', () => ({ + QRCodeSVG: ({ value }: { value: string }) => ( +

{value}
+ ), +})); + +vi.mock('@tanstack/react-query', () => ({ + useMutation: (options: { + kind: 'start' | 'check'; + onSuccess?: (result: { pairingId: string; deepLink: string }) => void; + }) => { + if (options.kind === 'start') { + const mutate = () => { + startMutateMock(); + options.onSuccess?.({ + pairingId: '11111111-1111-4111-8111-111111111111', + deepLink: 'https://t.me/RoomoteSetupBot?start=opaque-claim-token', + }); + }; + return { + isError: false, + isPending: false, + mutate, + reset: vi.fn(), + }; + } + + return { + mutateAsync: vi.fn(), + }; + }, +})); + +vi.mock('@/trpc/client', () => ({ + useTRPC: () => ({ + comms: { + startTelegramPairing: { + mutationOptions: (options: object) => ({ + ...options, + kind: 'start', + }), + }, + checkTelegramPairing: { + mutationOptions: (options?: object) => ({ + ...options, + kind: 'check', + }), + }, + }, + }), +})); + +import { TelegramManagedBotPairing } from './TelegramManagedBotPairing'; + +describe('TelegramManagedBotPairing', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('starts pairing on mount and shows the resulting QR code and link', async () => { + render(); + + await waitFor(() => { + expect(startMutateMock).toHaveBeenCalledTimes(1); + }); + + const link = await screen.findByRole('link', { + name: /Open in Telegram/, + }); + expect(link).toHaveAttribute( + 'href', + 'https://t.me/RoomoteSetupBot?start=opaque-claim-token', + ); + expect(screen.getByTestId('pairing-qr')).toHaveTextContent( + 'https://t.me/RoomoteSetupBot?start=opaque-claim-token', + ); + expect( + screen.queryByRole('button', { + name: 'Create my Telegram bot', + }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/app/(onboarding)/setup/TelegramManagedBotPairing.tsx b/apps/web/src/app/(onboarding)/setup/TelegramManagedBotPairing.tsx index dc85ca4ee..f3535b1fc 100644 --- a/apps/web/src/app/(onboarding)/setup/TelegramManagedBotPairing.tsx +++ b/apps/web/src/app/(onboarding)/setup/TelegramManagedBotPairing.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useMutation } from '@tanstack/react-query'; import { QRCodeSVG } from 'qrcode.react'; import { toast } from 'sonner'; @@ -36,6 +36,7 @@ export function TelegramManagedBotPairing({ const [expired, setExpired] = useState(false); const pollBusyRef = useRef(false); const lastErrorRef = useRef(null); + const autoStartedRef = useRef(false); const start = useMutation( trpc.comms.startTelegramPairing.mutationOptions({ @@ -50,9 +51,15 @@ export function TelegramManagedBotPairing({ const check = useMutation(trpc.comms.checkTelegramPairing.mutationOptions()); const checkMutateAsync = check.mutateAsync; - const stopPairing = useCallback(() => { - setPairing(null); - }, []); + const startMutate = start.mutate; + useEffect(() => { + if (disabled || autoStartedRef.current) { + return; + } + + autoStartedRef.current = true; + startMutate(); + }, [disabled, startMutate]); useEffect(() => { if (!pairing) { @@ -129,9 +136,6 @@ export function TelegramManagedBotPairing({ Waiting for you to confirm in Telegram…
-
@@ -145,20 +149,32 @@ export function TelegramManagedBotPairing({ copy-paste. Follow the prompts in Telegram, choose the name and username you want, and Roomote connects it automatically.

- {expired && ( -

- That pairing expired before the bot was created. Start again when - you're ready. -

+ {expired || start.isError ? ( + <> +

+ {expired + ? 'That pairing expired before the bot was created.' + : 'Telegram setup could not be prepared.'} +

+ + + ) : ( +
+ + Preparing Telegram setup… +
)} -
); }