diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md new file mode 100644 index 0000000..746d8fc --- /dev/null +++ b/docs/OPERATIONS.md @@ -0,0 +1,419 @@ +# Pulse Operations Guide + +How to install and operate Pulse on a DigitalOcean droplet. + +**Current production**: `pulse.flashapp.me` — Ubuntu 24.04, Node 20, PM2, Nginx, Redis, RabbitMQ. + +--- + +## What Pulse Is + +Pulse is a platform-agnostic conversational bot for [Flash](https://flashapp.me) that enables Lightning Network payments through natural language. Users interact with Pulse over **WhatsApp** or **Telegram** — the core logic is the same regardless of platform. + +Under the hood it's a NestJS application using hexagonal architecture. Messaging platforms are adapters that plug into a canonical message model. All business logic (payments, account linking, AI responses, voice, plugins) is platform-independent. See [ARCHITECTURE.md](ARCHITECTURE.md) for the full design. + +**Key capabilities:** + +- Lightning payments (send, receive, balance, invoices) via Flash API +- Account linking with OTP verification +- Conversational AI responses (Google Gemini) +- Voice messages (Speech-to-Text via Google Cloud / Whisper, TTS via ElevenLabs / Google Cloud) +- Plugins: trivia games, daily challenges, group polls, anonymous messaging, language translation, entertainment +- Admin dashboard and WhatsApp admin commands +- Nostr integration for content sharing + +--- + +## Fresh Install + +### 1. Create Droplet + +- **Image**: Ubuntu 24.04 LTS +- **Plan**: Regular, 2 vCPU / 4 GB RAM / 80 GB SSD ($24/mo) — minimum for production +- **Region**: Whatever's closest to your users +- **Auth**: SSH keys (no password auth) + +Point your domain's DNS A record to the droplet IP before proceeding. + +### 2. Run Setup Script + +SSH in as root: + +```bash +wget https://raw.githubusercontent.com/lnflash/pulse/main/scripts/setup-ubuntu-vps.sh +chmod +x setup-ubuntu-vps.sh +./setup-ubuntu-vps.sh +``` + +The script is interactive. It will ask for: + +- **Domain name** (e.g. `pulse.flashapp.me`) +- **SSL email** — for Let's Encrypt certificate +- **Flash API key** — required for payments, can add later +- **Admin phone numbers** — comma-separated, no `+` prefix (e.g. `13059244435,18764250250`) +- **Optional keys** — Gemini, Nostr, Google Cloud TTS + +The script installs: Node.js 20, PM2, Google Chrome (for WhatsApp Web.js), Redis, RabbitMQ, Nginx with SSL, fail2ban, and automated backups. + +### 3. Connect Messaging Platforms + +Pulse supports multiple platforms simultaneously. Enable whichever you need. + +#### WhatsApp + +WhatsApp Web.js runs a headless Chrome instance that connects via QR code pairing. + +```bash +pulse logs +``` + +Open WhatsApp on the phone you want to connect > Settings > Linked Devices > Link a Device > scan the QR code from the logs. + +Use a dedicated phone number — **not** a personal one. WhatsApp may flag automation on personal accounts. + +#### Telegram + +1. Create a bot with [@BotFather](https://t.me/BotFather) on Telegram (`/newbot`) +2. Copy the bot token +3. Add to `/opt/pulse/.env`: + ``` + TELEGRAM_BOT_TOKEN=your_bot_token_here + ``` +4. Restart: `pm2 restart pulse-production` + +The bot starts polling automatically when the token is present. No webhook configuration needed. + +#### Adding Future Platforms + +The platform layer is designed for extension. The `IMessagePlatform` interface (in `src/modules/messaging/abstractions/`) and the `Platform` enum (in `src/core/types/platform.ts`) define what a platform adapter must implement. Currently `WhatsAppCloud` and `Telegram` are the supported values. + +### 4. Verify + +```bash +pulse status # PM2 process should be "online" +curl localhost:3000/health # Should return OK +``` + +Send a message on any connected platform — you should get a response. + +--- + +## Daily Operations + +### Management Commands + +```bash +pulse start # Start the bot +pulse stop # Stop the bot +pulse restart # Restart the bot +pulse status # PM2 process status +pulse logs # Tail logs (Ctrl+C to exit) +pulse logs --lines 200 # Show more history +pulse monitor # Interactive PM2 monitor (CPU/mem) +pulse update # Pull latest code, rebuild, restart +pulse backup # Manual backup +``` + +### Checking Logs + +PM2 logs: + +```bash +# Combined (stdout + stderr) +pm2 logs pulse-production --lines 100 --nostream + +# Errors only +pm2 logs pulse-production --err --lines 50 --nostream + +# Live tail +pm2 logs pulse-production +``` + +Log files on disk: + +``` +~/.pm2/logs/pulse-production-out.log # stdout +~/.pm2/logs/pulse-production-error.log # stderr +/var/log/nginx/pulse_access.log # Nginx access +/var/log/nginx/pulse_error.log # Nginx errors +``` + +### Service Status + +```bash +pm2 status # Pulse process +systemctl status redis-server # Redis +systemctl status rabbitmq-server # RabbitMQ +systemctl status nginx # Nginx +``` + +--- + +## Updating Pulse + +```bash +cd /opt/pulse +git pull origin main +npm install +npm run build +pm2 restart pulse-production +``` + +Or use the shortcut: `pulse update` + +--- + +## Environment Configuration + +The `.env` file lives at `/opt/pulse/.env`. After editing, restart: `pm2 restart pulse-production` + +### Core (Required) + +| Variable | Description | +| --------------------- | ------------------------------------------------- | +| `NODE_ENV` | `production` | +| `PORT` | `3000` (Nginx proxies to this) | +| `FLASH_API_URL` | `https://api.flashapp.me/graphql` | +| `FLASH_API_KEY` | Flash API key — required for all payment features | +| `REDIS_HOST` | `localhost` | +| `REDIS_PORT` | `6379` | +| `REDIS_PASSWORD` | Generated during setup | +| `RABBITMQ_URL` | `amqp://pulse:@localhost:5672` | +| `ADMIN_PHONE_NUMBERS` | Comma-separated, no `+` prefix | + +### Messaging Platforms + +| Variable | Description | +| -------------------- | --------------------------------------------------------- | +| `WHATSAPP_INSTANCES` | Comma-separated phone numbers for multi-instance WhatsApp | +| `TELEGRAM_BOT_TOKEN` | Telegraf bot token — enables the Telegram platform | + +### AI & Voice (Optional) + +| Variable | Description | +| ---------------------- | -------------------------------------------------- | +| `GEMINI_API_KEY` | Google Gemini — conversational AI responses | +| `OPENAI_API_KEY` | OpenAI Whisper — speech-to-text for voice messages | +| `ELEVENLABS_API_KEY` | ElevenLabs — ultra-realistic voice synthesis | +| `GOOGLE_CLOUD_KEYFILE` | Path to GCP service account JSON — TTS and STT | + +### Integrations (Optional) + +| Variable | Description | +| ---------------------- | ----------------------------------------------------- | +| `NOSTR_PRIVATE_KEY` | Nostr nsec — enables content sharing / zap forwarding | +| `NOSTR_RELAYS` | Comma-separated relay WSS URLs | +| `SUPPORT_PHONE_NUMBER` | Routes user support requests to this number | + +### Security (Generated during setup) + +| Variable | Description | +| ----------------- | --------------------------- | +| `JWT_SECRET` | JWT signing key | +| `ENCRYPTION_KEY` | Session encryption key | +| `ENCRYPTION_SALT` | Encryption salt | +| `SESSION_SECRET` | Session secret | +| `WEBHOOK_SECRET` | Webhook verification secret | + +--- + +## Infrastructure Details + +### Nginx + +Config: `/etc/nginx/sites-enabled/pulse` + +- Proxies HTTPS to `localhost:3000` +- Rate limiting: 10 req/s per IP, burst 20 +- WebSocket support at `/socket.io` +- SSL via Let's Encrypt (auto-renews via certbot cron) +- Security headers (X-Frame-Options, X-Content-Type-Options, etc.) + +Test config changes: + +```bash +nginx -t && systemctl reload nginx +``` + +### Redis + +Config: `/etc/redis/redis.conf` + +- Bound to `127.0.0.1`, password-protected +- Used for session management, identity mapping, and caching +- Memory limit: 256 MB with LRU eviction + +```bash +redis-cli -a INFO memory # Check memory usage +redis-cli -a DBSIZE # Key count +``` + +### RabbitMQ + +Used for event messaging between services. The production config supports both monolith mode (in-process transport) and multi-process mode (RabbitMQ transport). + +Management UI: `http://:15672` (username: `pulse`, password in `.env`) + +### SSL Certificates + +Managed by Certbot. Auto-renewal is configured via cron at `/etc/cron.d/pulse`. + +Manual renewal: + +```bash +certbot renew +systemctl reload nginx +``` + +### Firewall (UFW) + +Open ports: 22 (SSH), 80 (HTTP), 443 (HTTPS). That's it. + +```bash +ufw status # Verify +``` + +### Backups + +Automated daily at 3 AM. Backs up: + +- WhatsApp session data (`/opt/pulse/whatsapp-sessions/`) +- `.env` configuration +- Redis dump + +Stored in `/opt/pulse/backups/`, last 7 retained. + +--- + +## Troubleshooting + +### WhatsApp Disconnected + +The most common issue. WhatsApp Web sessions expire or get disconnected periodically. + +```bash +pm2 restart pulse-production +pm2 logs pulse-production # Watch for new QR code +``` + +If no QR code appears and it keeps restarting, clear the session: + +```bash +rm -rf /opt/pulse/whatsapp-sessions/* +pm2 restart pulse-production +pm2 logs pulse-production # Scan new QR code +``` + +### Telegram Bot Not Responding + +Check that `TELEGRAM_BOT_TOKEN` is set in `.env` and the token is valid. Look for Telegraf startup messages in logs: + +```bash +pm2 logs pulse-production --lines 50 --nostream | grep -i telegram +``` + +If the bot was responding before and stopped, restart: + +```bash +pm2 restart pulse-production +``` + +### High Restart Count + +Check `pm2 status` — the restart counter accumulates over time. High restarts can mean: + +- WhatsApp disconnections triggering restarts +- Memory limit exceeded (max 1 GB) +- Unhandled exceptions + +Check error logs: + +```bash +pm2 logs pulse-production --err --lines 50 --nostream +``` + +### Redis Connection Issues + +```bash +systemctl status redis-server +redis-cli -a ping # Should return PONG +``` + +If Redis is down: + +```bash +systemctl restart redis-server +pm2 restart pulse-production +``` + +### Nginx 502 Bad Gateway + +Pulse isn't running or isn't listening on port 3000. + +```bash +pm2 status # Is pulse online? +curl localhost:3000/health # Is the app responding? +pm2 restart pulse-production # Restart if needed +``` + +### Out of Disk Space + +```bash +df -h / +# Clean up old logs +find /opt/pulse/logs -name "*.log" -mtime +7 -delete +pm2 flush # Clear PM2 log files +``` + +--- + +## Admin Controls + +### WhatsApp Admin Commands + +Admins (numbers listed in `ADMIN_PHONE_NUMBERS`) can send these via WhatsApp: + +| Command | Effect | +| --------------------- | ------------------------------------------------- | +| `admin status` | Connection status for all platforms | +| `admin disconnect` | Disconnect current WhatsApp session | +| `admin reconnect` | Generate new QR code (sent as image via WhatsApp) | +| `admin clear-session` | Full WhatsApp session reset | + +### Admin HTTP API + +Pulse exposes admin endpoints at `/admin/` (JWT-protected): + +| Endpoint | Method | Description | +| ----------------------- | ------ | -------------------------- | +| `/admin/auth/login` | POST | Admin login | +| `/admin/auth/verify` | POST | Verify OTP | +| `/admin/auth/refresh` | POST | Refresh JWT token | +| `/admin/status` | GET | System status | +| `/admin/users/:userId` | GET | User details | +| `/admin/stats` | GET | Usage statistics | +| `/admin/broadcast` | POST | Broadcast message to users | +| `/admin/features` | GET | Feature flag list | +| `/admin/features/:name` | PUT | Toggle feature flag | + +--- + +## Architecture Overview + +See [ARCHITECTURE.md](ARCHITECTURE.md) for the full design. + +Pulse uses hexagonal architecture (ports and adapters). The key insight is that **all business logic is platform-agnostic**: + +``` +Users (WhatsApp, Telegram, ...) + ↓ +Platform Adapters (translate to canonical messages) + ↓ +Message Orchestrator (NLP → intent detection → handler routing) + ↓ +Handlers (inject port interfaces, not platform specifics) + ↓ +Adapters (Flash API, Redis, AI services, voice services) +``` + +The app runs as a single process (`fork` mode in PM2). WhatsApp Web.js launches a headless Chrome instance. Telegram uses Telegraf in long-polling mode. Both feed into the same orchestrator and handler pipeline. diff --git a/package.json b/package.json index 6dca683..b1670a2 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "node": ">=20.0.0" }, "scripts": { - "build": "tsc --project tsconfig.json", + "build": "tsc --project tsconfig.json && node scripts/copy-prompts.js", "build:watch": "tsc --project tsconfig.json --watch", "dev": "tsx watch src/index.ts", "start": "node dist/index.js", diff --git a/scripts/copy-prompts.js b/scripts/copy-prompts.js new file mode 100644 index 0000000..0d03f16 --- /dev/null +++ b/scripts/copy-prompts.js @@ -0,0 +1,38 @@ +#!/usr/bin/env node +/** + * copy-prompts.js + * + * Copies Markdown prompt files from src/prompts/ → dist/prompts/. + * Run as part of the build pipeline after `tsc`. + * + * Why this exists: + * TypeScript's compiler only emits .js/.d.ts files — it ignores .md assets. + * PromptLoader resolves prompts from dist/prompts/ at runtime, so we must + * mirror the src/prompts/ tree into dist/ after every build. + * + * Usage: + * node scripts/copy-prompts.js + */ + +import { cpSync, mkdirSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const root = join(__dirname, '..'); +const src = join(root, 'src', 'prompts'); +const dest = join(root, 'dist', 'prompts'); + +mkdirSync(dest, { recursive: true }); + +cpSync(src, dest, { + recursive: true, + filter: (source) => { + // Only copy .md files and directories + return source.endsWith('.md') || !source.includes('.'); + }, +}); + +console.log(`✅ Prompts copied: ${src} → ${dest}`); diff --git a/src/adapters/context/PersistentContextAdapter.ts b/src/adapters/context/PersistentContextAdapter.ts index d7748b2..98dc781 100644 --- a/src/adapters/context/PersistentContextAdapter.ts +++ b/src/adapters/context/PersistentContextAdapter.ts @@ -74,6 +74,23 @@ export class PersistentContextAdapter implements ContextStorePort { .digest(); } + /** + * Ensure the base storage directory exists. + * Call once at startup before any reads/writes. + */ + async initialize(): Promise { + try { + await mkdir(this.basePath, { recursive: true }); + logger.debug({ basePath: this.basePath }, 'PersistentContextAdapter: storage directory ready'); + } catch (err) { + logger.error( + { basePath: this.basePath, error: String(err) }, + 'PersistentContextAdapter: failed to create storage directory', + ); + throw err; + } + } + // --------------------------------------------------------------------------- // Private helpers // --------------------------------------------------------------------------- diff --git a/src/app.module.ts b/src/app.module.ts index 48d31c8..e3d6be8 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -242,7 +242,7 @@ export async function createAppModule(): Promise { // --- Orchestrators --- const promptLoader = new PromptLoader(); - const agentOrchestrator = new AgentOrchestrator(primaryAI, toolRegistry); + const agentOrchestrator = new AgentOrchestrator(primaryAI, toolRegistry, wallet); // --- Interaction log store --- const logStore = new (await import('./core/context/InteractionLogStore.js')).InteractionLogStore( @@ -309,6 +309,9 @@ export async function createAppModule(): Promise { export async function initModule(module: AppModule): Promise { logger.info('AppModule: initialising'); + // Ensure cold store directory exists before any context reads/writes + await module.coldContextStore.initialize(); + if (config.WHATSAPP_PHONE_NUMBER_ID && config.WHATSAPP_ACCESS_TOKEN) { await module.messaging.initialize(); module.messageOrchestrator.register(); diff --git a/src/core/agent/AgentLoop.ts b/src/core/agent/AgentLoop.ts index 4ed7e03..8313f25 100644 --- a/src/core/agent/AgentLoop.ts +++ b/src/core/agent/AgentLoop.ts @@ -14,6 +14,7 @@ import { isTerminalSignal, requiresUserInput } from './CompletionSignal.js'; import type { ToolRegistry } from './ToolRegistry.js'; import type { AIProviderPort, AIMessage } from '../../ports/AIProviderPort.js'; import type { UserContext } from '../context/UserContext.js'; +import type { WalletPort } from '../../ports/WalletPort.js'; import { patchContext } from '../context/UserContext.js'; import { logger } from '../../config/logger.js'; @@ -62,15 +63,18 @@ export class AgentLoop { private readonly config: AgentConfig; private readonly toolRegistry: ToolRegistry; private readonly aiProvider: AIProviderPort; + private readonly walletPort: WalletPort; constructor( config: AgentConfig, toolRegistry: ToolRegistry, aiProvider: AIProviderPort, + walletPort: WalletPort, ) { this.config = config; this.toolRegistry = toolRegistry; this.aiProvider = aiProvider; + this.walletPort = walletPort; } /** @@ -184,6 +188,7 @@ export class AgentLoop { contextPatch = { ...contextPatch, ...patch }; }, requestId, + walletPort: this.walletPort, }; const toolResult = await this.toolRegistry.execute( diff --git a/src/core/context/ContextManager.ts b/src/core/context/ContextManager.ts index fea7e8b..c2ee193 100644 --- a/src/core/context/ContextManager.ts +++ b/src/core/context/ContextManager.ts @@ -100,7 +100,7 @@ export class ContextManager { // 1. Memory LRU cache if (this.options.enableCache) { const cached = this.memCache.get(phoneHash); - if (cached && Date.now() - cached.cachedAt < 60_000) { + if (cached && Date.now() - cached.cachedAt < 1_800_000) { // 30 minutes logger.debug({ phoneHash }, 'ContextManager: context loaded from memory cache'); return cached.context; } @@ -157,23 +157,37 @@ export class ContextManager { async saveContext(context: UserContext): Promise { const phoneHash = context.identity.phoneHash; + // Always update the in-memory cache first — this ensures the context is + // available for the next turn even if durable writes fail. + this.setMemCache(phoneHash, context); + // Write to both stores concurrently; cold store is authoritative const opts = { ttlSeconds: this.options.defaultTtlSeconds }; - const [, coldErr] = await Promise.allSettled([ + const [hotResult, coldResult] = await Promise.allSettled([ this.hotCache.saveContext(phoneHash, context, opts), this.coldStore.saveContext(phoneHash, context, opts), - ]).then((results) => results.map((r) => (r.status === 'rejected' ? r.reason : null))); + ]); + + const hotErr = hotResult.status === 'rejected' ? hotResult.reason : null; + const coldErr = coldResult.status === 'rejected' ? coldResult.reason : null; + + if (hotErr) { + logger.warn( + { phoneHash, error: String(hotErr) }, + 'ContextManager: hot cache write failed (memory cache still valid)', + ); + } if (coldErr) { logger.error( { phoneHash, error: String(coldErr) }, - 'ContextManager: cold store write failed', + 'ContextManager: cold store write failed (memory cache still valid)', ); - // Don't swallow — durable write failure should surface + // Surface the error so callers know persistence failed, + // but session remains available via in-memory cache. throw coldErr as Error; } - this.setMemCache(phoneHash, context); logger.debug({ phoneHash }, 'ContextManager: context saved (write-through)'); } diff --git a/src/core/context/UserContext.ts b/src/core/context/UserContext.ts index 665fe62..751e9ef 100644 --- a/src/core/context/UserContext.ts +++ b/src/core/context/UserContext.ts @@ -35,6 +35,12 @@ export const IdentitySchema = z.object({ displayName: z.string().optional(), /** User's timezone, e.g. 'America/Jamaica' */ timezone: z.string().optional(), + /** + * Flash API Bearer token for this user session. + * Obtained during OTP verification and used by wallet tools. + * Stored in context so it can be injected into WalletPort per-request. + */ + authToken: z.string().optional(), }); /** Language and dialect understanding */ @@ -214,6 +220,7 @@ export type PartialIdentityInput = { flashAccountId?: string; accountLinked?: boolean; kycTier?: 0 | 1 | 2; + authToken?: string; }; /** diff --git a/src/core/tools/Tool.ts b/src/core/tools/Tool.ts index 6a90503..886e1ab 100644 --- a/src/core/tools/Tool.ts +++ b/src/core/tools/Tool.ts @@ -9,6 +9,7 @@ import type { CompletionSignal } from '../agent/CompletionSignal.js'; import type { UserContext } from '../context/UserContext.js'; +import type { WalletPort } from '../../ports/WalletPort.js'; /** Category grouping for tools. */ export type ToolCategory = @@ -58,6 +59,12 @@ export interface ToolExecutionContext { * Correlation ID for tracing this request through logs. */ requestId: string; + /** + * Wallet adapter — provides access to Flash API wallet operations. + * Injected by AgentOrchestrator. Required for all wallet tools. + * Use walletPort.setAuthToken(accountId, token) before calling wallet methods. + */ + walletPort: WalletPort; } /** diff --git a/src/core/tools/identity/VerifyOTP.ts b/src/core/tools/identity/VerifyOTP.ts index 20754c5..b6d5023 100644 --- a/src/core/tools/identity/VerifyOTP.ts +++ b/src/core/tools/identity/VerifyOTP.ts @@ -173,6 +173,7 @@ export class VerifyOTP extends BaseTool { ...context.userContext.identity, accountLinked: true, phoneNumber: phone, + authToken, }, }); return this.complete( @@ -193,6 +194,7 @@ export class VerifyOTP extends BaseTool { phoneNumber: phone, flashUsername: me?.username ?? undefined, flashAccountId: defaultAccount?.id ?? undefined, + authToken, }, }); @@ -214,6 +216,7 @@ export class VerifyOTP extends BaseTool { ...context.userContext.identity, accountLinked: true, phoneNumber: phone, + authToken, }, }); return this.complete( diff --git a/src/core/tools/wallet/CheckBalance.ts b/src/core/tools/wallet/CheckBalance.ts index b7cfe97..ab60999 100644 --- a/src/core/tools/wallet/CheckBalance.ts +++ b/src/core/tools/wallet/CheckBalance.ts @@ -29,15 +29,52 @@ export class CheckBalance extends BaseTool { params: Record, context: ToolExecutionContext, ): Promise { - const { userContext } = context; + const { userContext, walletPort } = context; const accountId = userContext.identity.flashAccountId; + const authToken = userContext.identity.authToken; if (!accountId) { return this.fail('No Flash account ID found. Account may not be fully linked.'); } - // WalletPort is injected at runtime by the Orchestrator via DI - // For now, return a not-implemented error that signals the adapter is missing - throw new Error('WalletPort not injected. CheckBalance requires a WalletPort adapter.'); + if (!authToken) { + return this.fail('No auth token found. Please link your Flash account first.'); + } + + try { + // Register this user's auth token before querying + // Register token so the adapter can authenticate this user's requests + if ('setAuthToken' in walletPort) { + (walletPort as unknown as { setAuthToken(id: string, tok: string): void }).setAuthToken(accountId, authToken); + } + + const balance = await walletPort.getBalance(accountId); + const { available, total, pendingOut } = balance; + + const lines: string[] = [ + `Balance for account ${accountId}:`, + ` Available: ${available.display} ${available.currency}`, + ]; + + if (total.amountCents !== available.amountCents) { + lines.push(` Total (incl. pending): ${total.display} ${total.currency}`); + } + + if (pendingOut.amountCents > 0) { + lines.push(` Pending outgoing: ${pendingOut.display} ${pendingOut.currency}`); + } + + lines.push(` As of: ${balance.asOf.toISOString()}`); + + return this.success(lines.join('\n'), { + accountId, + availableCents: available.amountCents, + currency: available.currency, + asOf: balance.asOf.toISOString(), + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return this.fail(`Failed to fetch balance: ${message}`); + } } } diff --git a/src/core/tools/wallet/EstimateFee.ts b/src/core/tools/wallet/EstimateFee.ts index a11d373..6dc9ad2 100644 --- a/src/core/tools/wallet/EstimateFee.ts +++ b/src/core/tools/wallet/EstimateFee.ts @@ -3,6 +3,15 @@ */ import { BaseTool, type ToolResult, type ToolExecutionContext } from '../Tool.js'; +import type { Money } from '../../../ports/WalletPort.js'; + +function registerToken(context: ToolExecutionContext, accountId: string): void { + const { authToken } = context.userContext.identity; + if (authToken && 'setAuthToken' in context.walletPort) { + (context.walletPort as unknown as { setAuthToken(id: string, tok: string): void }) + .setAuthToken(accountId, authToken); + } +} export class EstimateFee extends BaseTool { readonly name = 'estimate_fee'; @@ -35,9 +44,51 @@ export class EstimateFee extends BaseTool { }; async execute( - _params: Record, - _context: ToolExecutionContext, + params: Record, + context: ToolExecutionContext, ): Promise { - throw new Error('WalletPort not injected. EstimateFee requires a WalletPort adapter.'); + const { userContext, walletPort } = context; + const accountId = userContext.identity.flashAccountId; + + if (!accountId) { + return this.fail('No Flash account ID found. Account may not be fully linked.'); + } + + if (accountId) registerToken(context, accountId); + + const destination = params['destination'] as string | undefined; + if (!destination) return this.fail('Destination is required.'); + + const amountParam = params['amount'] as { value: number; currency: string } | undefined; + const amount: Money = amountParam + ? { + amountCents: Math.round(amountParam.value * 100), + currency: amountParam.currency, + display: `${amountParam.value} ${amountParam.currency}`, + } + : { amountCents: 0, currency: 'SAT', display: '0 SAT' }; + + try { + const estimate = await walletPort.estimateFee(destination, amount); + + return this.success( + [ + 'Fee estimates:', + ` Low: ${estimate.low.display}`, + ` Medium: ${estimate.medium.display} (recommended)`, + ` High: ${estimate.high.display}`, + ` Est. settlement time: ~${Math.round(estimate.estimatedSettlementSeconds / 60)} min`, + ].join('\n'), + { + lowCents: estimate.low.amountCents, + mediumCents: estimate.medium.amountCents, + highCents: estimate.high.amountCents, + currency: estimate.medium.currency, + }, + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return this.fail(`Failed to estimate fee: ${message}`); + } } } diff --git a/src/core/tools/wallet/GetExchangeRate.ts b/src/core/tools/wallet/GetExchangeRate.ts index eb1174c..d9a9067 100644 --- a/src/core/tools/wallet/GetExchangeRate.ts +++ b/src/core/tools/wallet/GetExchangeRate.ts @@ -30,9 +30,35 @@ export class GetExchangeRate extends BaseTool { }; async execute( - _params: Record, - _context: ToolExecutionContext, + params: Record, + context: ToolExecutionContext, ): Promise { - throw new Error('WalletPort not injected. GetExchangeRate requires a WalletPort adapter.'); + const { walletPort } = context; + const from = params['from'] as string | undefined; + const to = params['to'] as string | undefined; + + if (!from || !to) { + return this.fail("Both 'from' and 'to' currency codes are required."); + } + + try { + const rate = await walletPort.getExchangeRate(from, to); + + return this.success( + `Exchange rate: 1 ${rate.from} = ${rate.rate.toFixed(6)} ${rate.to}\n` + + ` Effective rate (incl. fees): ${rate.effectiveRate.toFixed(6)} ${rate.to}\n` + + ` Valid for: ${rate.validForSeconds}s`, + { + from: rate.from, + to: rate.to, + rate: rate.rate, + effectiveRate: rate.effectiveRate, + timestamp: rate.timestamp.toISOString(), + }, + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return this.fail(`Failed to fetch exchange rate: ${message}`); + } } } diff --git a/src/core/tools/wallet/GetTransactionHistory.ts b/src/core/tools/wallet/GetTransactionHistory.ts index 21a8a28..eecd41a 100644 --- a/src/core/tools/wallet/GetTransactionHistory.ts +++ b/src/core/tools/wallet/GetTransactionHistory.ts @@ -4,6 +4,14 @@ import { BaseTool, type ToolResult, type ToolExecutionContext } from '../Tool.js'; +function registerToken(context: ToolExecutionContext, accountId: string): void { + const { authToken } = context.userContext.identity; + if (authToken && 'setAuthToken' in context.walletPort) { + (context.walletPort as unknown as { setAuthToken(id: string, tok: string): void }) + .setAuthToken(accountId, authToken); + } +} + export class GetTransactionHistory extends BaseTool { readonly name = 'get_transaction_history'; readonly description = @@ -39,9 +47,53 @@ export class GetTransactionHistory extends BaseTool { }; async execute( - _params: Record, - _context: ToolExecutionContext, + params: Record, + context: ToolExecutionContext, ): Promise { - throw new Error('WalletPort not injected. GetTransactionHistory requires a WalletPort adapter.'); + const { userContext, walletPort } = context; + const accountId = userContext.identity.flashAccountId; + + if (!accountId) { + return this.fail('No Flash account ID found. Account may not be fully linked.'); + } + if (!userContext.identity.authToken) { + return this.fail('No auth token found. Please link your Flash account first.'); + } + + registerToken(context, accountId); + + const limit = Math.min(Number(params['limit'] ?? 10), 50); + const direction = params['direction'] as 'credit' | 'debit' | undefined; + const from = params['fromDate'] ? new Date(params['fromDate'] as string) : undefined; + const to = params['toDate'] ? new Date(params['toDate'] as string) : undefined; + + try { + const history = await walletPort.getTransactionHistory({ + accountId, + limit, + direction, + from, + to, + }); + + if (history.transactions.length === 0) { + return this.success('No transactions found matching your criteria.'); + } + + const lines = history.transactions.map((tx) => { + const arrow = tx.direction === 'credit' ? '↓ Received' : '↑ Sent'; + const counterparty = tx.counterparty ? ` ${tx.direction === 'credit' ? 'from' : 'to'} ${tx.counterparty}` : ''; + const date = tx.createdAt.toLocaleDateString(); + return `${arrow} ${tx.amount.display}${counterparty} — ${tx.status} (${date})`; + }); + + return this.success( + `Last ${history.transactions.length} transaction(s):\n${lines.join('\n')}`, + { count: history.transactions.length }, + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return this.fail(`Failed to fetch transactions: ${message}`); + } } } diff --git a/src/core/tools/wallet/ReceivePayment.ts b/src/core/tools/wallet/ReceivePayment.ts index 2aa013e..702ca51 100644 --- a/src/core/tools/wallet/ReceivePayment.ts +++ b/src/core/tools/wallet/ReceivePayment.ts @@ -3,6 +3,15 @@ */ import { BaseTool, type ToolResult, type ToolExecutionContext } from '../Tool.js'; +import type { Money } from '../../../ports/WalletPort.js'; + +function registerToken(context: ToolExecutionContext, accountId: string): void { + const { authToken } = context.userContext.identity; + if (authToken && 'setAuthToken' in context.walletPort) { + (context.walletPort as unknown as { setAuthToken(id: string, tok: string): void }) + .setAuthToken(accountId, authToken); + } +} export class ReceivePayment extends BaseTool { readonly name = 'receive_payment'; @@ -39,9 +48,62 @@ export class ReceivePayment extends BaseTool { }; async execute( - _params: Record, - _context: ToolExecutionContext, + params: Record, + context: ToolExecutionContext, ): Promise { - throw new Error('WalletPort not injected. ReceivePayment requires a WalletPort adapter.'); + const { userContext, walletPort } = context; + const accountId = userContext.identity.flashAccountId; + + if (!accountId) { + return this.fail('No Flash account ID found. Account may not be fully linked.'); + } + if (!userContext.identity.authToken) { + return this.fail('No auth token found. Please link your Flash account first.'); + } + + registerToken(context, accountId); + + const amountParam = params['amount'] as { value: number; currency: string } | undefined; + const description = params['description'] as string | undefined; + const expiryMinutes = Number(params['expiryMinutes'] ?? 60); + + let amount: Money | undefined; + if (amountParam) { + amount = { + amountCents: Math.round(amountParam.value * 100), + currency: amountParam.currency, + display: `${amountParam.value} ${amountParam.currency}`, + }; + } + + try { + const invoice = await walletPort.createInvoice({ + accountId, + amount, + description, + expirySeconds: expiryMinutes * 60, + }); + + const lines = [ + amount + ? `✅ Invoice created for ${amount.display}:` + : '✅ Open-amount invoice created:', + ` Payment request: ${invoice.paymentRequest}`, + ` Expires: ${invoice.expiresAt.toISOString()}`, + ]; + + if (description) { + lines.push(` Description: ${description}`); + } + + return this.success(lines.join('\n'), { + paymentRequest: invoice.paymentRequest, + paymentHash: invoice.paymentHash, + expiresAt: invoice.expiresAt.toISOString(), + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return this.fail(`Failed to create invoice: ${message}`); + } } } diff --git a/src/core/tools/wallet/SendPayment.ts b/src/core/tools/wallet/SendPayment.ts index b1288f8..8a73a59 100644 --- a/src/core/tools/wallet/SendPayment.ts +++ b/src/core/tools/wallet/SendPayment.ts @@ -3,6 +3,16 @@ */ import { BaseTool, type ToolResult, type ToolExecutionContext } from '../Tool.js'; +import type { Money } from '../../../ports/WalletPort.js'; +import { v4 as uuidv4 } from 'uuid'; + +function registerToken(context: ToolExecutionContext, accountId: string): void { + const { authToken } = context.userContext.identity; + if (authToken && 'setAuthToken' in context.walletPort) { + (context.walletPort as unknown as { setAuthToken(id: string, tok: string): void }) + .setAuthToken(accountId, authToken); + } +} export class SendPayment extends BaseTool { readonly name = 'send_payment'; @@ -40,9 +50,66 @@ export class SendPayment extends BaseTool { }; async execute( - _params: Record, - _context: ToolExecutionContext, + params: Record, + context: ToolExecutionContext, ): Promise { - throw new Error('WalletPort not injected. SendPayment requires a WalletPort adapter.'); + const { userContext, walletPort } = context; + const accountId = userContext.identity.flashAccountId; + + if (!accountId) { + return this.fail('No Flash account ID found. Account may not be fully linked.'); + } + if (!userContext.identity.authToken) { + return this.fail('No auth token found. Please link your Flash account first.'); + } + + registerToken(context, accountId); + + const destination = params['destination'] as string | undefined; + if (!destination) { + return this.fail('Destination is required.'); + } + + const amountParam = params['amount'] as { value: number; currency: string } | undefined; + const memo = params['memo'] as string | undefined; + + let amount: Money | undefined; + if (amountParam) { + const amountCents = Math.round(amountParam.value * 100); + amount = { + amountCents, + currency: amountParam.currency, + display: `${amountParam.value} ${amountParam.currency}`, + }; + } + + try { + const result = await walletPort.sendPayment({ + fromAccountId: accountId, + destination, + amount, + memo, + idempotencyKey: uuidv4(), + }); + + return this.complete( + [ + '✅ Payment sent successfully!', + ` Amount: ${result.amountSent.display}`, + ` Fee: ${result.fee.display}`, + ` To: ${result.destinationDisplay}`, + ` Transaction ID: ${result.transactionId}`, + ` Settled: ${result.settledAt.toISOString()}`, + ].join('\n'), + { + transactionId: result.transactionId, + amountCents: result.amountSent.amountCents, + currency: result.amountSent.currency, + }, + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return this.fail(`Payment failed: ${message}`); + } } } diff --git a/src/index.ts b/src/index.ts index cb48992..52f27e7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -40,6 +40,7 @@ import { FileSystemAdapter } from './adapters/storage/FileSystemAdapter.js'; import { MessageOrchestrator } from './orchestrator/MessageOrchestrator.js'; import { AgentOrchestrator } from './orchestrator/AgentOrchestrator.js'; import { PromptLoader } from './config/PromptLoader.js'; +import { FlashAPIAdapter } from './adapters/wallet/FlashAPIAdapter.js'; import { createHealthRouter } from './api/routes/health.js'; import { createWebhookRouter } from './api/routes/webhooks.js'; import { createAdminRouter } from './api/routes/admin.js'; @@ -65,6 +66,9 @@ async function bootstrap(): Promise { keyPrefix: `${config.REDIS_KEY_PREFIX}context:`, }); + // Ensure cold store directory exists before any reads/writes + await coldStore.initialize(); + // ContextManager: read from hotCache first, fall back to coldStore; write-through both const contextManager = new ContextManager(hotCache, coldStore); @@ -152,8 +156,13 @@ async function bootstrap(): Promise { // 9. Orchestrators // --------------------------------------------------------------------------- + // WalletPort adapter — shared instance, tokens registered per-user at runtime + const walletAdapter = new FlashAPIAdapter({ + apiUrl: config.FLASH_API_URL ?? 'https://api.flashapp.me', + }); + // AgentOrchestrator: manages AgentLoop lifecycle for each message turn - const agentOrchestrator = new AgentOrchestrator(aiProvider, toolRegistry); + const agentOrchestrator = new AgentOrchestrator(aiProvider, toolRegistry, walletAdapter); // --------------------------------------------------------------------------- // 10. Messaging Adapter (WhatsApp Cloud API) diff --git a/src/orchestrator/AgentOrchestrator.ts b/src/orchestrator/AgentOrchestrator.ts index f3ccf7e..fa3d3a6 100644 --- a/src/orchestrator/AgentOrchestrator.ts +++ b/src/orchestrator/AgentOrchestrator.ts @@ -11,6 +11,7 @@ import type { IncomingMessage } from '../ports/MessagingPort.js'; import type { UserContext } from '../core/context/UserContext.js'; import type { AIProviderPort, AIMessage } from '../ports/AIProviderPort.js'; import type { ToolRegistry } from '../core/agent/ToolRegistry.js'; +import type { WalletPort } from '../ports/WalletPort.js'; import { AgentLoop } from '../core/agent/AgentLoop.js'; import { createDefaultAgentConfig } from '../core/agent/AgentConfig.js'; import { logger } from '../config/logger.js'; @@ -59,6 +60,7 @@ export class AgentOrchestrator { constructor( private readonly aiProvider: AIProviderPort, private readonly toolRegistry: ToolRegistry, + private readonly walletPort: WalletPort, ) {} /** @@ -100,7 +102,7 @@ export class AgentOrchestrator { })); // ── Create and run the AgentLoop ─────────────────────────────────────── - const loop = new AgentLoop(agentConfig, this.toolRegistry, this.aiProvider); + const loop = new AgentLoop(agentConfig, this.toolRegistry, this.aiProvider, this.walletPort); const userText = message.text ?? '[Voice message]'; diff --git a/src/prompts/system/base-agent.md b/src/prompts/system/base-agent.md index e057e8c..c5f08bb 100644 --- a/src/prompts/system/base-agent.md +++ b/src/prompts/system/base-agent.md @@ -8,7 +8,7 @@ You are **Pulse**, the AI-powered financial assistant for Flash — the Caribbea **You are Pulse.** Not "AI", not "bot", not "virtual assistant". Pulse. -You were built by Flash, a financial technology company serving the Caribbean. Flash lets people send and receive money over Bitcoin's Lightning Network, convert between local currencies (JMD, TTD, BBD, USD), and manage payments entirely from WhatsApp. +You were built by Flash, a financial technology company serving the Caribbean. Flash lets people send and receive money over Bitcoin's Lightning Network, convert between supported local currencies (JMD, TTD, BBD, USD, and others as Flash expands), and manage payments entirely from WhatsApp. You have a warm, confident personality. You are: - **Direct** — get to the point, no corporate fluff @@ -67,6 +67,8 @@ Reply *yes* to confirm or *no* to cancel. ### 2. Show Amounts in the User's Currency Always denominate amounts in the user's `preferredCurrency`. Show sat/BTC equivalents as secondary information only. Do NOT show prices exclusively in satoshis — most users don't think in sats. +See the **Currency Model** section for the distinction between display currency, account currency, and asset currency. + ### 3. Never Guess at Financial Data If a tool fails or returns stale data, say so. Never invent a balance, rate, or transaction status. "I couldn't retrieve your balance right now — try again in a moment" is better than a made-up number. @@ -89,6 +91,55 @@ You never ask for, store, or transmit passwords, PINs, seed phrases, or card num --- +## Currency Model + +Flash operates across three distinct currency layers. You must understand which layer you're working with at any point in a transaction. + +### The Three Layers + +**Display currency** (`preferredCurrency`) +What the user sees, thinks in, and communicates in. Set per-user. Examples: JMD, USD, TTD, BBD. This is always what you present to the user — never lead with sats or BTC. + +**Account currency** +The base unit Flash uses to denominate account balances internally. Currently USD for most Flash accounts. When a user says "my balance", they're asking about this layer, converted into their display currency. + +**Asset currency** +What actually moves on the payment network. For Lightning payments, this is satoshis (sats). Users never need to think about this layer — but you must be aware of it when quoting fees, explaining transaction mechanics, or handling failures. + +### How the Layers Interact + +A typical send looks like this: + +``` +User sees: "$2,500 JMD" (display currency) +Flash processes: "$16.40 USD" (account currency, at Flash's effective rate) +Network moves: "27,200 sats" (asset currency, on Lightning) +``` + +Never conflate these. "Your balance is 27,200 sats" is wrong. "Your balance is $2,500 JMD" (using their display currency, derived from their USD account) is right. + +### Supported Currencies + +The list of supported display currencies is dynamic and expands as Flash enters new markets. Do not hardcode or assume a fixed list. If a user asks whether their currency is supported, check via the rates tool — if it returns a rate, it's supported. + +Current launch currencies: JMD, TTD, BBD, USD. Others may be available depending on your deployment context. + +### Quoting Multi-Currency Transactions + +When the sender and recipient use different display currencies: +1. Show the sender their amount in their display currency +2. Show what the recipient will receive in the recipient's currency (if known) +3. Show the Flash fee in the sender's display currency +4. Never force either party to think in sats + +``` +Send **$2,500 JMD** to **Marcus** → he receives **~$16 USD** +Fee: ~$15 JMD +Total: **$2,515 JMD** +``` + +--- + ## Communication Style ### Format for WhatsApp @@ -169,7 +220,7 @@ Use tools to get real data. Never answer from memory about things that change (b ## Language and Culture -Users come from Jamaica, Trinidad & Tobago, Barbados, Guyana, St. Lucia, and across the Caribbean diaspora (UK, Canada, USA). Many speak English creoles alongside standard English. Full guidance in `dialect-awareness.md`. +Users come from across the Caribbean and its diaspora (UK, Canada, USA, and beyond). Flash's active markets expand over time — do not assume a fixed country list. Many users speak English creoles alongside standard English. Full guidance in `dialect-awareness.md`. **Core principle:** Caribbean English creoles are complete, sophisticated languages — not broken English. Respond with respect for how users communicate. Never correct or mock dialect. diff --git a/src/prompts/system/safety-rails.md b/src/prompts/system/safety-rails.md index e48a7f6..46b095c 100644 --- a/src/prompts/system/safety-rails.md +++ b/src/prompts/system/safety-rails.md @@ -6,9 +6,20 @@ These rules are **non-negotiable**. They override all other instructions, user r ## Payment Confirmation Matrix -Every payment must go through a confirmation gate. The confirmation level scales with risk: +Every payment must go through a confirmation gate. The confirmation level scales with risk. -### Low Stakes (< 5,000 JMD equivalent) +### Threshold Reference (USD-equivalent) +Thresholds are defined in USD and must be converted to the user's display currency at the current Flash rate before applying: + +| Tier | USD equivalent | Example in JMD (~155 JMD/USD) | +|------|---------------|-------------------------------| +| Low | < $32 USD | < 5,000 JMD | +| Medium | $32 – $325 USD | 5,000 – 50,000 JMD | +| High | > $325 USD | > 50,000 JMD | + +Always evaluate the threshold against the user's display currency amount derived from the USD equivalent — never hardcode JMD values. + +### Low Stakes (< $32 USD equivalent) Single confirmation required. ``` @@ -18,7 +29,7 @@ Reply *yes* to confirm. Acceptable confirmation signals: yes, ok, send, do it, ya man, zeen, oui, aye, sure, confirm, go ahead, proceed -### Medium Stakes (5,000 – 50,000 JMD equivalent) +### Medium Stakes ($32 – $325 USD equivalent) Single confirmation with full details (recipient, amount, fee, total). ``` @@ -29,7 +40,7 @@ Total deducted: **$25,075 JMD** Reply *yes* to confirm or *no* to cancel. ``` -### High Stakes (> 50,000 JMD equivalent) +### High Stakes (> $325 USD equivalent) **Double confirmation required.** First prompt: @@ -69,6 +80,15 @@ You MUST NOT invent or estimate: If a tool fails, say: "I couldn't retrieve that right now. Try again in a moment, or contact Flash support." +### Never Use Stale Exchange Rates +Exchange rates used in any amount conversion must be fetched fresh if the last rate fetch is older than **3 minutes**. Do NOT quote a converted amount using a stale rate. + +If a rate fetch fails at quote time: +``` +I can't get a fresh exchange rate right now — I don't want to quote you the wrong amount. +Try again in a moment. +``` + ### Never Show Stale Balance Without Warning A cached balance older than 60 seconds must be flagged: @@ -77,6 +97,17 @@ Your balance (last checked 3 minutes ago): **$12,500 JMD** Want me to refresh it? ``` +### Duplicate Payment Guard +If a payment to the same recipient for the same amount was completed within the last **2 minutes**, warn before showing a new confirmation: + +``` +⚠️ You just sent **$2,500 JMD** to **Kezia** a moment ago. Did you mean to send again? + +Reply *yes* to send again or *no* to cancel. +``` + +Do not silently re-send. Always surface this warning even if the user says "send again." + ### Transaction Status — No Guessing If a payment is in-flight and status is unknown, say so clearly: @@ -179,6 +210,17 @@ Escalate immediately (use the `Escalate` tool) and do NOT complete the transacti 5. **Sanctions evasion** — Destination is a known sanctioned entity or country 6. **Gambling proceeds** — Clear references to routing winnings through Flash in prohibited jurisdictions 7. **Account takeover signals** — User doesn't know their own username, account age, or last transaction +8. **Behavioral baseline deviation** — See below + +### AML Behavioral Baseline +Escalate if any of these patterns appear **within a single session**: + +- **10x spike** — Current transaction is 10× or more the user's typical transaction size (as reported by the transaction history tool) +- **New recipient + large + urgency** — First-ever send to a recipient, amount > $100 USD equivalent, AND user expresses time pressure +- **Rapid accumulation** — Multiple incoming payments followed immediately by a single large outbound (could indicate pass-through) +- **Unusual recipient count** — User attempts to send to 5+ different recipients in one session at elevated amounts + +When triggering on behavioral baseline, do NOT tell the user their history triggered a flag. Use the standard escalation script. ### Escalation Script ``` @@ -186,13 +228,38 @@ I need to pause here — something about this transaction needs a quick review b I'm flagging this for them now. Someone will follow up with you shortly. -[Flash support: support@flashapp.me | +1-876-XXX-XXXX] +[Flash support: wa.me/18762909250] ``` Do NOT explain exactly what triggered the escalation — that helps bad actors learn to avoid detection. --- +## Lightning-Specific Guards + +### Expired Invoice +Before presenting a payment confirmation for a Lightning invoice, validate that the invoice has not expired. If expired, reject immediately — do not show a confirmation screen. + +``` +This payment request has expired — Lightning invoices are only valid for a short time. + +Ask the recipient to generate a new one and send it to you. +``` + +### Invoice Amount Mismatch +If the user says "pay Marcus $5,000 JMD" but the invoice encodes a different amount, surface the mismatch before confirming: + +``` +⚠️ The payment request is for **$4,800 JMD**, not $5,000 JMD. + +Send **$4,800 JMD** to **Marcus**? +Reply *yes* to confirm or *no* to cancel. +``` + +Never silently use the invoice amount when it differs from what the user stated. + +--- + ## Error Handling ### Tool Failures diff --git a/tests/core/agent/ToolRegistry.test.ts b/tests/core/agent/ToolRegistry.test.ts index 803795d..07cc9f4 100644 --- a/tests/core/agent/ToolRegistry.test.ts +++ b/tests/core/agent/ToolRegistry.test.ts @@ -4,8 +4,21 @@ import { ToolRegistry } from '../../../src/core/agent/ToolRegistry'; import type { Tool, ToolResult, ToolExecutionContext } from '../../../src/core/tools/Tool'; +import type { WalletPort } from '../../../src/ports/WalletPort'; import { createDefaultContext } from '../../../src/core/context/UserContext'; +const stubWalletPort = { + getBalance: jest.fn(), + sendPayment: jest.fn(), + createInvoice: jest.fn(), + getInvoice: jest.fn(), + getTransactionHistory: jest.fn(), + getExchangeRate: jest.fn(), + estimateFee: jest.fn(), + resolveRecipient: jest.fn(), + ping: jest.fn(), +} as unknown as WalletPort; + /** Factory for a test tool. */ function makeTool(overrides: Partial = {}): Tool { return { @@ -114,6 +127,7 @@ describe('ToolRegistry', () => { userContext, updateContext: jest.fn(), requestId: 'req-123', + walletPort: stubWalletPort, }; const result = await registry.execute('test_tool', {}, ctx); expect(result.success).toBe(true); @@ -125,6 +139,7 @@ describe('ToolRegistry', () => { userContext, updateContext: jest.fn(), requestId: 'req-123', + walletPort: stubWalletPort, }; await expect(registry.execute('nonexistent', {}, ctx)).rejects.toThrow(/unknown tool/); }); @@ -139,6 +154,7 @@ describe('ToolRegistry', () => { userContext, updateContext: jest.fn(), requestId: 'req-123', + walletPort: stubWalletPort, }; const result = await registry.execute('test_tool', {}, ctx); expect(result.success).toBe(false); diff --git a/tests/core/regression/onboarding-otp-balance.test.ts b/tests/core/regression/onboarding-otp-balance.test.ts new file mode 100644 index 0000000..ef74a65 --- /dev/null +++ b/tests/core/regression/onboarding-otp-balance.test.ts @@ -0,0 +1,337 @@ +/** + * Regression test: onboarding → OTP → balance → repeat balance + * + * Covers the P0 session persistence bug (pulse#38) and wallet adapter injection + * (pulse#40). Both blockers must be fixed for this suite to pass. + * + * Test philosophy: + * - Test behavior, not implementation. Verify observable outcomes. + * - Use real ContextManager, real ToolRegistry, real CheckBalance/GetAccountStatus. + * - Mock only external I/O: AI provider, Flash API, context stores. + * - Each test section maps to one step in the happy path. + * + * If this test regresses, session persistence or adapter injection is broken. + */ + +// --------------------------------------------------------------------------- +// Mocks (must be before imports) +// --------------------------------------------------------------------------- + +jest.mock('../../../src/config/logger', () => ({ + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, + childLogger: jest.fn().mockReturnValue({ + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }), +})); + +// --------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------- + +import { ToolRegistry } from '../../../src/core/agent/ToolRegistry'; +import { ContextManager } from '../../../src/core/context/ContextManager'; +import { createDefaultContext, patchContext } from '../../../src/core/context/UserContext'; +import { CheckBalance } from '../../../src/core/tools/wallet/CheckBalance'; +import { GetAccountStatus } from '../../../src/core/tools/identity/GetAccountStatus'; +import { MockWalletPort, makeBalance } from '../../mocks/MockWalletPort'; +import { MockContextStore } from '../../mocks/MockContextStore'; +import type { ToolExecutionContext } from '../../../src/core/tools/Tool'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const PHONE = '+18765551234'; +const PHONE_HASH = ContextManager.hashPhone(PHONE); +const ACCOUNT_ID = 'acct-jabs-001'; +const AUTH_TOKEN = 'test-bearer-token-xyz'; +const BALANCE_CENTS = 2303; // $23.03 + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeLinkedContext() { + return createDefaultContext(PHONE_HASH, { + identity: { + phoneHash: PHONE_HASH, + phoneNumber: PHONE, + accountLinked: true, + flashAccountId: ACCOUNT_ID, + authToken: AUTH_TOKEN, + flashUsername: 'jabs', + kycTier: 1, + }, + }); +} + +function makeUnlinkedContext() { + return createDefaultContext(PHONE_HASH, { + identity: { + phoneHash: PHONE_HASH, + phoneNumber: PHONE, + }, + }); +} + +function makeExecContext( + walletPort: MockWalletPort, + userContext = makeLinkedContext(), +): ToolExecutionContext { + return { + userContext, + updateContext: jest.fn(), + requestId: 'req-regression-001', + walletPort: walletPort as unknown as import('../../../src/ports/WalletPort').WalletPort, + }; +} + +// --------------------------------------------------------------------------- +// Test suite +// --------------------------------------------------------------------------- + +describe('Regression: onboarding → OTP → balance → repeat balance', () => { + let walletPort: MockWalletPort; + let toolRegistry: ToolRegistry; + + beforeEach(() => { + walletPort = new MockWalletPort(); + walletPort.setSimpleBalance(ACCOUNT_ID, BALANCE_CENTS, 'USD'); + // Support setAuthToken call from CheckBalance + (walletPort as unknown as Record)['setAuthToken'] = jest.fn(); + + toolRegistry = new ToolRegistry(); + toolRegistry.register(new CheckBalance()); + toolRegistry.register(new GetAccountStatus()); + }); + + // ── Step 1: Unlinked user — wallet tools NOT available ─────────────────── + + describe('Step 1: unlinked user — wallet tools filtered out', () => { + it('check_balance is not available to unlinked users', () => { + const tools = toolRegistry.getToolsForUser(makeUnlinkedContext()); + expect(tools.map((t) => t.name)).not.toContain('check_balance'); + }); + + it('get_account_status is available without auth', () => { + const tools = toolRegistry.getToolsForUser(makeUnlinkedContext()); + expect(tools.map((t) => t.name)).toContain('get_account_status'); + }); + + it('get_account_status reports unlinked state', async () => { + const tool = new GetAccountStatus(); + const ctx = makeExecContext(walletPort, makeUnlinkedContext()); + const result = await tool.execute({}, ctx); + expect(result.output).toContain('not yet linked'); + expect(result.data?.['linked']).toBe(false); + }); + }); + + // ── Step 2: OTP success — context updated correctly ────────────────────── + + describe('Step 2: OTP success — authToken + accountLinked stored in context', () => { + it('patchContext preserves authToken after OTP verification', () => { + const unlinked = makeUnlinkedContext(); + const afterOtp = patchContext(unlinked, { + identity: { + ...unlinked.identity, + accountLinked: true, + flashAccountId: ACCOUNT_ID, + authToken: AUTH_TOKEN, + flashUsername: 'jabs', + }, + }); + + expect(afterOtp.identity.accountLinked).toBe(true); + expect(afterOtp.identity.authToken).toBe(AUTH_TOKEN); + expect(afterOtp.identity.flashAccountId).toBe(ACCOUNT_ID); + }); + + it('context round-trip through UserContextSchema preserves authToken', () => { + const linked = makeLinkedContext(); + expect(linked.identity.authToken).toBe(AUTH_TOKEN); + expect(linked.identity.accountLinked).toBe(true); + }); + }); + + // ── Step 3: Balance check — CORE REGRESSION ────────────────────────────── + + describe('Step 3: check_balance available and working for linked user', () => { + it('check_balance is available once accountLinked=true', () => { + const tools = toolRegistry.getToolsForUser(makeLinkedContext()); + expect(tools.map((t) => t.name)).toContain('check_balance'); + }); + + it('check_balance calls walletPort.getBalance with correct accountId', async () => { + const getBalanceSpy = jest.spyOn(walletPort, 'getBalance'); + const tool = new CheckBalance(); + const ctx = makeExecContext(walletPort); + + await tool.execute({}, ctx); + + expect(getBalanceSpy).toHaveBeenCalledWith(ACCOUNT_ID); + }); + + it('check_balance returns balance data (not a re-link error)', async () => { + const tool = new CheckBalance(); + const ctx = makeExecContext(walletPort); + + const result = await tool.execute({}, ctx); + + expect(result.success).toBe(true); + expect(result.output).toContain('23.03'); + expect(result.output).toContain(ACCOUNT_ID); + expect(result.data?.['availableCents']).toBe(BALANCE_CENTS); + }); + + it('check_balance fails gracefully without auth token', async () => { + const tool = new CheckBalance(); + const noTokenCtx = createDefaultContext(PHONE_HASH, { + identity: { + phoneHash: PHONE_HASH, + accountLinked: true, + flashAccountId: ACCOUNT_ID, + kycTier: 1, + // NO authToken + }, + }); + const ctx = makeExecContext(walletPort, noTokenCtx); + + const result = await tool.execute({}, ctx); + expect(result.success).toBe(false); + expect(result.output).toContain('auth token'); + }); + + it('check_balance registers authToken with walletPort before calling getBalance', async () => { + const setAuthTokenSpy = jest.fn(); + (walletPort as unknown as Record)['setAuthToken'] = setAuthTokenSpy; + + const tool = new CheckBalance(); + const ctx = makeExecContext(walletPort); + + await tool.execute({}, ctx); + + expect(setAuthTokenSpy).toHaveBeenCalledWith(ACCOUNT_ID, AUTH_TOKEN); + }); + }); + + // ── Step 4: Session persistence — context survives save/load ───────────── + + describe('Step 4: session persistence — context survives across turns', () => { + let contextStore: MockContextStore; + let contextManager: ContextManager; + + beforeEach(() => { + contextStore = new MockContextStore(); + contextManager = new ContextManager(contextStore, contextStore); + }); + + it('linked context persists correctly via saveContext/loadContext', async () => { + const linked = makeLinkedContext(); + await contextManager.saveContext(linked); + + const reloaded = await contextManager.loadContext(PHONE_HASH); + expect(reloaded.identity.accountLinked).toBe(true); + expect(reloaded.identity.authToken).toBe(AUTH_TOKEN); + expect(reloaded.identity.flashAccountId).toBe(ACCOUNT_ID); + }); + + it('save(phone)/load(phone) round-trip preserves linked state', async () => { + const linked = makeLinkedContext(); + await contextManager.save(PHONE, linked); + + const reloaded = await contextManager.load(PHONE); + expect(reloaded.identity.accountLinked).toBe(true); + expect(reloaded.identity.authToken).toBe(AUTH_TOKEN); + }); + + it('wallet tools available after context round-trip through store', async () => { + await contextManager.saveContext(makeLinkedContext()); + const reloaded = await contextManager.loadContext(PHONE_HASH); + + const tools = toolRegistry.getToolsForUser(reloaded); + expect(tools.map((t) => t.name)).toContain('check_balance'); + }); + + it('authToken survives 3 save/load cycles', async () => { + await contextManager.saveContext(makeLinkedContext()); + const r1 = await contextManager.loadContext(PHONE_HASH); + await contextManager.saveContext(r1); + const r2 = await contextManager.loadContext(PHONE_HASH); + await contextManager.saveContext(r2); + const r3 = await contextManager.loadContext(PHONE_HASH); + + expect(r3.identity.accountLinked).toBe(true); + expect(r3.identity.authToken).toBe(AUTH_TOKEN); + }); + + it('second balance check uses same linked context as first', async () => { + await contextManager.saveContext(makeLinkedContext()); + + // Turn 3: first balance check + const ctx3 = await contextManager.loadContext(PHONE_HASH); + const tool = new CheckBalance(); + const result3 = await tool.execute({}, makeExecContext(walletPort, ctx3)); + expect(result3.success).toBe(true); + + // Turn 4: second balance check — same context + const ctx4 = await contextManager.loadContext(PHONE_HASH); + const result4 = await tool.execute({}, makeExecContext(walletPort, ctx4)); + expect(result4.success).toBe(true); + expect(result4.data?.['availableCents']).toBe(BALANCE_CENTS); + }); + }); + + // ── Step 5: Resilience — memCache survives store write failure ──────────── + + describe('Step 5: session resilience when store write fails', () => { + it('memCache holds linked context even when cold store write fails', async () => { + // Use separate stores so we can fail only the cold store + const hotStore = new MockContextStore(); + const coldStore = new MockContextStore(); + const mgr = new ContextManager(hotStore, coldStore); + + const linked = makeLinkedContext(); + + // Fail the cold store (authoritative) write + coldStore.failOnSave(); + + // Save throws because cold store failed + let threw = false; + try { + await mgr.saveContext(linked); + } catch { + threw = true; + } + expect(threw).toBe(true); + + // Load should return from memCache — NOT a new default context + const reloaded = await mgr.loadContext(PHONE_HASH); + expect(reloaded.identity.accountLinked).toBe(true); + expect(reloaded.identity.authToken).toBe(AUTH_TOKEN); + }); + + it('new user is NOT returned when memCache has linked context', async () => { + const store = new MockContextStore(); + const mgr = new ContextManager(store, store); + + // Save succeeds — populates memCache + await mgr.saveContext(makeLinkedContext()); + + // Now break the store + store.failOnLoad(); + + // loadContext should return memCache, not fall through to create default + const reloaded = await mgr.loadContext(PHONE_HASH); + expect(reloaded.identity.accountLinked).toBe(true); + }); + }); +}); diff --git a/tests/orchestrator/AgentOrchestrator.test.ts b/tests/orchestrator/AgentOrchestrator.test.ts index d86f8cf..c04c219 100644 --- a/tests/orchestrator/AgentOrchestrator.test.ts +++ b/tests/orchestrator/AgentOrchestrator.test.ts @@ -45,6 +45,19 @@ import { createDefaultContext, patchContext } from '../../src/core/context/UserC import type { IncomingMessage } from '../../src/ports/MessagingPort'; import type { AIProviderPort } from '../../src/ports/AIProviderPort'; import type { ToolRegistry } from '../../src/core/agent/ToolRegistry'; +import type { WalletPort } from '../../src/ports/WalletPort'; + +const stubWalletPort: WalletPort = { + getBalance: jest.fn(), + sendPayment: jest.fn(), + createInvoice: jest.fn(), + getInvoice: jest.fn(), + getTransactionHistory: jest.fn(), + getExchangeRate: jest.fn(), + estimateFee: jest.fn(), + resolveRecipient: jest.fn(), + ping: jest.fn(), +} as unknown as WalletPort; const MockAgentLoop = AgentLoop as jest.MockedClass; const mockCreateDefaultAgentConfig = createDefaultAgentConfig as jest.MockedFunction< @@ -124,7 +137,7 @@ describe('AgentOrchestrator', () => { aiProvider = makeMockAIProvider(); toolRegistry = makeMockToolRegistry(); - orchestrator = new AgentOrchestrator(aiProvider, toolRegistry); + orchestrator = new AgentOrchestrator(aiProvider, toolRegistry, stubWalletPort); }); // -------------------------------------------------------------------------- @@ -461,6 +474,7 @@ describe('AgentOrchestrator', () => { expect.anything(), // agentConfig toolRegistry, aiProvider, + expect.anything(), // walletPort ); }); diff --git a/tests/unit/tools/wallet/CheckBalance.test.ts b/tests/unit/tools/wallet/CheckBalance.test.ts index fad83ab..e005824 100644 --- a/tests/unit/tools/wallet/CheckBalance.test.ts +++ b/tests/unit/tools/wallet/CheckBalance.test.ts @@ -9,11 +9,18 @@ jest.mock('../../../../src/config/logger', () => ({ warn: jest.fn(), error: jest.fn(), }, + childLogger: jest.fn().mockReturnValue({ + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }), })); import { CheckBalance } from '../../../../src/core/tools/wallet/CheckBalance'; import { createDefaultContext } from '../../../../src/core/context/UserContext'; import type { ToolExecutionContext } from '../../../../src/core/tools/Tool'; +import type { WalletPort, WalletBalance } from '../../../../src/ports/WalletPort'; // --------------------------------------------------------------------------- // Helpers @@ -21,6 +28,31 @@ import type { ToolExecutionContext } from '../../../../src/core/tools/Tool'; const PHONE_HASH = 'checkabalance-test-hash-000000000000000000000000000000000000000'; const ACCOUNT_ID = 'flash-account-uuid-123'; +const AUTH_TOKEN = 'test-bearer-token-abc123'; + +const MOCK_BALANCE: WalletBalance = { + accountId: ACCOUNT_ID, + available: { amountCents: 10050, currency: 'USD', display: '$100.50' }, + total: { amountCents: 10050, currency: 'USD', display: '$100.50' }, + pendingOut: { amountCents: 0, currency: 'USD', display: '$0.00' }, + asOf: new Date('2026-01-01T00:00:00Z'), +}; + +function makeStubWalletPort(overrides: Partial = {}): WalletPort { + return { + getBalance: jest.fn().mockResolvedValue(MOCK_BALANCE), + sendPayment: jest.fn(), + createInvoice: jest.fn(), + getInvoice: jest.fn(), + getTransactionHistory: jest.fn(), + getExchangeRate: jest.fn(), + estimateFee: jest.fn(), + resolveRecipient: jest.fn(), + ping: jest.fn().mockResolvedValue(true), + setAuthToken: jest.fn(), + ...overrides, + } as unknown as WalletPort; +} function makeLinkedContext() { return createDefaultContext(PHONE_HASH, { @@ -29,6 +61,7 @@ function makeLinkedContext() { phoneNumber: '+18765551234', accountLinked: true, flashAccountId: ACCOUNT_ID, + authToken: AUTH_TOKEN, kycTier: 1, }, }); @@ -45,11 +78,15 @@ function makeUnlinkedContext() { }); } -function makeContext(userContext: ReturnType): ToolExecutionContext { +function makeContext( + userContext: ReturnType, + walletPort?: WalletPort, +): ToolExecutionContext { return { userContext, updateContext: jest.fn(), requestId: 'req-test-001', + walletPort: walletPort ?? makeStubWalletPort(), }; } @@ -90,7 +127,7 @@ describe('CheckBalance tool', () => { it('has parameters schema', () => { expect(tool.parameters).toBeDefined(); - expect((tool.parameters as any).type).toBe('object'); + expect((tool.parameters as Record).type).toBe('object'); }); }); @@ -98,11 +135,8 @@ describe('CheckBalance tool', () => { describe('unlinked account', () => { it('returns fail when no flashAccountId is set', async () => { - const unlinkedCtx = makeUnlinkedContext(); - const ctx = makeContext(unlinkedCtx); - + const ctx = makeContext(makeUnlinkedContext()); const result = await tool.execute({}, ctx); - expect(result.success).toBe(false); expect(result.output).toContain('No Flash account ID'); expect(result.signal).toBe('continue'); @@ -115,38 +149,81 @@ describe('CheckBalance tool', () => { }); }); - // ── Linked account ──────────────────────────────────────────────────────── + // ── Linked account — auth token missing ────────────────────────────────── - describe('linked account without WalletPort injection', () => { - it('throws when WalletPort is not injected', async () => { - const linkedCtx = makeLinkedContext(); - const ctx = makeContext(linkedCtx); + describe('linked account without auth token', () => { + it('returns fail when authToken is missing from context', async () => { + const ctxNoToken = createDefaultContext(PHONE_HASH, { + identity: { + phoneHash: PHONE_HASH, + accountLinked: true, + flashAccountId: ACCOUNT_ID, + kycTier: 1, + }, + }); + const ctx = makeContext(ctxNoToken); + const result = await tool.execute({}, ctx); + expect(result.success).toBe(false); + expect(result.output).toContain('auth token'); + }); + }); - // The current implementation throws when WalletPort is not injected. - // In production, the Orchestrator injects the WalletPort. - // Test that the error is meaningful. - await expect(tool.execute({}, ctx)).rejects.toThrow(/WalletPort not injected/); + // ── Linked account with WalletPort ─────────────────────────────────────── + + describe('linked account with injected WalletPort', () => { + it('calls walletPort.getBalance with the correct accountId', async () => { + const walletPort = makeStubWalletPort(); + const ctx = makeContext(makeLinkedContext(), walletPort); + await tool.execute({}, ctx); + expect(walletPort.getBalance).toHaveBeenCalledWith(ACCOUNT_ID); }); - it('the error message describes the injection requirement', async () => { - const ctx = makeContext(makeLinkedContext()); - try { - await tool.execute({}, ctx); - fail('Expected an error'); - } catch (err) { - expect((err as Error).message).toContain('WalletPort'); - } + it('registers the auth token via setAuthToken', async () => { + const walletPort = makeStubWalletPort(); + const ctx = makeContext(makeLinkedContext(), walletPort); + await tool.execute({}, ctx); + expect((walletPort as unknown as { setAuthToken: jest.Mock }).setAuthToken) + .toHaveBeenCalledWith(ACCOUNT_ID, AUTH_TOKEN); + }); + + it('returns success with balance info', async () => { + const walletPort = makeStubWalletPort(); + const ctx = makeContext(makeLinkedContext(), walletPort); + const result = await tool.execute({}, ctx); + expect(result.success).toBe(true); + expect(result.output).toContain('$100.50'); + expect(result.output).toContain(ACCOUNT_ID); + }); + + it('returns data with accountId, availableCents, currency', async () => { + const walletPort = makeStubWalletPort(); + const ctx = makeContext(makeLinkedContext(), walletPort); + const result = await tool.execute({}, ctx); + expect(result.data).toMatchObject({ + accountId: ACCOUNT_ID, + availableCents: 10050, + currency: 'USD', + }); + }); + + it('returns fail when walletPort.getBalance throws', async () => { + const walletPort = makeStubWalletPort({ + getBalance: jest.fn().mockRejectedValue(new Error('Network timeout')), + }); + const ctx = makeContext(makeLinkedContext(), walletPort); + const result = await tool.execute({}, ctx); + expect(result.success).toBe(false); + expect(result.output).toContain('Network timeout'); }); }); // ── Parameter handling ─────────────────────────────────────────────────── describe('parameter handling', () => { - it('accepts an optional currency parameter', async () => { - // When unlinked, the currency param is irrelevant — it fails before reading it + it('accepts an optional currency parameter without error', async () => { const ctx = makeContext(makeUnlinkedContext()); const result = await tool.execute({ currency: 'JMD' }, ctx); - expect(result.success).toBe(false); + expect(result.success).toBe(false); // fails on unlinked, not on bad params }); it('accepts empty params object', async () => { @@ -160,7 +237,6 @@ describe('CheckBalance tool', () => { describe('BaseTool helpers (via fail())', () => { it('fail() returns success=false with continue signal', async () => { - // The unlinked account path tests fail() indirectly const ctx = makeContext(makeUnlinkedContext()); const result = await tool.execute({}, ctx); expect(result.success).toBe(false); @@ -168,49 +244,3 @@ describe('CheckBalance tool', () => { }); }); }); - -// --------------------------------------------------------------------------- -// CheckBalance with mocked WalletPort (monkey-patching approach) -// --------------------------------------------------------------------------- - -describe('CheckBalance with injected WalletPort', () => { - it('can be extended to accept a WalletPort via subclass', async () => { - // Demonstrate how the tool would work with a WalletPort injected. - // We override execute() to simulate the expected behavior. - class CheckBalanceWithWallet extends CheckBalance { - override async execute( - params: Record, - context: ToolExecutionContext, - ) { - const accountId = context.userContext.identity.flashAccountId; - if (!accountId) { - return this.fail('No Flash account ID found. Account may not be fully linked.'); - } - // Simulate a successful balance check - return this.complete( - `Your current balance is USD 100.00 available.`, - { accountId, currency: params['currency'] ?? 'USD' }, - ); - } - } - - const tool = new CheckBalanceWithWallet(); - const ctx: ToolExecutionContext = { - userContext: createDefaultContext('hash-001', { - identity: { - phoneHash: 'hash-001', - accountLinked: true, - flashAccountId: 'acct-001', - kycTier: 1, - }, - }), - updateContext: jest.fn(), - requestId: 'req-001', - }; - - const result = await tool.execute({ currency: 'USD' }, ctx); - expect(result.success).toBe(true); - expect(result.signal).toBe('complete'); - expect(result.output).toContain('100.00'); - }); -});