diff --git a/package-lock.json b/package-lock.json index d0474e9..a420100 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7054,7 +7054,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, diff --git a/src/compliance/cases.ts b/src/compliance/cases.ts index 8280360..a0d147e 100644 --- a/src/compliance/cases.ts +++ b/src/compliance/cases.ts @@ -1,6 +1,6 @@ -import { PrismaClient, CaseStatus } from '@prisma/client' +import { CaseStatus } from '@prisma/client' +import db from '../db' -const prisma = new PrismaClient() const CASE_OPEN_SCORE = 75 /** @@ -23,12 +23,12 @@ export async function checkAndOpenCase( ) { if (score >= CASE_OPEN_SCORE) { // Open or attach to case - const existingCase = await prisma.complianceCase.findFirst({ + const existingCase = await db.complianceCase.findFirst({ where: { userId, status: { notIn: TERMINAL_CASE_STATUSES } }, }) if (existingCase) { - await prisma.caseEvent.create({ + await db.caseEvent.create({ data: { caseId: existingCase.id, type: 'EVIDENCE', @@ -37,7 +37,7 @@ export async function checkAndOpenCase( }, }) } else { - await prisma.complianceCase.create({ + await db.complianceCase.create({ data: { userId, priority: 'HIGH', diff --git a/src/compliance/travelRule.ts b/src/compliance/travelRule.ts index 4c823b5..63709dd 100644 --- a/src/compliance/travelRule.ts +++ b/src/compliance/travelRule.ts @@ -1,6 +1,5 @@ -import { PrismaClient } from '@prisma/client' +import db from '../db' -const prisma = new PrismaClient() const TRAVEL_RULE_THRESHOLD = 1000 // e.g. USD export async function detectTravelRule( @@ -9,7 +8,7 @@ export async function detectTravelRule( direction: 'INBOUND' | 'OUTBOUND' ) { if (amountInBaseCurrency >= TRAVEL_RULE_THRESHOLD) { - await prisma.travelRuleRecord.create({ + await db.travelRuleRecord.create({ data: { transactionId: outboxOpId, direction, diff --git a/src/config/env.ts b/src/config/env.ts index a09c88e..b602e7d 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -434,11 +434,22 @@ export const config = { }, transcription: { provider: process.env.TRANSCRIPTION_PROVIDER || 'openai', + /** + * Secondary provider used when the primary is unavailable (#400). The + * registry wraps both into a single provider that retries the fallback + * only on a TranscriptionUnavailableError — not on UnsupportedAudioError, + * where a second vendor would fail identically. + */ + fallbackProvider: process.env.TRANSCRIPTION_FALLBACK_PROVIDER || 'deepgram', openaiApiKey: process.env.OPENAI_API_KEY || '', + deepgramApiKey: process.env.DEEPGRAM_API_KEY || '', model: process.env.TRANSCRIPTION_MODEL || 'whisper-1', + deepgramModel: process.env.DEEPGRAM_MODEL || 'nova-2', apiUrl: process.env.TRANSCRIPTION_API_URL || 'https://api.openai.com/v1/audio/transcriptions', + deepgramApiUrl: + process.env.DEEPGRAM_API_URL || 'https://api.deepgram.com/v1/listen', confidenceThreshold: parseFloat( process.env.TRANSCRIPTION_CONFIDENCE_THRESHOLD || '0.6' ), diff --git a/src/fiat/providers/transak.ts b/src/fiat/providers/transak.ts new file mode 100644 index 0000000..11b64b7 --- /dev/null +++ b/src/fiat/providers/transak.ts @@ -0,0 +1,279 @@ +/** + * Transak fiat ramp provider (#399) — the second live buy/sell vendor, added + * so a MoonPay outage can fail over to a working alternative (regulatory + * restrictions permitting). Everything Transak-specific (endpoints, request/ + * response shapes, and the webhook signature scheme) is contained here, behind + * FiatRampProvider. + * + * Webhook verification follows Transak's scheme: the `x-transak-signature` + * header carries an HMAC-SHA256 of the raw request body, hex-encoded, keyed by + * the webhook secret. Transak normally signs webhooks with the account API + * secret, so TRANSAK_WEBHOOK_SECRET overrides it when a dedicated webhook key + * is configured. We compare with a timing-safe equality check and never throw + * from verification. + * https://docs.transak.com/docs/webhooks + */ +import { createHmac, timingSafeEqual } from 'crypto' +import { logger } from '../../utils/logger' +import { HttpClientAdapter } from '../../utils/http-client' +import { config } from '../../config/env' +import { + CreateOrderRequest, + CreateOrderResult, + FiatRampProvider, + NormalizedWebhookStatus, + ParsedWebhook, + QuoteRequest, + QuoteResult, +} from '../types' + +const PROVIDER_NAME = 'transak' + +/** Map Transak's raw order statuses onto our normalized set. */ +function normalizeStatus(raw: string | undefined): NormalizedWebhookStatus { + switch ((raw || '').toUpperCase()) { + case 'SUCCESS_COMPLETED': + return 'SETTLED' + case 'AWAITING_PAYMENT_FROM_USER': + case 'INITIATED': + case 'PENDING': + return 'PENDING' + case 'PAYMENT_DONE': + case 'PROCESSING': + case 'PROCESSING_WITHOUT_KYC': + case 'IN_PROGRESS': + return 'PROCESSING' + case 'ON_HOLD_FOR_KYC': + return 'KYC_REQUIRED' + case 'FAILED': + case 'EXPIRED': + case 'CANCELLED': + case 'QUOTE_EXPIRED': + return 'FAILED' + case 'REFUNDED': + return 'REFUNDED' + default: + return 'PENDING' + } +} + +function timingSafeEqualHex(a: string, b: string): boolean { + // Length mismatch => not equal, and avoid Buffer length throw in timingSafeEqual. + if (a.length !== b.length) return false + try { + return timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex')) + } catch { + return false + } +} + +export class TransakProvider implements FiatRampProvider { + readonly name = PROVIDER_NAME + + private readonly apiKey: string + private readonly apiSecret: string + private readonly webhookSecret: string + private readonly baseUrl: string + private readonly network: string + private readonly http: HttpClientAdapter + + constructor(opts?: { + apiKey?: string + apiSecret?: string + webhookSecret?: string + baseUrl?: string + network?: string + }) { + this.apiKey = opts?.apiKey ?? process.env.TRANSAK_API_KEY ?? '' + this.apiSecret = opts?.apiSecret ?? process.env.TRANSAK_API_SECRET ?? '' + this.webhookSecret = + opts?.webhookSecret ?? process.env.TRANSAK_WEBHOOK_SECRET ?? '' + this.baseUrl = + opts?.baseUrl ?? + process.env.TRANSAK_API_BASE_URL ?? + 'https://api.transak.com' + this.network = opts?.network ?? process.env.TRANSAK_NETWORK ?? 'mainnet' + this.http = new HttpClientAdapter({ + timeoutMs: config.httpClient.timeoutMs, + maxRetries: config.httpClient.maxRetries, + baseDelayMs: config.httpClient.baseDelayMs, + maxDelayMs: config.httpClient.maxDelayMs, + circuitBreakerThreshold: config.httpClient.circuitBreakerThreshold, + circuitBreakerResetMs: config.httpClient.circuitBreakerResetMs, + }) + } + + async getQuote(req: QuoteRequest): Promise { + const isBuy = req.direction === 'ON_RAMP' + + const url = + `${this.baseUrl}/api/v2/pricing/public/quotes` + + `?apiKey=${encodeURIComponent(this.apiKey)}` + + `&fiatCurrency=${encodeURIComponent(req.fiatCurrency)}` + + `&cryptoCurrency=${encodeURIComponent(req.assetSymbol)}` + + `&network=${encodeURIComponent(this.network)}` + + `&isBuyOrSell=${isBuy ? 'BUY' : 'SELL'}` + + `&fiatAmount=${encodeURIComponent(String(req.fiatAmount))}` + + const data = await this.http.execute(async () => { + const res = await fetch(url, { + method: 'GET', + headers: { Accept: 'application/json' }, + }) + if (!res.ok) { + throw new Error(`Transak quote failed: HTTP ${res.status}`) + } + return (await res.json()) as Record + }, 'transak.getQuote') + + // Transak's public pricing comes back as quotes[fiat][crypto] → a single + // priced element whose `price` is the fiat cost of ONE crypto unit. + const quotes = data.quotes as Record | undefined + const perFiat = + quotes?.[req.fiatCurrency] ?? quotes?.[req.fiatCurrency.toLowerCase()] + const perAsset = + perFiat?.[req.assetSymbol] ?? perFiat?.[req.assetSymbol.toLowerCase()] + const first = Array.isArray(perAsset) ? perAsset[0] : perAsset + + const price = Number(first?.price ?? 0) + const fee = Number(first?.fee ?? 0) + const cryptoAmount = price > 0 ? req.fiatAmount / price : 0 + const rate = price > 0 ? 1 / price : undefined + + // Transak reports a single total fee, not an itemized breakdown — so only + // providerFee is populated and the quote is labelled unpriced when the + // price element gave us no fee at all. + const hasFeeData = Number.isFinite(fee) && fee > 0 + const fees = hasFeeData + ? { providerFee: fee, networkFee: null, fxSpread: null } + : null + + return { + provider: this.name, + direction: req.direction, + fiatAmount: req.fiatAmount, + fiatCurrency: req.fiatCurrency, + assetSymbol: req.assetSymbol, + cryptoAmount, + feeAmount: hasFeeData ? fee : undefined, + rate, + rateSource: 'PROVIDER', + fees, + unpriced: fees === null, + } + } + + async createOrder(req: CreateOrderRequest): Promise { + // Transak's primary integration is a hosted widget; server-side we + // register an order intent and hand back the hosted checkout URL. + const isBuy = req.direction === 'ON_RAMP' + + const params = new URLSearchParams() + params.set('apiKey', this.apiKey) + params.set('type', isBuy ? 'BUY' : 'SELL') + params.set('fiatCurrency', req.fiatCurrency) + params.set('cryptoCurrency', req.assetSymbol) + params.set('network', this.network) + params.set('fiatAmount', String(req.fiatAmount)) + params.set('walletAddress', req.walletAddress) + params.set('partnerCustomerId', req.userId) + + const url = `${this.baseUrl}/api/v2/order?${params.toString()}` + + const body = JSON.stringify({ + orderType: isBuy ? 'BUY' : 'SELL', + fiatCurrency: req.fiatCurrency, + fiatAmount: req.fiatAmount, + cryptoCurrency: req.assetSymbol, + network: this.network, + walletAddress: req.walletAddress, + partnerCustomerId: req.userId, + }) + + const data = await this.http.execute(async () => { + const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.apiKey}`, + }, + body, + }) + if (!res.ok) { + throw new Error(`Transak createOrder failed: HTTP ${res.status}`) + } + return (await res.json()) as Record + }, 'transak.createOrder') + + const providerOrderId = String(data.id ?? '') + if (!providerOrderId) { + throw new Error('Transak createOrder returned no order id') + } + + return { + providerOrderId, + checkoutUrl: + (data.checkoutUrl as string) ?? (data.widgetUrl as string) ?? undefined, + kycUrl: (data.kycLink as string) ?? undefined, + status: normalizeStatus(data.status as string | undefined), + cryptoAmount: Number(data.cryptoAmount ?? 0) || undefined, + } + } + + verifyWebhookSignature( + rawBody: string, + headers: Record + ): boolean { + // Transak signs webhooks with the account API secret; a dedicated webhook + // secret overrides it so the webhook credential can be isolated. + const signingKey = this.webhookSecret || this.apiSecret + if (!signingKey) { + // No configured secret means we cannot verify — reject rather than trust. + logger.error( + '[Transak] TRANSAK_API_SECRET/TRANSAK_WEBHOOK_SECRET not configured — rejecting webhook' + ) + return false + } + + const header = + headers['x-transak-signature'] ?? + headers['X-Transak-Signature'] ?? + headers['transak-signature'] + if (!header) return false + + const expected = createHmac('sha256', signingKey) + .update(rawBody) + .digest('hex') + + return timingSafeEqualHex(expected, header.trim()) + } + + parseWebhookPayload(rawBody: string): ParsedWebhook { + const parsed = JSON.parse(rawBody) as Record + const data = (parsed.data ?? parsed) as Record + + const providerOrderId = String( + data.id ?? data.orderId ?? parsed.orderId ?? '' + ) + const status = normalizeStatus(data.status as string | undefined) + + return { + providerOrderId, + status, + txHash: + (data.hash as string) ?? + (data.txHash as string) ?? + (data.cryptoTransactionId as string) ?? + undefined, + cryptoAmount: + Number(data.cryptoAmount ?? data.cryptoCurrencyAmount ?? 0) || + undefined, + kycUrl: (data.kycUrl as string) ?? (data.kycLink as string) ?? undefined, + reason: + (data.statusReason as string) ?? + (data.failureReason as string) ?? + (data.message as string) ?? + undefined, + } + } +} diff --git a/src/fiat/registry.ts b/src/fiat/registry.ts index 54b1313..77fd99b 100644 --- a/src/fiat/registry.ts +++ b/src/fiat/registry.ts @@ -29,6 +29,7 @@ import { } from './types' import { MoonPayProvider } from './providers/moonpay' import { SandboxProvider } from './providers/sandbox' +import { TransakProvider } from './providers/transak' const registry = new Map() @@ -181,6 +182,11 @@ if (sandboxEnabled) { register(new SandboxProvider()) } +// Second live vendor (#399) — registered unconditionally so orders can fail +// over to it whenever the default provider is unhealthy, just like any other +// healthy provider in the registry. +register(new TransakProvider()) + /** The provider key used for newly created orders absent any other signal. */ export function defaultProviderName(): string { return process.env.FIAT_DEFAULT_PROVIDER || 'moonpay' diff --git a/src/telegram/alertManager.ts b/src/telegram/alertManager.ts new file mode 100644 index 0000000..7e00d36 --- /dev/null +++ b/src/telegram/alertManager.ts @@ -0,0 +1,165 @@ +import db from '../db' +import { logger } from '../utils/logger' +import { + createAlertRuleSchema, + type DeliveryChannel, +} from '../validators/alert-validators' + +/** + * Telegram-facing alert-rule management (#402). + * + * The Telegram layer identifies users by wallet address (the in-memory chat + * store holds the custodial wallet), so these helpers resolve the DB user by + * walletAddress and then perform the same owner-scoped CRUD the HTTP routes do + * — the exact pattern src/whatsapp/alertManager.ts uses. A rule is only ever + * visible or mutable by its owner: the walletAddress → userId resolution IS + * the ownership check here. + */ + +export interface AlertRuleView { + id: string + metric: string + protocolName: string | null + comparator: string + threshold: number + deliveryChannel: string + cooldownMinutes: number + isActive: boolean +} + +const viewSelect = { + id: true, + metric: true, + protocolName: true, + comparator: true, + threshold: true, + deliveryChannel: true, + cooldownMinutes: true, + isActive: true, +} + +function toView(rule: { + id: string + metric: string + protocolName: string | null + comparator: string + threshold: unknown + deliveryChannel: string + cooldownMinutes: number + isActive: boolean +}): AlertRuleView { + return { + id: rule.id, + metric: rule.metric, + protocolName: rule.protocolName, + comparator: rule.comparator, + threshold: Number(rule.threshold), + deliveryChannel: rule.deliveryChannel, + cooldownMinutes: rule.cooldownMinutes, + isActive: rule.isActive, + } +} + +async function resolveUserId(walletAddress: string): Promise { + const user = await db.user.findUnique({ + where: { walletAddress }, + select: { id: true }, + }) + return user?.id ?? null +} + +export type CreateAlertResult = + { ok: true; rule: AlertRuleView } | { ok: false; error: string } + +/** + * Create an alert rule for the user owning `walletAddress`. Validates the + * (partially NLP-derived) input through the same Zod schema the HTTP route + * uses, so conversational and API rules share one validation source of truth. + */ +export async function createAlertRuleForWallet( + walletAddress: string, + input: { + metric?: string + protocolName?: string + comparator?: string + threshold?: number + deliveryChannel: DeliveryChannel + } +): Promise { + const userId = await resolveUserId(walletAddress) + if (!userId) { + return { + ok: false, + error: 'I could not find your account. Please try again.', + } + } + + const parsed = createAlertRuleSchema.safeParse({ + metric: input.metric, + protocolName: input.protocolName, + comparator: input.comparator, + threshold: input.threshold, + deliveryChannel: input.deliveryChannel, + }) + + if (!parsed.success) { + // Surface a single friendly hint rather than raw Zod detail over Telegram. + return { + ok: false, + error: + 'I couldn\'t understand that alert. Try e.g. "alert me when Blend apy below 5" or "notify me if portfolio value below 1000".', + } + } + + const rule = await db.alertRule.create({ + data: { + userId, + metric: parsed.data.metric, + protocolName: parsed.data.protocolName ?? null, + comparator: parsed.data.comparator, + threshold: parsed.data.threshold, + deliveryChannel: parsed.data.deliveryChannel, + cooldownMinutes: parsed.data.cooldownMinutes, + }, + select: viewSelect, + }) + + return { ok: true, rule: toView(rule) } +} + +/** List the alert rules owned by the user behind `walletAddress`. */ +export async function listAlertRulesForWallet( + walletAddress: string +): Promise { + const userId = await resolveUserId(walletAddress) + if (!userId) return [] + + const rules = await db.alertRule.findMany({ + where: { userId }, + select: viewSelect, + orderBy: { createdAt: 'desc' }, + }) + return rules.map(toView) +} + +/** + * Delete an alert rule by id, but only if it belongs to `walletAddress`. + * Returns true when a rule was deleted, false when none matched (unknown id or + * not owned by this user) — the caller cannot distinguish the two, by design. + */ +export async function deleteAlertRuleForWallet( + walletAddress: string, + alertId: string +): Promise { + const userId = await resolveUserId(walletAddress) + if (!userId) return false + + const result = await db.alertRule.deleteMany({ + where: { id: alertId, userId }, + }) + + if (result.count > 0) { + logger.info(`[AlertManager] Deleted alert ${alertId} for user ${userId}`) + } + return result.count > 0 +} diff --git a/src/telegram/formatters.ts b/src/telegram/formatters.ts index 97dd5eb..89de1e5 100644 --- a/src/telegram/formatters.ts +++ b/src/telegram/formatters.ts @@ -4,6 +4,9 @@ export function formatHelpMessage(): string { '- /balance → check your wallet balance', '- /deposit → get deposit instructions', '- /withdraw → withdraw funds (if available)', + '- "alert me when Blend apy < 5" → create a price/yield alert', + '- "list my alerts" → see your alert rules', + '- "delete alert " → remove an alert rule', '- /earnings → see your performance', '- /help → show this message again', ].join('\n') diff --git a/src/telegram/handler.ts b/src/telegram/handler.ts index f47f914..db04652 100644 --- a/src/telegram/handler.ts +++ b/src/telegram/handler.ts @@ -19,6 +19,21 @@ import { logger } from '../utils/logger' import { config } from '../config' import db from '../db' import { handleAssistantMessage } from '../agent/assistant/assistant' +import { + getPendingConfirmation, + setPendingConfirmation, + clearPendingConfirmation, +} from './pendingConfirmations' +import { + createAlertRuleForWallet, + listAlertRulesForWallet, + deleteAlertRuleForWallet, +} from './alertManager' +import { + formatAlertCreatedReply, + formatAlertListReply, + formatAlertDeletedReply, +} from '../whatsapp/formatters' export type TelegramResponse = { body: string @@ -28,6 +43,54 @@ function formatUnknownMessage(): string { return `Sorry, I didn't understand that.\n${formatHelpMessage()}` } +/** + * Financial intents that must be confirmed before execution (#402). Telegram + * has no voice channel, so — unlike WhatsApp, which gates only voice-originated + * financial intents — every deposit/withdraw command over Telegram parks a + * confirmation first, so a mistyped "withdraw 5000" can never move funds in + * one step. + */ +const FINANCIAL_ACTIONS: ReadonlySet = new Set([ + 'deposit', + 'withdraw', +]) + +function isFinancialIntent(intent: Intent): boolean { + return FINANCIAL_ACTIONS.has(intent.action) +} + +// The affirmative/negative/summarize semantics below are mirrored VERBATIM +// from src/whatsapp/handler.ts — keep them identical so both channels read +// "yes"/"no" the same way (duplicated deliberately, not imported, to keep the +// Telegram import graph lean of the WhatsApp transport stack). +/** Affirmative reply to a pending confirmation ("yes", "confirm", "yeah"…). */ +function isAffirmative(message: string): boolean { + return /^\s*(yes|yep|yeah|yup|confirm|confirmed|ok|okay|sure|correct|do it|go ahead|proceed|y)\s*[.!]*\s*$/i.test( + message + ) +} + +/** Negative reply to a pending confirmation ("no", "cancel", "stop"…). */ +function isNegative(message: string): boolean { + return /^\s*(no|nope|nah|cancel|stop|abort|don'?t|never mind|nevermind|n)\s*[.!]*\s*$/i.test( + message + ) +} + +/** Human-readable echo of a financial intent for the confirmation prompt. */ +function summarizeIntent(intent: Intent): string { + switch (intent.action) { + case 'deposit': + return `deposit ${intent.amount ?? ''}`.trim() + case 'withdraw': + return intent.all + ? 'withdraw all' + : `withdraw ${intent.amount ?? ''}`.trim() + default: + return intent.action + } +} + /** * Tool-calling assistant fallback (#318), mirroring * src/whatsapp/handler.ts's tryAssistantFallback — same rollout gate @@ -103,6 +166,51 @@ async function executeIntent( } case 'help': return { body: formatHelpMessage() } + case 'alert_create': { + const walletAddress = getUserWalletAddress(chatId) + if (!walletAddress) { + return { body: 'I could not find your account. Please try again.' } + } + const result = await createAlertRuleForWallet(walletAddress, { + metric: intent.metric, + protocolName: intent.protocolName, + comparator: intent.comparator, + threshold: intent.threshold, + // Telegram has no outbound messenger delivery leg yet (the + // DeliveryChannel enum has no TELEGRAM value), so Telegram-originated + // rules deliver over the shared alerting pipeline's always-on + // real-time stream plus the opt-in webhook leg. + deliveryChannel: 'WEBHOOK', + }) + if (!result.ok) { + return { body: result.error } + } + return { body: formatAlertCreatedReply(result.rule) } + } + case 'alert_list': { + const walletAddress = getUserWalletAddress(chatId) + if (!walletAddress) { + return { body: 'I could not find your account. Please try again.' } + } + const rules = await listAlertRulesForWallet(walletAddress) + return { body: formatAlertListReply(rules) } + } + case 'alert_delete': { + const walletAddress = getUserWalletAddress(chatId) + if (!walletAddress) { + return { body: 'I could not find your account. Please try again.' } + } + if (!intent.alertId) { + return { + body: 'Please tell me which alert to delete, e.g. "delete alert ".', + } + } + const deleted = await deleteAlertRuleForWallet( + walletAddress, + intent.alertId + ) + return { body: formatAlertDeletedReply(deleted) } + } case 'clarification': return { body: intent.prompt } default: @@ -133,6 +241,25 @@ export async function handleTelegramMessage( return formatLinkInstructions(code) } + // If a financial command is awaiting confirmation, the next message is + // treated as the yes/no reply (#402) — mirroring WhatsApp's pending- + // confirmation flow. Checked BEFORE parsing so an affirmative reply is + // never re-parsed as a new command. + const pending = getPendingConfirmation(normalizedChatId) + if (pending) { + if (isAffirmative(message)) { + clearPendingConfirmation(normalizedChatId) + return (await executeIntent(pending.intent, normalizedChatId)).body + } + if (isNegative(message)) { + clearPendingConfirmation(normalizedChatId) + return 'Okay, cancelled. Nothing was done.' + } + // Anything else: keep the pending action and re-prompt rather than + // silently dropping it or acting on the new message. + return `You still have a pending action: ${pending.summary}. Reply "yes" to confirm or "no" to cancel.` + } + const intent = await parseIntent(message) if (intent.action === 'unknown') { const assistantReply = await tryAssistantFallback(message, normalizedChatId) @@ -140,6 +267,13 @@ export async function handleTelegramMessage( return formatUnknownMessage() } - const response = await executeIntent(intent, normalizedChatId) - return response.body + // Confirm-before-execute for financial commands (#402). Every Telegram + // deposit/withdraw parks a confirmation first — see FINANCIAL_ACTIONS. + if (isFinancialIntent(intent)) { + const summary = summarizeIntent(intent) + setPendingConfirmation(normalizedChatId, intent, summary) + return `I heard: *${summary}*.\nReply "yes" to confirm or "no" to cancel.` + } + + return (await executeIntent(intent, normalizedChatId)).body } diff --git a/src/telegram/pendingConfirmations.ts b/src/telegram/pendingConfirmations.ts new file mode 100644 index 0000000..3572804 --- /dev/null +++ b/src/telegram/pendingConfirmations.ts @@ -0,0 +1,62 @@ +import type { Intent } from '../nlp/parser' + +/** + * Pending confirmations for Telegram financial commands (#402). + * + * A financial intent (deposit/withdraw) received over Telegram is NOT executed + * immediately — it is parked here and echoed back to the user ("I heard: + * withdraw 50 — confirm?"). The user's next message is checked for an + * affirmative/negative reply before the intent runs. This is the Telegram + * counterpart of src/whatsapp/pendingConfirmations.ts, keyed by chat id. + * + * In-memory, matching the existing Telegram user store. A pending confirmation + * expires after a short TTL so a stale "yes" long after the fact can never + * trigger a fund movement. + */ + +export interface PendingConfirmation { + intent: Intent + /** Human-readable echo shown to the user, kept for logging/debugging. */ + summary: string + expiresAt: number +} + +const store = new Map() + +/** How long a parked confirmation stays valid. */ +export const CONFIRMATION_TTL_MS = 5 * 60 * 1000 // 5 minutes + +export function setPendingConfirmation( + chatId: string, + intent: Intent, + summary: string, + now: number = Date.now() +): void { + store.set(chatId, { intent, summary, expiresAt: now + CONFIRMATION_TTL_MS }) +} + +/** + * Return the live pending confirmation for a chat, or null if none exists or + * it has expired. Expired entries are evicted on read. + */ +export function getPendingConfirmation( + chatId: string, + now: number = Date.now() +): PendingConfirmation | null { + const pending = store.get(chatId) + if (!pending) return null + if (now >= pending.expiresAt) { + store.delete(chatId) + return null + } + return pending +} + +export function clearPendingConfirmation(chatId: string): void { + store.delete(chatId) +} + +/** Test seam. */ +export function clearAllPendingConfirmations(): void { + store.clear() +} diff --git a/src/whatsapp/transcription/deepgramProvider.ts b/src/whatsapp/transcription/deepgramProvider.ts new file mode 100644 index 0000000..e7bd1c4 --- /dev/null +++ b/src/whatsapp/transcription/deepgramProvider.ts @@ -0,0 +1,134 @@ +import { config } from '../../config' +import { logger } from '../../utils/logger' +import { + AudioInput, + TranscriptionProvider, + TranscriptionResult, + TranscriptionUnavailableError, + UnsupportedAudioError, + isSupportedAudioType, +} from './types' + +/** + * Deepgram speech-to-text provider (#400). + * + * The second STT vendor behind {@link TranscriptionProvider}, wired into the + * registry as the automatic fallback for WhatsApp voice notes when the primary + * (OpenAI) provider is down. Talks to the v1 `listen` REST endpoint directly + * via global fetch (Node 18+) — no vendor SDK. + * + * Deepgram natively returns a per-alternative confidence in [0,1], which maps + * straight onto the handler's low-confidence gate. + * + * Privacy: the audio buffer is held only for the duration of the request and + * is never written to disk. See docs/WHATSAPP_VOICE.md. + */ + +interface DeepgramAlternative { + transcript: string + confidence: number +} + +interface DeepgramChannel { + alternatives: DeepgramAlternative[] +} + +interface DeepgramResponse { + results?: { + channels?: DeepgramChannel[] + } +} + +export class DeepgramTranscriptionProvider implements TranscriptionProvider { + readonly name = 'deepgram' + + async transcribe(audio: AudioInput): Promise { + if (!isSupportedAudioType(audio.contentType)) { + throw new UnsupportedAudioError( + `Unsupported audio content type: ${audio.contentType}` + ) + } + + const apiKey = config.transcription.deepgramApiKey + if (!apiKey) { + // Missing credentials is an availability problem from the user's POV. + throw new TranscriptionUnavailableError( + 'Transcription provider is not configured (missing DEEPGRAM_API_KEY)' + ) + } + + const url = new URL(config.transcription.deepgramApiUrl) + url.searchParams.set('model', config.transcription.deepgramModel) + url.searchParams.set('punctuate', 'true') + + let response: Response + try { + const controller = new AbortController() + const timer = setTimeout( + () => controller.abort(), + config.httpClient.timeoutMs + ) + try { + response = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Token ${apiKey}`, + 'Content-Type': + audio.contentType.split(';')[0]?.trim() || 'audio/ogg', + }, + body: new Uint8Array(audio.buffer), + signal: controller.signal, + }) + } finally { + clearTimeout(timer) + } + } catch (err) { + // Network error, DNS failure, timeout/abort — provider is unavailable. + throw new TranscriptionUnavailableError( + `Transcription request failed: ${err instanceof Error ? err.message : String(err)}` + ) + } + + if (!response.ok) { + const detail = await response.text().catch(() => '') + // 4xx on the audio itself (e.g. unprocessable format) → unsupported; + // everything else (5xx, 429, auth) → unavailable/outage. + if ( + response.status === 400 || + response.status === 415 || + response.status === 422 + ) { + throw new UnsupportedAudioError( + `Provider rejected the audio (HTTP ${response.status})` + ) + } + logger.warn( + `[Transcription] Deepgram returned HTTP ${response.status}: ${detail.slice(0, 200)}` + ) + throw new TranscriptionUnavailableError( + `Transcription provider error (HTTP ${response.status})` + ) + } + + let payload: DeepgramResponse + try { + payload = (await response.json()) as DeepgramResponse + } catch (err) { + throw new TranscriptionUnavailableError( + `Could not parse transcription response: ${err instanceof Error ? err.message : String(err)}` + ) + } + + const alternative = payload.results?.channels?.[0]?.alternatives?.[0] + const text = (alternative?.transcript ?? '').trim() + + const confidence = + typeof alternative?.confidence === 'number' + ? Math.max(0, Math.min(1, alternative.confidence)) + : // No confidence in the payload (shouldn't happen); treat as mid + // confidence so a clear short command isn't force-rejected. + 0.7 + + return { text, confidence } + } +} diff --git a/src/whatsapp/transcription/registry.ts b/src/whatsapp/transcription/registry.ts index 7407ba1..3949409 100644 --- a/src/whatsapp/transcription/registry.ts +++ b/src/whatsapp/transcription/registry.ts @@ -6,10 +6,22 @@ * provider exclusively through {@link getDefaultTranscriptionProvider}, so * swapping STT vendors is a one-line registry change (plus config) with no edits * to the handler — the same pattern as the fiat provider registry. + * + * Multi-provider fallback (#400): the registry ships two vendors and + * {@link getDefaultTranscriptionProvider} returns a composite that runs the + * configured primary first and transparently retries the configured fallback + * when the primary is unavailable. */ import { config } from '../../config' -import { TranscriptionProvider } from './types' +import { logger } from '../../utils/logger' +import { + AudioInput, + TranscriptionProvider, + TranscriptionResult, + TranscriptionUnavailableError, +} from './types' import { OpenAiTranscriptionProvider } from './openaiProvider' +import { DeepgramTranscriptionProvider } from './deepgramProvider' const registry = new Map() @@ -17,8 +29,8 @@ function register(provider: TranscriptionProvider): void { registry.set(provider.name, provider) } -// v1 ships a single provider. Add further vendors here — nothing else changes. register(new OpenAiTranscriptionProvider()) +register(new DeepgramTranscriptionProvider()) /** Resolve a provider by key. Throws if the key is unknown/unconfigured. */ export function getTranscriptionProvider(name: string): TranscriptionProvider { @@ -29,9 +41,49 @@ export function getTranscriptionProvider(name: string): TranscriptionProvider { return provider } -/** The active provider for incoming voice notes (from config). */ +/** + * Composite provider (#400) that delegates to `primary` and, on a + * {@link TranscriptionUnavailableError} (outage/transport/auth failure), + * retries through `fallback`. An {@link UnsupportedAudioError} is NOT retried: + * the audio itself is the problem, so a second vendor would fail identically — + * and it is the handler's job to tell the user their format wasn't understood, + * not that transcription is down. + */ +class FallbackTranscriptionProvider implements TranscriptionProvider { + readonly name: string + + constructor( + private readonly primary: TranscriptionProvider, + private readonly fallback: TranscriptionProvider + ) { + this.name = `${primary.name}->${fallback.name}` + } + + async transcribe(audio: AudioInput): Promise { + try { + return await this.primary.transcribe(audio) + } catch (err) { + if (!(err instanceof TranscriptionUnavailableError)) { + throw err + } + logger.warn( + `[Transcription] Primary provider "${this.primary.name}" unavailable; falling back to "${this.fallback.name}": ${err.message}` + ) + return await this.fallback.transcribe(audio) + } + } +} + +/** The active provider (or fallback pair) for incoming voice notes. */ export function getDefaultTranscriptionProvider(): TranscriptionProvider { - return getTranscriptionProvider(config.transcription.provider) + const primary = getTranscriptionProvider(config.transcription.provider) + const fallback = getTranscriptionProvider( + config.transcription.fallbackProvider + ) + if (primary === fallback) { + return primary + } + return new FallbackTranscriptionProvider(primary, fallback) } /** diff --git a/tests/unit/analytics/no-duplicate-definitions.test.ts b/tests/unit/analytics/no-duplicate-definitions.test.ts index d3b32d2..c51b6d2 100644 --- a/tests/unit/analytics/no-duplicate-definitions.test.ts +++ b/tests/unit/analytics/no-duplicate-definitions.test.ts @@ -63,7 +63,9 @@ describe('Anti-Duplication Guard: Risk Analytics Engine', () => { // Skip strategyMetrics.ts — it re-exports inferPeriodsPerYear as a // delegating adapter (arrow const) that calls the canonical metrics.ts // implementation. It is NOT a duplicate implementation. - if (filePath.endsWith('agent/strategyMetrics.ts')) continue + // Normalize separators so this matches on both POSIX and Windows. + if (filePath.split('\\').join('/').endsWith('agent/strategyMetrics.ts')) + continue const content = fs.readFileSync(filePath, 'utf8') const lines = content.split('\n') diff --git a/tests/unit/fiat/registry.test.ts b/tests/unit/fiat/registry.test.ts index 7deaf2e..928606f 100644 --- a/tests/unit/fiat/registry.test.ts +++ b/tests/unit/fiat/registry.test.ts @@ -17,13 +17,15 @@ function loadRegistry(env: Record) { } describe('fiat provider registry — health tracking + circuit breaker', () => { - it('registers moonpay and sandbox by default outside production', () => { + it('registers moonpay, sandbox, and transak by default outside production', () => { const registry = loadRegistry({ NODE_ENV: 'test', FIAT_ENABLE_SANDBOX_PROVIDER: 'true', }) const names = registry.getAllProviders().map((p: any) => p.name) - expect(names).toEqual(expect.arrayContaining(['moonpay', 'sandbox'])) + expect(names).toEqual( + expect.arrayContaining(['moonpay', 'sandbox', 'transak']) + ) }) it('opens the circuit after the configured consecutive-failure threshold', () => { @@ -79,7 +81,7 @@ describe('fiat provider registry — health tracking + circuit breaker', () => { }) registry.recordProviderFailure('moonpay') const healthy = registry.getHealthyProviders().map((p: any) => p.name) - expect(healthy).toEqual(['sandbox']) + expect(healthy).toEqual(expect.arrayContaining(['sandbox', 'transak'])) }) }) @@ -139,6 +141,7 @@ describe('fiat provider registry — selection policy', () => { }) registry.recordProviderFailure('moonpay') registry.recordProviderFailure('sandbox') + registry.recordProviderFailure('transak') expect(() => registry.selectProviderForOrder({ policy: 'DEFAULT' }) @@ -151,6 +154,7 @@ describe('fiat provider registry — selection policy', () => { expect.arrayContaining([ expect.objectContaining({ provider: 'moonpay' }), expect.objectContaining({ provider: 'sandbox' }), + expect.objectContaining({ provider: 'transak' }), ]) ) } @@ -184,6 +188,7 @@ describe('fiat provider registry — admin manual failover', () => { expect(snapshots.map((s: any) => s.provider).sort()).toEqual([ 'moonpay', 'sandbox', + 'transak', ]) expect(snapshots.every((s: any) => s.healthy)).toBe(true) }) diff --git a/tests/unit/fiat/transak.test.ts b/tests/unit/fiat/transak.test.ts new file mode 100644 index 0000000..5f4145e --- /dev/null +++ b/tests/unit/fiat/transak.test.ts @@ -0,0 +1,180 @@ +// #399 — Transak provider unit tests: webhook signature verification, status +// normalization, and payload parsing. No network calls are exercised here. +import { createHmac } from 'crypto' +import { TransakProvider } from '../../../src/fiat/providers/transak' + +jest.mock('../../../src/utils/logger', () => ({ + logger: { + warn: jest.fn(), + error: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }, +})) + +const WEBHOOK_SECRET = 'whsec_test_key' + +function sign(rawBody: string, key = WEBHOOK_SECRET): string { + return createHmac('sha256', key).update(rawBody).digest('hex') +} + +describe('TransakProvider.verifyWebhookSignature', () => { + it('accepts a correctly signed payload', () => { + const body = JSON.stringify({ + id: 'abc_1', + status: 'SUCCESS_COMPLETED', + }) + const header = sign(body) + expect( + provider().verifyWebhookSignature(body, { 'x-transak-signature': header }) + ).toBe(true) + }) + + it('accepts the account API secret as the signing key when no webhook secret is set', () => { + const providerWithApiSecret = new TransakProvider({ + apiSecret: 'account_secret', + webhookSecret: '', + }) + const body = JSON.stringify({ id: 'abc_1', status: 'PENDING' }) + const header = sign(body, 'account_secret') + expect( + providerWithApiSecret.verifyWebhookSignature(body, { + 'x-transak-signature': header, + }) + ).toBe(true) + }) + + it('rejects a tampered body', () => { + const body = JSON.stringify({ id: 'abc_1', status: 'PENDING' }) + const header = sign(body) + const tampered = JSON.stringify({ id: 'abc_1', status: 'FAILED' }) + expect( + provider().verifyWebhookSignature(tampered, { + 'x-transak-signature': header, + }) + ).toBe(false) + }) + + it('rejects a signature made with the wrong key', () => { + const body = JSON.stringify({ id: 'abc_1' }) + const header = sign(body, 'wrong_key') + expect( + provider().verifyWebhookSignature(body, { 'x-transak-signature': header }) + ).toBe(false) + }) + + it('rejects when the signature header is missing or malformed', () => { + const body = '{}' + expect(provider().verifyWebhookSignature(body, {})).toBe(false) + expect( + provider().verifyWebhookSignature(body, { + 'x-transak-signature': 'garbage$$$', + }) + ).toBe(false) + }) + + it('rejects everything when no signing key is configured', () => { + const noKey = new TransakProvider({ apiSecret: '', webhookSecret: '' }) + const body = '{}' + const header = sign(body, '') + expect( + noKey.verifyWebhookSignature(body, { 'x-transak-signature': header }) + ).toBe(false) + }) +}) + +describe('TransakProvider.parseWebhookPayload', () => { + it('normalizes a completed order to SETTLED and extracts the tx hash', () => { + const body = JSON.stringify({ + id: 'tsk_42', + status: 'SUCCESS_COMPLETED', + hash: '0xabc', + cryptoAmount: 98.5, + }) + const parsed = provider().parseWebhookPayload(body) + expect(parsed).toMatchObject({ + providerOrderId: 'tsk_42', + status: 'SETTLED', + txHash: '0xabc', + cryptoAmount: 98.5, + }) + }) + + it('maps AWAITING_PAYMENT_FROM_USER to PENDING', () => { + const body = JSON.stringify({ + id: 'tsk_1', + status: 'AWAITING_PAYMENT_FROM_USER', + }) + expect(provider().parseWebhookPayload(body).status).toBe('PENDING') + }) + + it('maps PAYMENT_DONE and IN_PROGRESS to PROCESSING', () => { + expect( + provider().parseWebhookPayload( + JSON.stringify({ id: 'a', status: 'PAYMENT_DONE' }) + ).status + ).toBe('PROCESSING') + expect( + provider().parseWebhookPayload( + JSON.stringify({ id: 'b', status: 'IN_PROGRESS' }) + ).status + ).toBe('PROCESSING') + }) + + it('maps FAILED to FAILED and carries the reason', () => { + const body = JSON.stringify({ + id: 'tsk_1', + status: 'FAILED', + statusReason: 'card_declined', + }) + const parsed = provider().parseWebhookPayload(body) + expect(parsed.status).toBe('FAILED') + expect(parsed.reason).toBe('card_declined') + }) + + it('maps EXPIRED and CANCELLED to FAILED', () => { + expect( + provider().parseWebhookPayload( + JSON.stringify({ id: 'a', status: 'EXPIRED' }) + ).status + ).toBe('FAILED') + expect( + provider().parseWebhookPayload( + JSON.stringify({ id: 'b', status: 'CANCELLED' }) + ).status + ).toBe('FAILED') + }) + + it('maps REFUNDED to REFUNDED', () => { + expect( + provider().parseWebhookPayload( + JSON.stringify({ id: 'a', status: 'REFUNDED' }) + ).status + ).toBe('REFUNDED') + }) + + it('maps ON_HOLD_FOR_KYC to KYC_REQUIRED', () => { + const body = JSON.stringify({ + id: 'tsk_1', + status: 'ON_HOLD_FOR_KYC', + kycUrl: 'https://kyc.transak.com', + }) + const parsed = provider().parseWebhookPayload(body) + expect(parsed.status).toBe('KYC_REQUIRED') + expect(parsed.kycUrl).toBe('https://kyc.transak.com') + }) + + it('reads the order id from a wrapped data envelope', () => { + const body = JSON.stringify({ + data: { id: 'tsk_7', status: 'PENDING' }, + }) + expect(provider().parseWebhookPayload(body).providerOrderId).toBe('tsk_7') + }) +}) + +function provider(): TransakProvider { + return new TransakProvider({ + webhookSecret: WEBHOOK_SECRET, + apiSecret: 'account_secret', + }) +} diff --git a/tests/unit/telegram/telegramConfirmations.test.ts b/tests/unit/telegram/telegramConfirmations.test.ts new file mode 100644 index 0000000..d350df6 --- /dev/null +++ b/tests/unit/telegram/telegramConfirmations.test.ts @@ -0,0 +1,274 @@ +// Telegram financial-command confirmation + alert-rule management (#402). +// Telegram has no voice channel, so — unlike WhatsApp, where only +// voice-originated financial intents are gated — EVERY deposit/withdraw over +// Telegram parks a confirmation first. The security-critical assertion is that +// a financial intent never executes on the first pass. +process.env.NODE_ENV = 'test' +process.env.STELLAR_NETWORK = 'testnet' +process.env.STELLAR_RPC_URL = 'https://soroban-testnet.stellar.org' +process.env.STELLAR_AGENT_SECRET_KEY = 'S' + 'A'.repeat(55) +process.env.VAULT_CONTRACT_ID = 'C' + 'A'.repeat(55) +process.env.USDC_TOKEN_ADDRESS = 'C' + 'B'.repeat(55) +process.env.ANTHROPIC_API_KEY = 'sk-ant-test-key' +process.env.DATABASE_URL = 'postgresql://localhost:5432/test' +process.env.JWT_SEED = '0'.repeat(64) +process.env.WALLET_ENCRYPTION_KEY = '0'.repeat(64) +process.env.TELEGRAM_BOT_TOKEN = '123:test-token' +process.env.TELEGRAM_WEBHOOK_SECRET = 'test-secret' +// Force the regex fast-path only, so no live Claude call is attempted. +process.env.AI_MODE = 'local' + +import { handleTelegramMessage } from '../../../src/telegram/handler' +import { + clearTelegramUsersForTests, + getTelegramUser, +} from '../../../src/telegram/userManager' +import { + clearAllPendingConfirmations, + setPendingConfirmation, + getPendingConfirmation, + clearPendingConfirmation, +} from '../../../src/telegram/pendingConfirmations' +import { + createAlertRuleForWallet, + listAlertRulesForWallet, + deleteAlertRuleForWallet, +} from '../../../src/telegram/alertManager' + +jest.mock('../../../src/utils/logger', () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})) + +// Stub the custodial-wallet creation the chat store triggers, so tests don't +// touch Stellar/crypto. +jest.mock('../../../src/stellar/wallet', () => ({ + createCustodialWallet: jest + .fn() + .mockResolvedValue({ publicKey: 'G' + 'A'.repeat(55) }), + getWalletByUserId: jest + .fn() + .mockResolvedValue({ publicKey: 'G' + 'A'.repeat(55) }), +})) + +// The alert manager is mocked so the handler's alert wiring can be tested in +// isolation from Prisma (the real manager is covered by the same DB-owner-scope +// pattern as src/whatsapp/alertManager.ts). +jest.mock('../../../src/telegram/alertManager', () => ({ + createAlertRuleForWallet: jest.fn(), + listAlertRulesForWallet: jest.fn(), + deleteAlertRuleForWallet: jest.fn(), +})) + +const mockCreateAlert = createAlertRuleForWallet as jest.Mock +const mockListAlerts = listAlertRulesForWallet as jest.Mock +const mockDeleteAlert = deleteAlertRuleForWallet as jest.Mock + +const CHAT = 7 + +/** Link a chat (via the one-time code flow) and give it a starting balance. */ +async function linkedAndFundedChat( + chatId: number | string, + balance = 1000 +): Promise { + const chat = String(chatId) + const linkReply = await handleTelegramMessage(chat, 'hello') + const codeMatch = linkReply.match(/code:\s*([A-Z0-9-]+)/i) + expect(codeMatch).not.toBeNull() + const reply = await handleTelegramMessage(chat, `link ${codeMatch?.[1]}`) + expect(reply).toContain('linked') + + const user = getTelegramUser(chat)! + // Directly set the balance via the test view. + ;(user as { balance: number }).balance = balance + return chat +} + +beforeEach(() => { + jest.clearAllMocks() + clearTelegramUsersForTests() + clearAllPendingConfirmations() +}) + +describe('Telegram financial confirmation (#402)', () => { + it('does NOT execute a Telegram withdrawal on the first pass — asks to confirm', async () => { + await linkedAndFundedChat(CHAT, 500) + + const res = await handleTelegramMessage(CHAT, 'withdraw 50') + + // Confirmation prompt, NOT a withdrawal confirmation. + expect(res).toMatch(/confirm/i) + expect(res).toMatch(/withdraw 50/i) + // Balance untouched — nothing executed. + expect(getTelegramUser(String(CHAT))!.balance).toBe(500) + }) + + it('executes the withdrawal only after an affirmative reply', async () => { + await linkedAndFundedChat(CHAT, 500) + + await handleTelegramMessage(CHAT, 'withdraw 50') // parks confirmation + const res = await handleTelegramMessage(CHAT, 'yes') + + expect(res).toMatch(/withdrawal request received/i) + expect(getTelegramUser(String(CHAT))!.balance).toBe(450) + }) + + it('cancels the pending action on a negative reply without executing', async () => { + await linkedAndFundedChat(CHAT, 500) + + await handleTelegramMessage(CHAT, 'withdraw 50') + const res = await handleTelegramMessage(CHAT, 'no') + + expect(res).toMatch(/cancel/i) + expect(getTelegramUser(String(CHAT))!.balance).toBe(500) + }) + + it('keeps the pending action when the reply is unrelated, instead of bypassing it', async () => { + await linkedAndFundedChat(CHAT, 500) + + await handleTelegramMessage(CHAT, 'withdraw 50') + const res = await handleTelegramMessage(CHAT, 'balance') + + // Re-prompt, not a balance reply — the pending action is still live. + expect(res).toMatch(/pending action/i) + expect(getTelegramUser(String(CHAT))!.balance).toBe(500) + + const confirmed = await handleTelegramMessage(CHAT, 'yes') + expect(confirmed).toMatch(/withdrawal request received/i) + expect(getTelegramUser(String(CHAT))!.balance).toBe(450) + }) + + it('does not require confirmation for a read-only balance command', async () => { + await linkedAndFundedChat(CHAT, 500) + + const res = await handleTelegramMessage(CHAT, 'balance') + + expect(res).toMatch(/Your current balance/i) + expect(getTelegramUser(String(CHAT))!.balance).toBe(500) + }) + + it('confirms "withdraw all" before emptying the balance', async () => { + await linkedAndFundedChat(CHAT, 500) + + const parked = await handleTelegramMessage(CHAT, 'withdraw all') + expect(parked).toMatch(/withdraw all/i) + expect(parked).toMatch(/confirm/i) + expect(getTelegramUser(String(CHAT))!.balance).toBe(500) + + const res = await handleTelegramMessage(CHAT, 'yes') + expect(res).toMatch(/withdrawal request received/i) + expect(getTelegramUser(String(CHAT))!.balance).toBe(0) + }) + + it('confirms a deposit command before showing deposit instructions', async () => { + await linkedAndFundedChat(CHAT) + + const parked = await handleTelegramMessage(CHAT, 'deposit 10') + expect(parked).toMatch(/confirm/i) + expect(parked).toMatch(/deposit 10/i) + + const res = await handleTelegramMessage(CHAT, 'yes') + expect(res).toMatch(/To deposit/i) + }) + + it('still answers read-only intents like help directly, without confirmation', async () => { + await linkedAndFundedChat(CHAT) + + const res = await handleTelegramMessage(CHAT, 'help') + + expect(res).toMatch(/Welcome to NeuroWealth/i) + }) +}) + +describe('Telegram pending-confirmation store (#402)', () => { + const now = 1_700_000_000_000 + const withdrawIntent = { + action: 'withdraw' as const, + confidence: 1, + amount: 5, + } + + it('returns the pending confirmation within the TTL', () => { + setPendingConfirmation('1', withdrawIntent, 'withdraw 5', now) + expect(getPendingConfirmation('1', now + 60_000)?.summary).toBe( + 'withdraw 5' + ) + }) + + it('evicts a confirmation once the TTL has elapsed', () => { + setPendingConfirmation('1', withdrawIntent, 'withdraw 5', now) + expect(getPendingConfirmation('1', now + 5 * 60_000 + 1)).toBeNull() + }) + + it('clears a stored confirmation', () => { + setPendingConfirmation('1', withdrawIntent, 'withdraw 5', now) + clearPendingConfirmation('1') + expect(getPendingConfirmation('1', now)).toBeNull() + }) +}) + +describe('Telegram alert rules (#402)', () => { + it('creates an alert rule from a conversational command', async () => { + await linkedAndFundedChat(CHAT) + mockCreateAlert.mockResolvedValue({ + ok: true, + rule: { + id: 'alert-1', + metric: 'PROTOCOL_APY', + protocolName: 'Blend', + comparator: 'LT', + threshold: 5, + deliveryChannel: 'WEBHOOK', + cooldownMinutes: 60, + isActive: true, + }, + }) + + const res = await handleTelegramMessage(CHAT, 'alert me when Blend apy < 5') + + expect(res).toMatch(/Alert created/i) + expect(mockCreateAlert).toHaveBeenCalledWith( + getTelegramUser(String(CHAT))!.walletAddress, + expect.objectContaining({ + metric: 'apy', + protocolName: 'blend', + comparator: '<', + threshold: 5, + deliveryChannel: 'WEBHOOK', + }) + ) + }) + + it('lists a user\u2019s alert rules', async () => { + await linkedAndFundedChat(CHAT) + mockListAlerts.mockResolvedValue([ + { + id: 'a1', + metric: 'PROTOCOL_APY', + protocolName: 'Blend', + comparator: 'LT', + threshold: 5, + deliveryChannel: 'WEBHOOK', + cooldownMinutes: 60, + isActive: true, + }, + ]) + + const res = await handleTelegramMessage(CHAT, 'list my alerts') + + expect(res).toMatch(/Your alert rules/i) + expect(res).toMatch(/Blend/i) + }) + + it('deletes an alert rule by id', async () => { + await linkedAndFundedChat(CHAT) + mockDeleteAlert.mockResolvedValue(true) + + const res = await handleTelegramMessage(CHAT, 'delete alert a1') + + expect(res).toMatch(/Alert deleted/i) + expect(mockDeleteAlert).toHaveBeenCalledWith( + getTelegramUser(String(CHAT))!.walletAddress, + 'a1' + ) + }) +}) diff --git a/tests/unit/whatsapp/deepgramProvider.test.ts b/tests/unit/whatsapp/deepgramProvider.test.ts new file mode 100644 index 0000000..46385bb --- /dev/null +++ b/tests/unit/whatsapp/deepgramProvider.test.ts @@ -0,0 +1,144 @@ +// Deepgram transcription provider (#400). fetch is mocked so these tests are +// deterministic and offline; the key assertions are the request shape (auth +// header, endpoint) and the response parsing into { text, confidence }. +process.env.NODE_ENV = 'test' +process.env.STELLAR_NETWORK = 'testnet' +process.env.STELLAR_RPC_URL = 'https://soroban-testnet.stellar.org' +process.env.STELLAR_AGENT_SECRET_KEY = 'S' + 'A'.repeat(55) +process.env.VAULT_CONTRACT_ID = 'C' + 'A'.repeat(55) +process.env.USDC_TOKEN_ADDRESS = 'C' + 'B'.repeat(55) +process.env.ANTHROPIC_API_KEY = 'sk-ant-test-key' +process.env.DATABASE_URL = 'postgresql://localhost:5432/test' +process.env.JWT_SEED = '0'.repeat(64) +process.env.WALLET_ENCRYPTION_KEY = '0'.repeat(64) +process.env.TWILIO_AUTH_TOKEN = '0'.repeat(32) +process.env.TWILIO_ACCOUNT_SID = 'AC' + '0'.repeat(32) +process.env.DEEPGRAM_API_KEY = 'dg-test-key' +process.env.AI_MODE = 'local' + +import { DeepgramTranscriptionProvider } from '../../../src/whatsapp/transcription/deepgramProvider' +import { + TranscriptionUnavailableError, + UnsupportedAudioError, +} from '../../../src/whatsapp/transcription/types' +import { config } from '../../../src/config' + +jest.mock('../../../src/utils/logger', () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})) + +const provider = new DeepgramTranscriptionProvider() +const AUDIO = { + buffer: Buffer.from('fake-audio-bytes'), + contentType: 'audio/ogg', +} + +afterEach(() => { + jest.restoreAllMocks() + ;(config.transcription as { deepgramApiKey: string }).deepgramApiKey = + 'dg-test-key' +}) + +describe('DeepgramTranscriptionProvider (#400)', () => { + it('throws UnsupportedAudioError for an audio type it cannot process', async () => { + await expect( + provider.transcribe({ + buffer: Buffer.from('x'), + contentType: 'audio/x-weird', + }) + ).rejects.toThrow(UnsupportedAudioError) + }) + + it('throws TranscriptionUnavailableError when no API key is configured', async () => { + ;(config.transcription as { deepgramApiKey: string }).deepgramApiKey = '' + + await expect(provider.transcribe(AUDIO)).rejects.toThrow( + TranscriptionUnavailableError + ) + }) + + it('sends the raw audio to the listen endpoint with a token auth header', async () => { + const fetchSpy = jest.spyOn(global, 'fetch' as any).mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + results: { + channels: [ + { alternatives: [{ transcript: 'balance', confidence: 0.96 }] }, + ], + }, + }), + } as Response) + + const result = await provider.transcribe(AUDIO) + + expect(result.text).toBe('balance') + expect(result.confidence).toBe(0.96) + + const [url, options] = fetchSpy.mock.calls[0] + expect(String(url)).toContain('api.deepgram.com/v1/listen') + expect(String(url)).toContain('model=nova-2') + expect(options).toEqual( + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Token dg-test-key', + 'Content-Type': 'audio/ogg', + }), + }) + ) + }) + + it('parses the native confidence and clamps it to [0,1]', async () => { + jest.spyOn(global, 'fetch' as any).mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + results: { + channels: [ + { alternatives: [{ transcript: 'withdraw 5', confidence: 1.3 }] }, + ], + }, + }), + } as Response) + + const result = await provider.transcribe(AUDIO) + + expect(result.text).toBe('withdraw 5') + expect(result.confidence).toBe(1) + }) + + it('maps an audio-rejected 4xx to UnsupportedAudioError', async () => { + jest.spyOn(global, 'fetch' as any).mockResolvedValue({ + ok: false, + status: 415, + text: async () => 'unsupported media type', + } as Response) + + await expect(provider.transcribe(AUDIO)).rejects.toThrow( + UnsupportedAudioError + ) + }) + + it('maps an upstream 5xx to TranscriptionUnavailableError', async () => { + jest.spyOn(global, 'fetch' as any).mockResolvedValue({ + ok: false, + status: 503, + text: async () => 'service unavailable', + } as Response) + + await expect(provider.transcribe(AUDIO)).rejects.toThrow( + TranscriptionUnavailableError + ) + }) + + it('maps a transport failure to TranscriptionUnavailableError', async () => { + jest + .spyOn(global, 'fetch' as any) + .mockRejectedValue(new Error('ECONNRESET')) + + await expect(provider.transcribe(AUDIO)).rejects.toThrow( + TranscriptionUnavailableError + ) + }) +}) diff --git a/tests/unit/whatsapp/voiceHandler.test.ts b/tests/unit/whatsapp/voiceHandler.test.ts index 1eb9049..0ee1a68 100644 --- a/tests/unit/whatsapp/voiceHandler.test.ts +++ b/tests/unit/whatsapp/voiceHandler.test.ts @@ -186,6 +186,44 @@ describe('WhatsApp voice notes (#288)', () => { expect(res.body).toMatch(/aren't available right now|type your command/i) }) + it('falls through to the secondary provider when the primary is down (#400)', async () => { + await verifiedUser(PHONE) + useProvider(async () => { + throw new TranscriptionUnavailableError('openai 503') + }) + registerTranscriptionProvider({ + name: 'deepgram', // the configured fallback key + transcribe: async () => ({ text: 'balance', confidence: 0.94 }), + }) + + const res = await handleWhatsAppMessage(PHONE, '', MEDIA) + + // The primary is down, but transcription still succeeds via the fallback. + expect(res.body).toMatch(/balance/i) + }) + + it('does not attempt the fallback for an unsupported audio format (#400)', async () => { + await verifiedUser(PHONE) + const fallbackTranscribe = jest.fn(async () => ({ + text: 'balance', + confidence: 0.94, + })) + useProvider(async () => { + throw new UnsupportedAudioError('weird codec') + }) + registerTranscriptionProvider({ + name: 'deepgram', + transcribe: fallbackTranscribe, + }) + + const res = await handleWhatsAppMessage(PHONE, '', MEDIA) + + expect(res.body).toMatch(/format|type your command/i) + // An UnsupportedAudioError is never masked as an outage — the fallback + // must not be consulted for an audio the primary itself rejected. + expect(fallbackTranscribe).not.toHaveBeenCalled() + }) + it('falls through to unknown for transcribed nonsense (same as typed nonsense)', async () => { await verifiedUser(PHONE) useProvider(async () => ({ text: 'asdfghjkl qwerty', confidence: 0.95 }))