From f93395256856fb3dd5bd414e0704172f9e3453fc Mon Sep 17 00:00:00 2001 From: Noa Flaherty Date: Sun, 22 Feb 2026 13:56:57 -0500 Subject: [PATCH] feat: add Telegram guardian verification flow Co-Authored-By: Claude --- .../vellum-skills/telegram-setup/SKILL.md | 46 +++++++++++++++++-- assistant/src/daemon/handlers/config.ts | 41 +++++++++++++++++ assistant/src/daemon/ipc-contract.ts | 17 +++++++ .../src/runtime/routes/channel-routes.ts | 41 +++++++++++++++++ 4 files changed, 141 insertions(+), 4 deletions(-) diff --git a/assistant/src/config/vellum-skills/telegram-setup/SKILL.md b/assistant/src/config/vellum-skills/telegram-setup/SKILL.md index 709341a6dce..5eb07dbf33b 100644 --- a/assistant/src/config/vellum-skills/telegram-setup/SKILL.md +++ b/assistant/src/config/vellum-skills/telegram-setup/SKILL.md @@ -46,9 +46,45 @@ If the webhook secret changes (e.g., secret rotation), the gateway's credential ### Step 4: Register Bot Commands -Send the `telegram_config` IPC message with `action: "set_commands"` to register the `/new` command. The daemon handles token retrieval from secure storage internally — you do not need to retrieve it yourself. +Send the `telegram_config` IPC message with `action: "set_commands"` to register the `/new` and `/guardian-verify` commands: -### Step 5: Validate Routing Configuration +```json +{ + "type": "telegram_config", + "action": "set_commands", + "commands": [ + { "command": "new", "description": "Start a new conversation" }, + { "command": "guardian_verify", "description": "Verify your guardian identity" } + ] +} +``` + +The daemon handles token retrieval from secure storage internally — you do not need to retrieve it yourself. + +### Step 5: Verify Guardian Identity + +Now link the user's Telegram account as the trusted guardian for this bot. Tell the user: "Now let's verify your guardian identity. This links your Telegram account as the trusted guardian for this bot." + +1. Send the `guardian_verification` IPC message with `action: "create_challenge"` to generate a verification challenge: + +```json +{ + "type": "guardian_verification", + "action": "create_challenge" +} +``` + +2. The daemon returns a `guardian_verification_response` with `success: true`, `secret`, and `instruction`. Display the instruction to the user. It will look like: "Send `/guardian-verify ` to your bot from your Telegram account within 10 minutes." + +3. Wait for the user to confirm they have sent the command. The verification happens automatically when the bot receives the `/guardian-verify` message — the channel inbound handler validates the token and creates the guardian binding. + +4. If the user confirms success: "Guardian verified! Your Telegram account is now the trusted guardian for this bot." + +5. If the user reports failure or the challenge times out (10 minutes): "The verification code may have expired. Let's generate a new one." Then repeat from substep 1. + +**Note:** Guardian verification is optional but recommended. If the user declines or wants to skip, proceed to Step 6 without blocking. + +### Step 6: Validate Routing Configuration Verify that the gateway routing is configured to deliver inbound messages to the assistant: @@ -57,12 +93,13 @@ Verify that the gateway routing is configured to deliver inbound messages to the If routing is misconfigured, inbound Telegram messages will be rejected and the gateway will send a visible notice to the chat explaining the issue (rate-limited to once per 5 minutes per chat). -### Step 6: Report Success +### Step 7: Report Success Summarize what was done: - Bot verified and credentials stored securely via daemon - Webhook registration: handled automatically by the gateway -- Bot commands registered: /new +- Bot commands registered: /new, /guardian_verify +- Guardian identity verified (if completed) - Routing configuration validated The gateway automatically detects credentials from the vault, reconciles the Telegram webhook registration, and begins accepting Telegram webhooks shortly. In single-assistant mode, routing is automatically configured — no manual environment variable configuration or webhook registration is needed. If the webhook secret changes later, the gateway's credential watcher will automatically re-register the webhook. If the ingress URL changes (e.g., tunnel restart), the assistant daemon triggers an immediate internal reconcile so the webhook re-registers automatically without a gateway restart. @@ -94,4 +131,5 @@ The following steps still require **manual** action: |------|---------| | Bot token from @BotFather | User must create a bot and provide the token via secure prompt | | Bot command registration | Registered via the setup skill (Step 4 above) | +| Guardian verification | User sends `/guardian-verify ` to the bot (Step 5 above) | | Multi-assistant routing | Requires manual `GATEWAY_ASSISTANT_ROUTING_JSON` configuration | diff --git a/assistant/src/daemon/handlers/config.ts b/assistant/src/daemon/handlers/config.ts index 43f4355028d..2619ec0cf82 100644 --- a/assistant/src/daemon/handlers/config.ts +++ b/assistant/src/daemon/handlers/config.ts @@ -28,8 +28,10 @@ import type { VercelApiConfigRequest, TwitterIntegrationConfigRequest, TelegramConfigRequest, + GuardianVerificationRequest, ToolPermissionSimulateRequest, } from '../ipc-protocol.js'; +import { createVerificationChallenge } from '../../runtime/channel-guardian-service.js'; import { log, CONFIG_RELOAD_DEBOUNCE_MS, defineHandlers, type HandlerContext } from './shared.js'; import { MODEL_TO_PROVIDER } from '../session-slash.js'; @@ -1089,6 +1091,44 @@ export async function handleTelegramConfig( } } +export function handleGuardianVerification( + msg: GuardianVerificationRequest, + socket: net.Socket, + ctx: HandlerContext, +): void { + try { + if (msg.action !== 'create_challenge') { + ctx.send(socket, { + type: 'guardian_verification_response', + success: false, + error: `Unknown action: ${String(msg.action)}`, + }); + return; + } + + // In single-assistant mode, 'self' is the canonical assistant ID used + // by channel routes when validating challenges on the inbound path. + const assistantId = 'self'; + const channel = msg.channel ?? 'telegram'; + const result = createVerificationChallenge(assistantId, channel, msg.sessionId); + + ctx.send(socket, { + type: 'guardian_verification_response', + success: true, + secret: result.secret, + instruction: result.instruction, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log.error({ err }, 'Failed to handle guardian verification'); + ctx.send(socket, { + type: 'guardian_verification_response', + success: false, + error: message, + }); + } +} + export function handleEnvVarsRequest(socket: net.Socket, ctx: HandlerContext): void { const vars: Record = {}; for (const [key, value] of Object.entries(process.env)) { @@ -1220,6 +1260,7 @@ export const configHandlers = defineHandlers({ vercel_api_config: handleVercelApiConfig, twitter_integration_config: handleTwitterIntegrationConfig, telegram_config: handleTelegramConfig, + guardian_verification: handleGuardianVerification, env_vars_request: (_msg, socket, ctx) => handleEnvVarsRequest(socket, ctx), tool_permission_simulate: handleToolPermissionSimulate, tool_names_list: (_msg, socket, ctx) => handleToolNamesList(socket, ctx), diff --git a/assistant/src/daemon/ipc-contract.ts b/assistant/src/daemon/ipc-contract.ts index 5cb420a558f..ca8fd716b66 100644 --- a/assistant/src/daemon/ipc-contract.ts +++ b/assistant/src/daemon/ipc-contract.ts @@ -549,6 +549,21 @@ export interface TelegramConfigResponse { error?: string; } +export interface GuardianVerificationRequest { + type: 'guardian_verification'; + action: 'create_challenge'; + channel?: string; // Defaults to 'telegram' + sessionId?: string; +} + +export interface GuardianVerificationResponse { + type: 'guardian_verification_response'; + success: boolean; + secret?: string; + instruction?: string; + error?: string; +} + export interface TwitterIntegrationConfigResponse { type: 'twitter_integration_config_response'; success: boolean; @@ -1032,6 +1047,7 @@ export type ClientMessage = | VercelApiConfigRequest | TwitterIntegrationConfigRequest | TelegramConfigRequest + | GuardianVerificationRequest | TwitterAuthStartRequest | TwitterAuthStatusRequest | SessionsClearRequest @@ -2396,6 +2412,7 @@ export type ServerMessage = | VercelApiConfigResponse | TwitterIntegrationConfigResponse | TelegramConfigResponse + | GuardianVerificationResponse | TwitterAuthResult | TwitterAuthStatusResponse | OpenUrl diff --git a/assistant/src/runtime/routes/channel-routes.ts b/assistant/src/runtime/routes/channel-routes.ts index a860d45de08..31dcf5842e6 100644 --- a/assistant/src/runtime/routes/channel-routes.ts +++ b/assistant/src/runtime/routes/channel-routes.ts @@ -14,6 +14,7 @@ import { IngressBlockedError } from '../../util/errors.js'; import { getLogger } from '../../util/logger.js'; import { deliverChannelReply, deliverApprovalPrompt } from '../gateway-client.js'; import { parseApprovalDecision } from '../channel-approval-parser.js'; +import { validateAndConsumeChallenge } from '../channel-guardian-service.js'; import { getChannelApprovalPrompt, buildApprovalUIMetadata, @@ -243,6 +244,46 @@ export async function handleChannelInbound( const replyCallbackUrl = body.replyCallbackUrl; + // ── Guardian verification command intercept ── + // Handled before normal message processing so it never enters the agent loop. + if ( + !result.duplicate && + trimmedContent.startsWith('/guardian-verify ') && + replyCallbackUrl && + body.senderExternalUserId + ) { + const token = trimmedContent.slice('/guardian-verify '.length).trim(); + if (token.length > 0) { + const verifyResult = validateAndConsumeChallenge( + 'self', + sourceChannel, + token, + body.senderExternalUserId, + externalChatId, + ); + + const replyText = verifyResult.success + ? 'Guardian verified successfully. Your identity is now linked to this bot.' + : 'Verification failed. The code may be invalid or expired.'; + + try { + await deliverChannelReply(replyCallbackUrl, { + chatId: externalChatId, + text: replyText, + }, bearerToken); + } catch (err) { + log.error({ err, externalChatId }, 'Failed to deliver guardian verification reply'); + } + + return Response.json({ + accepted: true, + duplicate: false, + eventId: result.eventId, + guardianVerification: verifyResult.success ? 'verified' : 'failed', + }); + } + } + // ── Approval interception (gated behind feature flag) ── if ( isChannelApprovalsEnabled() &&