import { randomBytes } from "node:crypto"; import type { GuardianDelivery } from "@vellumai/gateway-client"; import { hashVerificationSecret } from "@vellumai/gateway-client"; import { MarkChannelRevokedIpcResponseSchema } from "@vellumai/gateway-client/gateway-ipc-contracts"; import { startVerificationCall } from "../../calls/call-domain.js"; import { countRecentSendsToDestination, createInboundVerificationSession, createOutboundSession, findActiveSession, getPendingSession, revokePendingSessions, updateSessionDelivery, } from "../../channels/gateway-verification-sessions.js"; import type { ChannelId } from "../../channels/types.js"; import { findContactChannel, getChannelById, getContact, } from "../../contacts/contact-store.js"; import { gatewayContactChannelState } from "../../contacts/gateway-channel-read.js"; import { getGuardianDelivery, guardianForChannel, } from "../../contacts/guardian-delivery-reader.js"; import { notifyContactsChanged } from "../../contacts/notify-contacts-changed.js"; import type { ContactChannel } from "../../contacts/types.js"; import { ipcCallPersistent } from "../../ipc/gateway-client.js"; import { getBindingByChannelChat } from "../../persistence/external-conversation-store.js"; import { resolveGuardianName } from "../../prompts/user-reference.js"; import { DAEMON_INTERNAL_ASSISTANT_ID } from "../../runtime/assistant-scope.js"; import { type ChannelReadinessService, createReadinessService, } from "../../runtime/channel-readiness-service.js"; import { getGuardianBinding, isGuardianBoundForChannel, } from "../../runtime/channel-verification-service.js"; import { cancelOutbound, deliverVerificationDiscord, deliverVerificationSlack, deliverVerificationTelegram, DESTINATION_RATE_WINDOW_MS, MAX_SENDS_PER_DESTINATION_WINDOW, normalizeTelegramDestination, } from "../../runtime/verification-outbound-actions.js"; import { composeVerificationText, GUARDIAN_VERIFY_TEMPLATE_KEYS, } from "../../runtime/verification-templates.js"; import { getTelegramBotUsername } from "../../telegram/bot-username.js"; import { normalizePhoneNumber } from "../../util/phone.js"; import { log } from "./shared.js"; // -- Channel verification result -- // // The shape returned by the verification service functions and served by the // HTTP channel-verification routes. export interface ChannelVerificationSessionResult { success: boolean; secret?: string; instruction?: string; /** Present when action is 'status'. */ bound?: boolean; guardianExternalUserId?: string; /** The channel this status pertains to (e.g. "telegram", "phone"). Present when action is 'status'. */ channel?: ChannelId; /** The assistant ID scoped to this status. Present when action is 'status'. */ assistantId?: string; /** The delivery chat ID for the guardian (e.g. Telegram chat ID). Present when action is 'status' and bound is true. */ guardianDeliveryChatId?: string; /** Optional channel username/handle for the bound guardian (for UI display). */ guardianUsername?: string; /** Optional display name for the bound guardian (for UI display). */ guardianDisplayName?: string; /** Whether a pending verification challenge exists for this (assistantId, channel). Used by relay setup to detect active voice verification sessions. */ hasPendingChallenge?: boolean; error?: string; /** Human-readable error detail (e.g. for already_bound failures). */ message?: string; /** Conversation ID for outbound verification flows. */ verificationSessionId?: string; /** Epoch ms when the verification session expires. */ expiresAt?: number; /** Epoch ms after which a resend is allowed. */ nextResendAt?: number; /** Number of sends for this session. */ sendCount?: number; /** Telegram deep-link URL for bootstrap (M3 placeholder). */ telegramBootstrapUrl?: string; /** True when the outbound session is still in pending_bootstrap state (Telegram handle flow). Prevents the client from clearing the bootstrap URL during status polling. */ pendingBootstrap?: boolean; } // --------------------------------------------------------------------------- // Readiness service singleton // --------------------------------------------------------------------------- // Lazy singleton — created on first use so module-load stays lightweight. let _readinessService: ChannelReadinessService | undefined; export function getReadinessService(): ChannelReadinessService { if (!_readinessService) { _readinessService = createReadinessService(); } return _readinessService; } // --------------------------------------------------------------------------- // Gateway delivery lookup // --------------------------------------------------------------------------- /** * Resolve the gateway-owned delivery (ACL source of truth) for a contact * channel, matching on type and either address or externalChatId. Returns * `undefined` when the gateway is unreachable or has no binding for it. */ async function deliveryForChannel( channel: Pick, ): Promise { const guardians = await getGuardianDelivery({ channelTypes: [channel.type] }); if (!guardians) { return undefined; } return guardians.find( (g) => g.channelType === channel.type && ((channel.address && g.address === channel.address) || (channel.externalChatId != null && g.externalChatId === channel.externalChatId)), ); } // --------------------------------------------------------------------------- // Extracted business logic functions // --------------------------------------------------------------------------- export async function createInboundChallenge( channel?: ChannelId, rebind?: boolean, conversationId?: string, ): Promise { const resolvedChannel = channel ?? "telegram"; // Gateway-backed presence guard: block re-binding when a guardian is already // bound. Null-list (gateway unreachable) is treated as bound, so a transient // miss blocks rather than letting a second binding through. const alreadyBound = await isGuardianBoundForChannel(resolvedChannel); if (alreadyBound && !rebind) { return { success: false, error: "already_bound", message: "A guardian is already bound for this channel. Revoke the existing binding first, or set rebind: true to replace.", channel: resolvedChannel, }; } const result = await createInboundVerificationSession( resolvedChannel, conversationId, ); return { success: true, secret: result.secret, instruction: result.instruction, channel: resolvedChannel, }; } export async function getVerificationStatus( channel?: ChannelId, ): Promise { const resolvedAssistantId = DAEMON_INTERNAL_ASSISTANT_ID; const resolvedChannel = channel ?? "telegram"; const binding = await getGuardianBinding( resolvedAssistantId, resolvedChannel, ); // Read the guardian displayName from the gateway delivery — getGuardianBinding // is a compatibility shim that doesn't carry metadataJson. const guardians = await getGuardianDelivery({ channelTypes: [resolvedChannel], }); const bindingDisplayName = guardians ? (guardianForChannel(guardians, resolvedChannel)?.displayName ?? undefined) : undefined; const guardianDisplayName = resolveGuardianName(bindingDisplayName); // Resolve username from external conversation store. let guardianUsername: string | undefined; if (binding?.guardianDeliveryChatId) { const ext = getBindingByChannelChat( resolvedChannel, binding.guardianDeliveryChatId, ); if (ext?.username) { guardianUsername = ext.username; } } // Active outbound session state is included so the UI can resume after app // restart and detect bootstrap completion. const [pendingSession, activeOutboundSession] = await Promise.all([ getPendingSession(resolvedChannel), // The guardian's own session. A channel can carry one per person // verifying, so an unscoped read would report a requester's session as // the guardian's pending verification. findActiveSession(resolvedChannel, { verificationPurpose: "guardian" }), ]); const hasPendingChallenge = pendingSession != null; const outboundFields: Record = {}; if (activeOutboundSession) { outboundFields.verificationSessionId = activeOutboundSession.id; outboundFields.expiresAt = activeOutboundSession.expiresAt; outboundFields.nextResendAt = activeOutboundSession.nextResendAt; outboundFields.sendCount = activeOutboundSession.sendCount; if (activeOutboundSession.status === "pending_bootstrap") { outboundFields.pendingBootstrap = true; } } return { success: true, bound: binding != null, guardianExternalUserId: binding?.guardianExternalUserId, guardianUsername, guardianDisplayName, channel: resolvedChannel, assistantId: resolvedAssistantId, guardianDeliveryChatId: binding?.guardianDeliveryChatId, hasPendingChallenge, ...outboundFields, }; } // --------------------------------------------------------------------------- // Revoke verification binding // --------------------------------------------------------------------------- export async function revokeVerificationForChannel( channel?: ChannelId, ): Promise { const assistantId = DAEMON_INTERNAL_ASSISTANT_ID; const resolvedChannel = channel ?? "telegram"; // Session teardown relays to the gateway (session SoT). Cancel any active // outbound session and pending challenges first (the macOS app uses // action: "revoke" to cancel an in-flight challenge even before a binding // exists, e.g. during verification setup). await cancelOutbound({ channel: resolvedChannel }); await revokePendingSessions(resolvedChannel); // Capture binding before revoking so we can downgrade the guardian's // channel — without this, the guardian would still pass the ACL check. const bindingBeforeRevoke = await getGuardianBinding( assistantId, resolvedChannel, ); if (!bindingBeforeRevoke) { return { success: true, bound: false, channel: resolvedChannel, }; } const contactResult = findContactChannel({ channelType: resolvedChannel, address: bindingBeforeRevoke.guardianExternalUserId, externalChatId: bindingBeforeRevoke.guardianDeliveryChatId, }); // Relay the ACL downgrade to the gateway (source of truth). The gateway's // mark_channel_revoked enforces the guardian guard and dual-writes the // contact-channel status back to the assistant DB. Gate on the gateway // delivery's live status, not the assistant DB column, so a redundant revoke // is still skipped for an already-revoked binding. if (contactResult) { const delivery = await deliveryForChannel(contactResult.channel); const deliveryStatus = delivery?.status; if ( deliveryStatus === "active" || deliveryStatus === "pending" || deliveryStatus === "unverified" ) { const result = await ipcCallPersistent("mark_channel_revoked", { contactChannelId: contactResult.channel.id, reason: "guardian_binding_revoked", }); const parsed = MarkChannelRevokedIpcResponseSchema.parse(result); if (!parsed.ok) { throw new Error("mark_channel_revoked relay returned ok: false"); } // Emit the invalidation so open client views stop showing the channel // as active after the gateway dual-writes it to "revoked". notifyContactsChanged(); } } return { success: true, bound: false, channel: resolvedChannel, }; } // --------------------------------------------------------------------------- // Trusted-contact verification (shared across transports) // --------------------------------------------------------------------------- /** Session TTL in seconds (matches challenge TTL of 10 minutes). */ const SESSION_TTL_SECONDS = 600; /** * Map a contact channel type to the verification ChannelId used by the * verification service. Returns null for unsupported channel types. */ function toVerificationChannel(channelType: string): ChannelId | null { switch (channelType) { case "phone": return "phone"; case "telegram": return "telegram"; case "slack": return "slack"; case "discord": return "discord"; default: return null; } } /** * Transport-agnostic trusted-contact verification. Looks up the contact * channel, derives the verification channel and destination, checks rate * limits, and creates the appropriate outbound session. * * Returns a `ChannelVerificationSessionResult` so both the message handler * and the HTTP handler can wrap it in their respective response envelopes. */ export async function verifyTrustedContact( contactChannelId: string, assistantId: string, ): Promise { const channel = getChannelById(contactChannelId); if (!channel) { return { success: false, error: `Channel "${contactChannelId}" not found`, }; } const contact = getContact(channel.contactId); if (!contact) { return { success: false, error: `Contact "${channel.contactId}" not found`, }; } // Already-verified short-circuit derived from the gateway contact-channel read // (ACL SoT), which covers all contacts — not just guardian deliveries. const gwState = await gatewayContactChannelState(channel); if (gwState?.status === "active" && gwState.verifiedAt != null) { return { success: false, error: "already_verified", message: "Channel is already verified", }; } const verificationChannel = toVerificationChannel(channel.type); if (!verificationChannel) { return { success: false, error: `Verification is not supported for channel type "${channel.type}"`, }; } const destination = channel.address; if (!destination) { return { success: false, error: "Channel has no address to send verification to", }; } const effectiveDestination = verificationChannel === "telegram" ? normalizeTelegramDestination(destination) : verificationChannel === "phone" ? (normalizePhoneNumber(destination) ?? destination) : destination; const recentSendCount = await countRecentSendsToDestination( verificationChannel, effectiveDestination, DESTINATION_RATE_WINDOW_MS, ); if (recentSendCount >= MAX_SENDS_PER_DESTINATION_WINDOW) { return { success: false, error: "rate_limited", message: "Too many verification attempts to this destination. Please try again later.", }; } // --- Telegram verification --- if (verificationChannel === "telegram") { if (channel.externalChatId) { const sessionResult = await createOutboundSession({ channel: verificationChannel, expectedChatId: channel.externalChatId, expectedExternalUserId: channel.address !== channel.externalChatId ? channel.address : undefined, identityBindingStatus: "bound", destinationAddress: effectiveDestination, verificationPurpose: "trusted_contact", }); const telegramBody = composeVerificationText( GUARDIAN_VERIFY_TEMPLATE_KEYS.TELEGRAM_CHALLENGE_REQUEST, { code: sessionResult.secret, expiresInMinutes: Math.floor(SESSION_TTL_SECONDS / 60), }, ); const now = Date.now(); const sendCount = 1; await updateSessionDelivery( sessionResult.sessionId, now, sendCount, null, ); deliverVerificationTelegram( channel.externalChatId, telegramBody, assistantId, ); return { success: true, verificationSessionId: sessionResult.sessionId, expiresAt: sessionResult.expiresAt, sendCount, channel: verificationChannel, }; } // Telegram handle only (no chat ID): bootstrap flow const { ensureTelegramBotUsernameResolved } = await import("../../runtime/channel-invite-transports/telegram.js"); await ensureTelegramBotUsernameResolved(); const botUsername = getTelegramBotUsername(); if (!botUsername) { return { success: false, error: "Telegram bot username is not configured. Set up the Telegram integration first.", }; } const bootstrapToken = randomBytes(16).toString("hex"); const bootstrapTokenHash = hashVerificationSecret(bootstrapToken); const sessionResult = await createOutboundSession({ channel: verificationChannel, identityBindingStatus: "pending_bootstrap", destinationAddress: effectiveDestination, bootstrapTokenHash, verificationPurpose: "trusted_contact", }); const telegramBootstrapUrl = `https://t.me/${botUsername}?start=gv_${bootstrapToken}`; return { success: true, verificationSessionId: sessionResult.sessionId, expiresAt: sessionResult.expiresAt, sendCount: 0, telegramBootstrapUrl, pendingBootstrap: true, channel: verificationChannel, }; } // --- Slack verification --- if (verificationChannel === "slack") { const slackUserId = channel.address; const sessionResult = await createOutboundSession({ channel: verificationChannel, expectedExternalUserId: channel.address !== channel.externalChatId ? channel.address : undefined, expectedChatId: channel.externalChatId ?? undefined, identityBindingStatus: "bound", destinationAddress: slackUserId, verificationPurpose: "trusted_contact", }); const slackBody = composeVerificationText( GUARDIAN_VERIFY_TEMPLATE_KEYS.SLACK_CHALLENGE_REQUEST, { code: sessionResult.secret, expiresInMinutes: Math.floor(SESSION_TTL_SECONDS / 60), }, ); const now = Date.now(); const sendCount = 1; await updateSessionDelivery(sessionResult.sessionId, now, sendCount, null); deliverVerificationSlack(slackUserId, slackBody, assistantId); return { success: true, verificationSessionId: sessionResult.sessionId, expiresAt: sessionResult.expiresAt, sendCount, channel: verificationChannel, }; } // --- Discord verification --- if (verificationChannel === "discord") { const discordUserId = channel.address; // Only the user snowflake is an identity. A Discord contact's // `externalChatId` is set only when the assistant has already seen them in // a DM, so it is absent for everyone first met in a guild, and the guild // channel is a room rather than a person either way. const sessionResult = await createOutboundSession({ channel: verificationChannel, expectedExternalUserId: discordUserId, identityBindingStatus: "bound", destinationAddress: discordUserId, verificationPurpose: "trusted_contact", }); const discordBody = composeVerificationText( GUARDIAN_VERIFY_TEMPLATE_KEYS.DISCORD_TRUSTED_CONTACT_CHALLENGE, { code: sessionResult.secret, expiresInMinutes: Math.floor(SESSION_TTL_SECONDS / 60), }, ); const now = Date.now(); const sendCount = 1; await updateSessionDelivery(sessionResult.sessionId, now, sendCount, null); deliverVerificationDiscord(discordUserId, discordBody, assistantId); return { success: true, verificationSessionId: sessionResult.sessionId, expiresAt: sessionResult.expiresAt, sendCount, channel: verificationChannel, }; } // --- Phone verification --- if (verificationChannel === "phone") { const normalizedPhone = normalizePhoneNumber(destination); if (!normalizedPhone) { return { success: false, error: "Could not parse phone number", }; } const sessionResult = await createOutboundSession({ channel: verificationChannel, expectedPhoneE164: normalizedPhone, expectedExternalUserId: normalizedPhone, destinationAddress: normalizedPhone, codeDigits: 6, verificationPurpose: "trusted_contact", }); const now = Date.now(); const sendCount = 1; await updateSessionDelivery(sessionResult.sessionId, now, sendCount, null); // Fire-and-forget: initiate Twilio verification call (async () => { try { const result = await startVerificationCall({ phoneNumber: normalizedPhone, verificationSessionId: sessionResult.sessionId, assistantId, }); if (!result.ok) { log.error( { error: result.error, status: result.status, phoneNumber: normalizedPhone, verificationSessionId: sessionResult.sessionId, }, "Failed to initiate verification call for trusted contact", ); } } catch (err) { log.error( { err, phoneNumber: normalizedPhone, verificationSessionId: sessionResult.sessionId, }, "Failed to initiate verification call for trusted contact", ); } })(); return { success: true, verificationSessionId: sessionResult.sessionId, expiresAt: sessionResult.expiresAt, sendCount, secret: sessionResult.secret, channel: verificationChannel, }; } return { success: false, error: `Verification is not supported for channel type "${channel.type}"`, }; }