diff --git a/docs/session-sharing.md b/docs/session-sharing.md new file mode 100644 index 000000000..5fb82fb91 --- /dev/null +++ b/docs/session-sharing.md @@ -0,0 +1,262 @@ +# Cross-Provider Session Sharing + +## Overview + +The session sharing feature allows users to link multiple messaging provider identities (e.g., WhatsApp phone number and Telegram user ID) to share a single Claude conversation session. This enables seamless conversation continuity across different platforms. + +## Supported Providers + +- **WhatsApp** (via wa-web provider) +- **Telegram** +- **Twilio** (SMS/WhatsApp via Twilio API) + +## How It Works + +### Without Identity Mapping (Default) + +By default, each provider maintains separate Claude sessions: + +- WhatsApp messages from `+1234567890` → session: `+1234567890` +- Telegram messages from user `@john` → session: `telegram:@john` +- Each provider has its own isolated conversation history + +### With Identity Mapping + +When identities are linked, they share the same Claude session: + +```bash +# Link WhatsApp and Telegram identities +warelay identity link --whatsapp +1234567890 --telegram @john --name "John Doe" + +# Now both providers share session: shared-abc-123 +# WhatsApp from +1234567890 → session: shared-abc-123 +# Telegram from @john → session: shared-abc-123 +``` + +Messages from either provider will continue the same conversation. + +## Architecture + +### Identity Mapping Storage + +Identity mappings are stored in `~/.clawdis/identity-map.json`: + +```json +{ + "version": 1, + "mappings": { + "shared-abc-123": { + "id": "shared-abc-123", + "name": "John Doe", + "identities": { + "whatsapp": "+1234567890", + "telegram": "@john" + }, + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z" + } + } +} +``` + +### Session ID Normalization + +The `normalizeSessionId()` function in `src/identity/normalize.ts` handles the mapping: + +```typescript +// Without mapping +normalizeSessionId("telegram", "123456") → "telegram:123456" +normalizeSessionId("whatsapp", "+1234") → "+1234" + +// With mapping (both return the same shared ID) +normalizeSessionId("telegram", "123456") → "shared-abc-123" +normalizeSessionId("whatsapp", "+1234") → "shared-abc-123" +``` + +### Integration Points + +Session normalization is integrated at the `deriveSessionKey()` level in `src/config/sessions.ts`, which means: + +- All auto-reply systems automatically use the normalized session IDs +- Session storage (`~/.clawdis/sessions.json`) uses normalized IDs +- No changes needed in individual provider implementations + +## Provider ID Formats + +### Telegram +- **Username format**: `@username` (e.g., `@john`) +- **User ID format**: `123456789` (numeric) +- **Normalized without mapping**: `telegram:@username` or `telegram:123456789` + +### WhatsApp (wa-web) +- **Format**: E.164 phone number (e.g., `+1234567890`) +- **Normalized without mapping**: `+1234567890` (phone number directly) + +### Twilio +- **Format**: E.164 phone number (e.g., `+1234567890`) +- **Normalized without mapping**: `+1234567890` (phone number directly) + +## CLI Commands + +The following commands are available for managing identity mappings: + +### Link Identities + +```bash +warelay identity link --whatsapp +1234567890 --telegram @john --name "John" +``` + +Links multiple provider identities to share a single Claude session. At least two providers must be specified. + +**Options:** +- `--whatsapp ` - WhatsApp phone number in E.164 format +- `--telegram ` - Telegram username (@username) or numeric user ID +- `--twilio ` - Twilio phone number in E.164 format +- `--name ` - Optional display name for the mapping + +### List All Mappings + +```bash +warelay identity list [--json] +``` + +Shows all identity mappings with their linked providers and timestamps. Use `--json` for machine-readable output. + +### Show Mapping Details + +```bash +warelay identity show [--json] +``` + +Displays detailed information about a specific identity mapping. + +### Unlink Identities + +```bash +warelay identity unlink +``` + +Removes an identity mapping. After unlinking, each provider will have its own separate Claude session again. + +## Use Cases + +### 1. Multi-Device Access +Link your personal WhatsApp and Telegram accounts to maintain conversation continuity: +- Start conversation on WhatsApp during work hours +- Continue same conversation on Telegram while commuting +- Claude remembers full context from both platforms + +### 2. Family/Team Sharing +Link multiple family members' or team members' accounts to share a Claude assistant: +- Mom's WhatsApp: `+1234567890` +- Dad's Telegram: `@dad_username` +- Both access the same family assistant with shared context + +### 3. Migration Scenarios +Smoothly migrate from one platform to another: +- Link old and new accounts before migration +- Conversation history preserved during transition +- Unlink old account after migration complete + +## Implementation Details + +### Provider Detection + +The `detectProvider()` function in `src/config/sessions.ts` determines the provider from the ID format: + +```typescript +function detectProvider(from: string): "whatsapp" | "telegram" | "twilio" { + // Telegram: "telegram:123" or "@username" + if (from.startsWith("telegram:") || from.startsWith("@")) { + return "telegram"; + } + // WhatsApp/Twilio: E.164 phone numbers + return "whatsapp"; +} +``` + +### Async Session Key Derivation + +Session key derivation is now async to support identity lookup: + +```typescript +// Before (synchronous) +const key = deriveSessionKey(scope, ctx); + +// After (asynchronous) +const key = await deriveSessionKey(scope, ctx); +``` + +All callers have been updated to handle the async nature: +- `src/auto-reply/reply.ts` - Auto-reply system +- `src/commands/agent.ts` - Agent command +- `src/web/auto-reply.ts` - Web provider auto-reply + +### Group Conversations + +Identity mapping does NOT apply to group conversations. Groups maintain separate session keys to avoid mixing group and individual conversation contexts: + +```typescript +// Group conversations always use group JID as session key +if (ctx.From.includes("@g.us")) { + return `group:${ctx.From}`; // No normalization +} +``` + +## Testing + +Comprehensive test coverage in `src/identity/`: + +- **`normalize.test.ts`** (11 tests): Session ID normalization logic +- **`storage.test.ts`** (18 tests): Identity map persistence and operations + +Run tests: +```bash +pnpm test src/identity +``` + +## Backwards Compatibility + +The feature is fully backwards compatible: + +- **No mapping = no change**: Without identity mappings, behavior is identical to before +- **Existing sessions preserved**: Old session IDs continue to work +- **Opt-in feature**: Users must explicitly create mappings to enable sharing + +## Security Considerations + +- Identity mappings are stored locally in `~/.clawdis/` +- No server-side synchronization (privacy-first design) +- Users have full control over which identities are linked +- Unlinking is immediate and removes all associations + +## Future Enhancements + +1. **Web UI**: Admin interface for managing identity mappings +2. **Auto-discovery**: Suggest linking when same phone number detected across providers +3. **Audit Log**: Track when identities were linked/unlinked +4. **Export/Import**: Backup and restore identity mappings +5. **CLI Tests**: Add comprehensive E2E tests for all CLI commands + +## Troubleshooting + +### Sessions not sharing after linking + +1. Check if mapping was created: `cat ~/.clawdis/identity-map.json` +2. Verify provider IDs match exactly (case-sensitive for Telegram usernames) +3. Restart the relay to pick up new mappings + +### Wrong identity format + +- WhatsApp/Twilio: Must use E.164 format with `+` prefix (e.g., `+1234567890`) +- Telegram usernames: Must include `@` prefix (e.g., `@john`) +- Telegram user IDs: Numeric only (e.g., `123456789`) + +### How to reset + +Delete the identity map file: +```bash +rm ~/.clawdis/identity-map.json +``` + +Sessions will revert to provider-specific IDs on next message. diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index a931d852d..a425cd321 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -232,7 +232,7 @@ export async function getReplyFromConfig( } } - sessionKey = deriveSessionKey(sessionScope, ctx); + sessionKey = await deriveSessionKey(sessionScope, ctx); sessionStore = loadSessionStore(storePath); const entry = sessionStore[sessionKey]; const idleMs = idleMinutes * 60_000; diff --git a/src/cli/program.ts b/src/cli/program.ts index 87f4f23e6..7d71c046e 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -1,6 +1,12 @@ import chalk from "chalk"; import { Command } from "commander"; import { agentCommand } from "../commands/agent.js"; +import { + identityLinkCommand, + identityListCommand, + identityShowCommand, + identityUnlinkCommand, +} from "../commands/identity.js"; import { sendCommand } from "../commands/send.js"; import { statusCommand } from "../commands/status.js"; import { webhookCommand } from "../commands/webhook.js"; @@ -377,6 +383,7 @@ Examples: .command("relay") .description("Auto-reply to inbound messages (auto-selects web or twilio)") .option("--provider ", "auto | web | twilio | telegram", "auto") + .option("--providers ", "Comma-separated list: web,telegram,twilio") .option("-i, --interval ", "Polling interval for twilio mode", "5") .option( "-l, --lookback ", @@ -406,9 +413,10 @@ Examples: "after", ` Examples: - warelay relay # auto: web if logged-in, else twilio poll - warelay relay --provider web # force personal web session - warelay relay --provider twilio # force twilio poll + warelay relay # auto: web if logged-in, else twilio poll + warelay relay --provider web # force personal web session + warelay relay --provider telegram # force telegram only + warelay relay --providers web,telegram # monitor both simultaneously warelay relay --provider twilio --interval 2 --lookback 30 # Troubleshooting: docs/refactor/web-relay-troubleshooting.md `, @@ -417,6 +425,54 @@ Examples: setVerbose(Boolean(opts.verbose)); const { file: logFile, level: logLevel } = getResolvedLoggerSettings(); defaultRuntime.log(info(`logs: ${logFile} (level ${logLevel})`)); + + // Handle --providers for multiple simultaneous relays + if (opts.providers) { + const providers = String(opts.providers).split(',').map(p => p.trim()); + const validProviders = ['web', 'telegram', 'twilio']; + const invalid = providers.filter(p => !validProviders.includes(p)); + if (invalid.length > 0) { + defaultRuntime.error(`Invalid providers: ${invalid.join(', ')}. Must be: web, telegram, twilio`); + defaultRuntime.exit(1); + } + + defaultRuntime.log(info(`Starting relay for providers: ${providers.join(', ')}`)); + + // Start all providers concurrently + const promises = providers.map(async (provider) => { + try { + if (provider === 'telegram') { + await monitorTelegramProvider(Boolean(opts.verbose), defaultRuntime); + } else if (provider === 'web') { + const cfg = loadConfig(); + const webTuning: WebMonitorTuning = {}; + if (opts.webHeartbeat) webTuning.heartbeatSeconds = Number.parseInt(String(opts.webHeartbeat), 10); + if (opts.heartbeatNow) webTuning.replyHeartbeatNow = true; + const reconnect: WebMonitorTuning["reconnect"] = {}; + if (opts.webRetries) reconnect.maxAttempts = Number.parseInt(String(opts.webRetries), 10); + if (opts.webRetryInitial) reconnect.initialMs = Number.parseInt(String(opts.webRetryInitial), 10); + if (opts.webRetryMax) reconnect.maxMs = Number.parseInt(String(opts.webRetryMax), 10); + if (Object.keys(reconnect).length > 0) webTuning.reconnect = reconnect; + + logWebSelfId(defaultRuntime, true); + await monitorWebProvider(Boolean(opts.verbose), undefined, true, undefined, defaultRuntime, undefined, webTuning); + } else if (provider === 'twilio') { + ensureTwilioEnv(); + logTwilioFrom(); + const intervalSeconds = Number.parseInt(opts.interval || "5", 10); + const lookbackMinutes = Number.parseInt(opts.lookback || "5", 10); + await monitorTwilio(intervalSeconds, lookbackMinutes); + } + } catch (err) { + defaultRuntime.error(danger(`${provider} relay failed: ${String(err)}`)); + } + }); + + await Promise.all(promises); + return; + } + + // Original single-provider logic const providerPref = String(opts.provider ?? "auto"); if (!["auto", "web", "twilio", "telegram"].includes(providerPref)) { defaultRuntime.error("--provider must be auto, web, twilio, or telegram"); @@ -772,5 +828,92 @@ Examples: } }); + // Identity management commands + const identity = program + .command("identity") + .description("Manage cross-provider identity mappings for shared Claude sessions"); + + identity + .command("link") + .description("Link provider identities to share a Claude session") + .option("--whatsapp ", "WhatsApp phone number (E.164 format)") + .option("--telegram ", "Telegram username (@username) or user ID") + .option("--twilio ", "Twilio phone number (E.164 format)") + .option("--name ", "Optional display name for this identity mapping") + .addHelpText( + "after", + ` +Examples: + warelay identity link --whatsapp +1234567890 --telegram @john --name "John Doe" + warelay identity link --whatsapp +1234567890 --twilio +1987654321 + warelay identity link --telegram 123456789 --whatsapp +1234567890`, + ) + .action(async (opts) => { + try { + await identityLinkCommand(opts, defaultRuntime); + } catch (err) { + defaultRuntime.error(String(err)); + defaultRuntime.exit(1); + } + }); + + identity + .command("list") + .description("List all identity mappings") + .option("--json", "Output as JSON", false) + .addHelpText( + "after", + ` +Examples: + warelay identity list + warelay identity list --json`, + ) + .action(async (opts) => { + try { + await identityListCommand(opts, defaultRuntime); + } catch (err) { + defaultRuntime.error(String(err)); + defaultRuntime.exit(1); + } + }); + + identity + .command("show ") + .description("Show details of a specific identity mapping") + .option("--json", "Output as JSON", false) + .addHelpText( + "after", + ` +Examples: + warelay identity show shared-abc-123 + warelay identity show shared-abc-123 --json`, + ) + .action(async (id, opts) => { + try { + await identityShowCommand({ id, ...opts }, defaultRuntime); + } catch (err) { + defaultRuntime.error(String(err)); + defaultRuntime.exit(1); + } + }); + + identity + .command("unlink ") + .description("Unlink an identity mapping (providers will have separate sessions)") + .addHelpText( + "after", + ` +Examples: + warelay identity unlink shared-abc-123`, + ) + .action(async (id) => { + try { + await identityUnlinkCommand({ id }, defaultRuntime); + } catch (err) { + defaultRuntime.error(String(err)); + defaultRuntime.exit(1); + } + }); + return program; } diff --git a/src/commands/agent.ts b/src/commands/agent.ts index ba185687b..6b108560b 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -65,11 +65,11 @@ function assertCommandConfig(cfg: WarelayConfig) { > & { mode: "command"; command: string[] }; } -function resolveSession(opts: { +async function resolveSession(opts: { to?: string; sessionId?: string; replyCfg: NonNullable["reply"]>; -}): SessionResolution { +}): Promise { const sessionCfg = opts.replyCfg?.session; const scope = sessionCfg?.scope ?? "per-sender"; const idleMinutes = Math.max( @@ -83,7 +83,7 @@ function resolveSession(opts: { let sessionKey: string | undefined = sessionStore && opts.to - ? deriveSessionKey(scope, { From: opts.to } as MsgContext) + ? await deriveSessionKey(scope, { From: opts.to } as MsgContext) : undefined; let sessionEntry = sessionKey && sessionStore ? sessionStore[sessionKey] : undefined; @@ -195,7 +195,7 @@ export async function agentCommand( } const timeoutMs = timeoutSeconds * 1000; - const sessionResolution = resolveSession({ + const sessionResolution = await resolveSession({ to: opts.to, sessionId: opts.sessionId, replyCfg, diff --git a/src/commands/identity.ts b/src/commands/identity.ts new file mode 100644 index 000000000..f52906d61 --- /dev/null +++ b/src/commands/identity.ts @@ -0,0 +1,312 @@ +import crypto from "node:crypto"; +import chalk from "chalk"; +import type { RuntimeEnv } from "../runtime.js"; +import { + deleteMapping, + getMapping, + listMappings, + setMapping, +} from "../identity/storage.js"; +import type { IdentityMapping } from "../identity/types.js"; +import { danger, info, success, warn } from "../globals.js"; + +type IdentityLinkOpts = { + whatsapp?: string; + telegram?: string; + twilio?: string; + name?: string; +}; + +type IdentityListOpts = { + json?: boolean; +}; + +type IdentityShowOpts = { + id: string; + json?: boolean; +}; + +type IdentityUnlinkOpts = { + id: string; +}; + +/** + * Validates E.164 phone number format (+country code + number) + */ +function isValidE164(phone: string): boolean { + return /^\+[1-9]\d{1,14}$/.test(phone); +} + +/** + * Validates Telegram username (@username) or numeric user ID + */ +function isValidTelegram(telegram: string): boolean { + // Allow @username or numeric user ID + return /^@[a-zA-Z0-9_]{5,32}$/.test(telegram) || /^\d+$/.test(telegram); +} + +/** + * Generate a random shared ID for a new identity mapping + */ +function generateSharedId(): string { + const randomBytes = crypto.randomBytes(8).toString("hex"); + return `shared-${randomBytes.slice(0, 8)}-${randomBytes.slice(8)}`; +} + +/** + * Link multiple provider identities to share a single Claude session + */ +export async function identityLinkCommand( + opts: IdentityLinkOpts, + runtime: RuntimeEnv, +): Promise { + const { whatsapp, telegram, twilio, name } = opts; + + // Validate that at least two providers are specified + const providers = [whatsapp, telegram, twilio].filter(Boolean); + if (providers.length < 2) { + runtime.error( + danger( + "At least two provider identities must be specified (--whatsapp, --telegram, or --twilio)", + ), + ); + runtime.exit(1); + return; + } + + // Validate formats + if (whatsapp && !isValidE164(whatsapp)) { + runtime.error( + danger( + `Invalid WhatsApp number format: ${whatsapp}. Must be E.164 format (e.g., +1234567890)`, + ), + ); + runtime.exit(1); + return; + } + + if (telegram && !isValidTelegram(telegram)) { + runtime.error( + danger( + `Invalid Telegram format: ${telegram}. Must be @username or numeric user ID`, + ), + ); + runtime.exit(1); + return; + } + + if (twilio && !isValidE164(twilio)) { + runtime.error( + danger( + `Invalid Twilio number format: ${twilio}. Must be E.164 format (e.g., +1234567890)`, + ), + ); + runtime.exit(1); + return; + } + + // Create the identity mapping + const sharedId = generateSharedId(); + const mapping: IdentityMapping = { + id: sharedId, + name, + identities: { + whatsapp, + telegram, + twilio, + }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + try { + await setMapping(mapping); + runtime.log( + success(`✓ Identity mapping created with shared ID: ${chalk.cyan(sharedId)}`), + ); + runtime.log(""); + runtime.log(info("Linked identities:")); + if (whatsapp) runtime.log(` WhatsApp: ${whatsapp}`); + if (telegram) runtime.log(` Telegram: ${telegram}`); + if (twilio) runtime.log(` Twilio: ${twilio}`); + if (name) runtime.log(` Name: ${name}`); + runtime.log(""); + runtime.log( + info( + "Messages from any of these identities will now share the same Claude session.", + ), + ); + } catch (err) { + runtime.error(danger(`Failed to create identity mapping: ${String(err)}`)); + runtime.exit(1); + } +} + +/** + * List all identity mappings + */ +export async function identityListCommand( + opts: IdentityListOpts, + runtime: RuntimeEnv, +): Promise { + try { + const mappings = await listMappings(); + + if (opts.json) { + console.log(JSON.stringify(mappings, null, 2)); + return; + } + + if (mappings.length === 0) { + runtime.log( + info( + "No identity mappings found. Use 'warelay identity link' to create one.", + ), + ); + return; + } + + runtime.log(chalk.bold.cyan(`\nIdentity Mappings (${mappings.length}):\n`)); + + for (const mapping of mappings) { + runtime.log(chalk.bold(` ${mapping.id}`)); + if (mapping.name) { + runtime.log(` Name: ${chalk.white(mapping.name)}`); + } + if (mapping.identities.whatsapp) { + runtime.log( + ` WhatsApp: ${chalk.green(mapping.identities.whatsapp)}`, + ); + } + if (mapping.identities.telegram) { + runtime.log( + ` Telegram: ${chalk.blue(mapping.identities.telegram)}`, + ); + } + if (mapping.identities.twilio) { + runtime.log(` Twilio: ${chalk.yellow(mapping.identities.twilio)}`); + } + runtime.log( + ` Created: ${chalk.gray(new Date(mapping.createdAt).toLocaleString())}`, + ); + if (mapping.updatedAt !== mapping.createdAt) { + runtime.log( + ` Updated: ${chalk.gray(new Date(mapping.updatedAt).toLocaleString())}`, + ); + } + runtime.log(""); + } + } catch (err) { + runtime.error(danger(`Failed to list identity mappings: ${String(err)}`)); + runtime.exit(1); + } +} + +/** + * Show details of a specific identity mapping + */ +export async function identityShowCommand( + opts: IdentityShowOpts, + runtime: RuntimeEnv, +): Promise { + try { + const mapping = await getMapping(opts.id); + + if (!mapping) { + runtime.error(danger(`Identity mapping not found: ${opts.id}`)); + runtime.exit(1); + return; + } + + if (opts.json) { + console.log(JSON.stringify(mapping, null, 2)); + return; + } + + runtime.log(chalk.bold.cyan(`\nIdentity Mapping: ${mapping.id}\n`)); + if (mapping.name) { + runtime.log(` Name: ${chalk.white(mapping.name)}`); + } + runtime.log(chalk.bold(" Linked Identities:")); + if (mapping.identities.whatsapp) { + runtime.log(` WhatsApp: ${chalk.green(mapping.identities.whatsapp)}`); + } + if (mapping.identities.telegram) { + runtime.log(` Telegram: ${chalk.blue(mapping.identities.telegram)}`); + } + if (mapping.identities.twilio) { + runtime.log(` Twilio: ${chalk.yellow(mapping.identities.twilio)}`); + } + runtime.log(""); + runtime.log( + ` Created: ${chalk.gray(new Date(mapping.createdAt).toLocaleString())}`, + ); + if (mapping.updatedAt !== mapping.createdAt) { + runtime.log( + ` Updated: ${chalk.gray(new Date(mapping.updatedAt).toLocaleString())}`, + ); + } + runtime.log(""); + } catch (err) { + runtime.error( + danger(`Failed to show identity mapping: ${String(err)}`), + ); + runtime.exit(1); + } +} + +/** + * Unlink an identity mapping + */ +export async function identityUnlinkCommand( + opts: IdentityUnlinkOpts, + runtime: RuntimeEnv, +): Promise { + try { + // First check if the mapping exists + const mapping = await getMapping(opts.id); + if (!mapping) { + runtime.error(danger(`Identity mapping not found: ${opts.id}`)); + runtime.exit(1); + return; + } + + // Show what will be unlinked + runtime.log(""); + runtime.log(warn(`Unlinking identity mapping: ${chalk.bold(opts.id)}`)); + if (mapping.name) { + runtime.log(` Name: ${mapping.name}`); + } + if (mapping.identities.whatsapp) { + runtime.log(` WhatsApp: ${mapping.identities.whatsapp}`); + } + if (mapping.identities.telegram) { + runtime.log(` Telegram: ${mapping.identities.telegram}`); + } + if (mapping.identities.twilio) { + runtime.log(` Twilio: ${mapping.identities.twilio}`); + } + runtime.log(""); + runtime.log( + warn( + "After unlinking, each provider will have its own separate Claude session.", + ), + ); + runtime.log(""); + + // Delete the mapping + const deleted = await deleteMapping(opts.id); + + if (deleted) { + runtime.log(success(`✓ Identity mapping ${opts.id} has been unlinked.`)); + } else { + runtime.error(danger(`Failed to unlink identity mapping: ${opts.id}`)); + runtime.exit(1); + } + } catch (err) { + runtime.error( + danger(`Failed to unlink identity mapping: ${String(err)}`), + ); + runtime.exit(1); + } +} diff --git a/src/config/sessions.ts b/src/config/sessions.ts index 81a068c71..aa36f4f4d 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -5,6 +5,7 @@ import path from "node:path"; import JSON5 from "json5"; import type { MsgContext } from "../auto-reply/templating.js"; import { CONFIG_DIR, normalizeE164 } from "../utils.js"; +import { normalizeSessionId } from "../identity/normalize.js"; export type SessionScope = "per-sender" | "global"; @@ -55,16 +56,59 @@ export async function saveSessionStore( ); } +/** + * Detect provider from message context. + */ +function detectProvider(from: string): "whatsapp" | "telegram" | "twilio" { + // Telegram format: "telegram:123456789" or "@username" + if (from.startsWith("telegram:") || from.startsWith("@")) { + return "telegram"; + } + // WhatsApp/Twilio use E.164 phone numbers + // Default to whatsapp for phone numbers + return "whatsapp"; +} + +/** + * Extract raw ID from message context based on provider. + */ +function extractRawId(from: string, provider: "whatsapp" | "telegram" | "twilio"): string { + if (provider === "telegram") { + if (from.startsWith("telegram:")) { + return from.slice("telegram:".length); + } + if (from.startsWith("@")) { + return from; // Keep @ for usernames + } + return from; + } + // WhatsApp/Twilio: use normalized E.164 + return normalizeE164(from); +} + // Decide which session bucket to use (per-sender vs global). -export function deriveSessionKey(scope: SessionScope, ctx: MsgContext) { +// Now supports identity mapping for cross-provider session sharing. +export async function deriveSessionKey(scope: SessionScope, ctx: MsgContext): Promise { if (scope === "global") return "global"; - const from = ctx.From ? normalizeE164(ctx.From) : ""; - // Preserve group conversations as distinct buckets + const from = ctx.From ? ctx.From : ""; + + // Preserve group conversations as distinct buckets (no identity mapping for groups) if (typeof ctx.From === "string" && ctx.From.includes("@g.us")) { return `group:${ctx.From}`; } if (typeof ctx.From === "string" && ctx.From.startsWith("group:")) { return ctx.From; } - return from || "unknown"; + + if (!from) return "unknown"; + + // Detect provider and extract raw ID + const provider = detectProvider(from); + const rawId = extractRawId(from, provider); + + if (!rawId) return "unknown"; + + // Use identity normalization to get shared session ID if mapped + const normalizedId = await normalizeSessionId(provider, rawId); + return normalizedId; } diff --git a/src/identity/index.ts b/src/identity/index.ts new file mode 100644 index 000000000..be6574053 --- /dev/null +++ b/src/identity/index.ts @@ -0,0 +1,15 @@ +/** + * Identity mapping module for cross-provider session sharing. + * + * This module allows linking multiple provider identities (e.g., WhatsApp phone + * number and Telegram user ID) to share a single Claude conversation session. + * + * Usage: + * 1. Link identities: `warelay identity link --whatsapp +1234 --telegram 5678 --name "John"` + * 2. Session normalization happens automatically in auto-reply + * 3. Unlink if needed: `warelay identity unlink ` + */ + +export * from "./types.js"; +export * from "./storage.js"; +export * from "./normalize.js"; diff --git a/src/identity/normalize.test.ts b/src/identity/normalize.test.ts new file mode 100644 index 000000000..acfe552d7 --- /dev/null +++ b/src/identity/normalize.test.ts @@ -0,0 +1,131 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { normalizeSessionId, denormalizeSessionId } from "./normalize.js"; +import * as storage from "./storage.js"; + +vi.mock("./storage.js"); + +describe("normalizeSessionId", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns telegram-prefixed ID when no mapping exists for Telegram", async () => { + vi.mocked(storage.findMappingByIdentity).mockResolvedValue(null); + + const result = await normalizeSessionId("telegram", "123456789"); + + expect(result).toBe("telegram:123456789"); + expect(storage.findMappingByIdentity).toHaveBeenCalledWith( + "telegram", + "123456789", + ); + }); + + it("returns phone number directly when no mapping exists for WhatsApp", async () => { + vi.mocked(storage.findMappingByIdentity).mockResolvedValue(null); + + const result = await normalizeSessionId("whatsapp", "+1234567890"); + + expect(result).toBe("+1234567890"); + expect(storage.findMappingByIdentity).toHaveBeenCalledWith( + "whatsapp", + "+1234567890", + ); + }); + + it("returns phone number directly when no mapping exists for Twilio", async () => { + vi.mocked(storage.findMappingByIdentity).mockResolvedValue(null); + + const result = await normalizeSessionId("twilio", "+1234567890"); + + expect(result).toBe("+1234567890"); + expect(storage.findMappingByIdentity).toHaveBeenCalledWith( + "twilio", + "+1234567890", + ); + }); + + it("returns shared ID when mapping exists", async () => { + const mockMapping = { + id: "shared-abc-123", + identities: { + telegram: "123456789", + whatsapp: "+1234567890", + }, + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + }; + vi.mocked(storage.findMappingByIdentity).mockResolvedValue(mockMapping); + + const result = await normalizeSessionId("telegram", "123456789"); + + expect(result).toBe("shared-abc-123"); + expect(storage.findMappingByIdentity).toHaveBeenCalledWith( + "telegram", + "123456789", + ); + }); + + it("returns same shared ID for different providers with mapping", async () => { + const mockMapping = { + id: "shared-xyz-456", + identities: { + telegram: "987654321", + whatsapp: "+9876543210", + }, + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + }; + + // First call for Telegram + vi.mocked(storage.findMappingByIdentity).mockResolvedValueOnce( + mockMapping, + ); + const telegramResult = await normalizeSessionId("telegram", "987654321"); + + // Second call for WhatsApp + vi.mocked(storage.findMappingByIdentity).mockResolvedValueOnce( + mockMapping, + ); + const whatsappResult = await normalizeSessionId( + "whatsapp", + "+9876543210", + ); + + expect(telegramResult).toBe("shared-xyz-456"); + expect(whatsappResult).toBe("shared-xyz-456"); + expect(telegramResult).toBe(whatsappResult); + }); +}); + +describe("denormalizeSessionId", () => { + it("extracts Telegram ID from telegram-prefixed session ID", () => { + const result = denormalizeSessionId("telegram", "telegram:123456789"); + expect(result).toBe("123456789"); + }); + + it("returns phone number for WhatsApp when session ID looks like phone", () => { + const result = denormalizeSessionId("whatsapp", "+1234567890"); + expect(result).toBe("+1234567890"); + }); + + it("returns phone number for Twilio when session ID looks like phone", () => { + const result = denormalizeSessionId("twilio", "+9876543210"); + expect(result).toBe("+9876543210"); + }); + + it("returns null for shared IDs (cannot denormalize without lookup)", () => { + const result = denormalizeSessionId("telegram", "shared-abc-123"); + expect(result).toBeNull(); + }); + + it("returns null when Telegram ID doesn't have telegram prefix", () => { + const result = denormalizeSessionId("telegram", "123456789"); + expect(result).toBeNull(); + }); + + it("returns null when WhatsApp ID doesn't look like phone number", () => { + const result = denormalizeSessionId("whatsapp", "shared-id-xyz"); + expect(result).toBeNull(); + }); +}); diff --git a/src/identity/normalize.ts b/src/identity/normalize.ts new file mode 100644 index 000000000..54b85468e --- /dev/null +++ b/src/identity/normalize.ts @@ -0,0 +1,59 @@ +import { findMappingByIdentity } from "./storage.js"; + +/** + * Normalize a session ID based on identity mappings. + * + * If the provider identity is mapped to a shared identity, returns the shared ID. + * Otherwise, returns the provider-specific session ID. + * + * @param provider - The messaging provider + * @param rawId - The raw provider-specific identifier (phone number, user ID, etc.) + * @returns Normalized session ID for Claude conversation storage + */ +export async function normalizeSessionId( + provider: "whatsapp" | "telegram" | "twilio", + rawId: string, +): Promise { + // Try to find a mapping for this identity + const mapping = await findMappingByIdentity(provider, rawId); + + if (mapping) { + // Use the shared identity ID + return mapping.id; + } + + // No mapping found, use provider-specific format + // WhatsApp and Twilio use phone numbers directly + // Telegram prefixes with "telegram:" + return provider === "telegram" ? `telegram:${rawId}` : rawId; +} + +/** + * Get the original provider-specific ID from a normalized session ID. + * + * This is useful for displaying the original identity to users. + * + * @param provider - The messaging provider + * @param normalizedId - The normalized session ID + * @returns The original provider-specific ID, or null if not found + */ +export function denormalizeSessionId( + provider: "whatsapp" | "telegram" | "twilio", + normalizedId: string, +): string | null { + // If it's a provider-prefixed ID, extract the raw ID + if (provider === "telegram" && normalizedId.startsWith("telegram:")) { + return normalizedId.slice("telegram:".length); + } + + // For WhatsApp/Twilio, if it looks like a phone number, return it + if ( + (provider === "whatsapp" || provider === "twilio") && + normalizedId.startsWith("+") + ) { + return normalizedId; + } + + // Otherwise it's probably a shared ID - we can't denormalize without lookup + return null; +} diff --git a/src/identity/storage.test.ts b/src/identity/storage.test.ts new file mode 100644 index 000000000..0004a2e36 --- /dev/null +++ b/src/identity/storage.test.ts @@ -0,0 +1,386 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + deleteMapping, + findMappingByIdentity, + getMapping, + listMappings, + loadIdentityMap, + saveIdentityMap, + setMapping, +} from "./storage.js"; +import type { IdentityMap, IdentityMapping } from "./types.js"; + +vi.mock("node:fs/promises"); +vi.mock("../utils.js", () => ({ + CONFIG_DIR: "/mock/config", +})); + +describe("loadIdentityMap", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("loads identity map from disk", async () => { + const mockMap: IdentityMap = { + version: 1, + mappings: { + "test-id": { + id: "test-id", + identities: { telegram: "123", whatsapp: "+1234" }, + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + }, + }, + }; + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockMap)); + + const result = await loadIdentityMap(); + + expect(result).toEqual(mockMap); + expect(fs.readFile).toHaveBeenCalledWith( + "/mock/config/identity-map.json", + "utf-8", + ); + }); + + it("returns empty map when file does not exist", async () => { + const error = new Error("ENOENT") as NodeJS.ErrnoException; + error.code = "ENOENT"; + vi.mocked(fs.readFile).mockRejectedValue(error); + + const result = await loadIdentityMap(); + + expect(result).toEqual({ + version: 1, + mappings: {}, + }); + }); + + it("throws error for unsupported version", async () => { + const mockMap = { + version: 999, + mappings: {}, + }; + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockMap)); + + await expect(loadIdentityMap()).rejects.toThrow( + "Unsupported identity map version: 999", + ); + }); + + it("throws error for other file read errors", async () => { + const error = new Error("Permission denied"); + vi.mocked(fs.readFile).mockRejectedValue(error); + + await expect(loadIdentityMap()).rejects.toThrow("Permission denied"); + }); +}); + +describe("saveIdentityMap", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("saves identity map to disk", async () => { + const mockMap: IdentityMap = { + version: 1, + mappings: { + "test-id": { + id: "test-id", + identities: { telegram: "123" }, + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + }, + }, + }; + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + await saveIdentityMap(mockMap); + + expect(fs.mkdir).toHaveBeenCalledWith("/mock/config", { recursive: true }); + expect(fs.writeFile).toHaveBeenCalledWith( + "/mock/config/identity-map.json", + JSON.stringify(mockMap, null, 2), + "utf-8", + ); + }); +}); + +describe("getMapping", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns mapping when it exists", async () => { + const mockMapping: IdentityMapping = { + id: "test-id", + identities: { telegram: "123" }, + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + }; + const mockMap: IdentityMap = { + version: 1, + mappings: { "test-id": mockMapping }, + }; + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockMap)); + + const result = await getMapping("test-id"); + + expect(result).toEqual(mockMapping); + }); + + it("returns null when mapping does not exist", async () => { + const mockMap: IdentityMap = { + version: 1, + mappings: {}, + }; + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockMap)); + + const result = await getMapping("non-existent"); + + expect(result).toBeNull(); + }); +}); + +describe("setMapping", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2024-01-15T12:00:00Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("creates new mapping with timestamps", async () => { + const mockMap: IdentityMap = { version: 1, mappings: {} }; + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockMap)); + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + const newMapping: IdentityMapping = { + id: "new-id", + identities: { telegram: "456" }, + createdAt: "", + updatedAt: "", + }; + + await setMapping(newMapping); + + expect(fs.writeFile).toHaveBeenCalled(); + const savedData = JSON.parse( + vi.mocked(fs.writeFile).mock.calls[0][1] as string, + ); + expect(savedData.mappings["new-id"]).toMatchObject({ + id: "new-id", + identities: { telegram: "456" }, + createdAt: "2024-01-15T12:00:00.000Z", + updatedAt: "2024-01-15T12:00:00.000Z", + }); + }); + + it("updates existing mapping with new timestamp", async () => { + const existingMapping: IdentityMapping = { + id: "existing-id", + identities: { telegram: "789" }, + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + }; + const mockMap: IdentityMap = { + version: 1, + mappings: { "existing-id": existingMapping }, + }; + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockMap)); + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + const updatedMapping: IdentityMapping = { + ...existingMapping, + identities: { telegram: "789", whatsapp: "+9999" }, + }; + + await setMapping(updatedMapping); + + const savedData = JSON.parse( + vi.mocked(fs.writeFile).mock.calls[0][1] as string, + ); + expect(savedData.mappings["existing-id"]).toMatchObject({ + id: "existing-id", + identities: { telegram: "789", whatsapp: "+9999" }, + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-15T12:00:00.000Z", + }); + }); +}); + +describe("deleteMapping", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("deletes mapping and returns true", async () => { + const mockMap: IdentityMap = { + version: 1, + mappings: { + "to-delete": { + id: "to-delete", + identities: { telegram: "123" }, + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + }, + }, + }; + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockMap)); + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + const result = await deleteMapping("to-delete"); + + expect(result).toBe(true); + const savedData = JSON.parse( + vi.mocked(fs.writeFile).mock.calls[0][1] as string, + ); + expect(savedData.mappings["to-delete"]).toBeUndefined(); + }); + + it("returns false when mapping does not exist", async () => { + const mockMap: IdentityMap = { version: 1, mappings: {} }; + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockMap)); + + const result = await deleteMapping("non-existent"); + + expect(result).toBe(false); + expect(fs.writeFile).not.toHaveBeenCalled(); + }); +}); + +describe("listMappings", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns all mappings as array", async () => { + const mapping1: IdentityMapping = { + id: "id-1", + identities: { telegram: "111" }, + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + }; + const mapping2: IdentityMapping = { + id: "id-2", + identities: { whatsapp: "+2222" }, + createdAt: "2024-01-02T00:00:00Z", + updatedAt: "2024-01-02T00:00:00Z", + }; + const mockMap: IdentityMap = { + version: 1, + mappings: { "id-1": mapping1, "id-2": mapping2 }, + }; + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockMap)); + + const result = await listMappings(); + + expect(result).toEqual([mapping1, mapping2]); + }); + + it("returns empty array when no mappings exist", async () => { + const mockMap: IdentityMap = { version: 1, mappings: {} }; + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockMap)); + + const result = await listMappings(); + + expect(result).toEqual([]); + }); +}); + +describe("findMappingByIdentity", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("finds mapping by Telegram identity", async () => { + const mockMapping: IdentityMapping = { + id: "test-id", + identities: { telegram: "123456", whatsapp: "+1234" }, + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + }; + const mockMap: IdentityMap = { + version: 1, + mappings: { "test-id": mockMapping }, + }; + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockMap)); + + const result = await findMappingByIdentity("telegram", "123456"); + + expect(result).toEqual(mockMapping); + }); + + it("finds mapping by WhatsApp identity", async () => { + const mockMapping: IdentityMapping = { + id: "test-id", + identities: { telegram: "123456", whatsapp: "+1234" }, + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + }; + const mockMap: IdentityMap = { + version: 1, + mappings: { "test-id": mockMapping }, + }; + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockMap)); + + const result = await findMappingByIdentity("whatsapp", "+1234"); + + expect(result).toEqual(mockMapping); + }); + + it("finds mapping by Twilio identity", async () => { + const mockMapping: IdentityMapping = { + id: "test-id", + identities: { twilio: "+5678", whatsapp: "+1234" }, + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + }; + const mockMap: IdentityMap = { + version: 1, + mappings: { "test-id": mockMapping }, + }; + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockMap)); + + const result = await findMappingByIdentity("twilio", "+5678"); + + expect(result).toEqual(mockMapping); + }); + + it("returns null when identity not found", async () => { + const mockMap: IdentityMap = { + version: 1, + mappings: { + "test-id": { + id: "test-id", + identities: { telegram: "999" }, + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + }, + }, + }; + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockMap)); + + const result = await findMappingByIdentity("telegram", "123"); + + expect(result).toBeNull(); + }); + + it("returns null when no mappings exist", async () => { + const mockMap: IdentityMap = { version: 1, mappings: {} }; + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockMap)); + + const result = await findMappingByIdentity("whatsapp", "+1234"); + + expect(result).toBeNull(); + }); +}); diff --git a/src/identity/storage.ts b/src/identity/storage.ts new file mode 100644 index 000000000..2d90aeaaa --- /dev/null +++ b/src/identity/storage.ts @@ -0,0 +1,127 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { CONFIG_DIR } from "../utils.js"; +import type { IdentityMap, IdentityMapping } from "./types.js"; + +const IDENTITY_MAP_FILE = "identity-map.json"; +const CURRENT_VERSION = 1; + +/** + * Get the path to the identity map file. + */ +function getIdentityMapPath(): string { + return path.join(CONFIG_DIR, IDENTITY_MAP_FILE); +} + +/** + * Load the identity map from disk. + * Returns empty map if file doesn't exist. + */ +export async function loadIdentityMap(): Promise { + const filePath = getIdentityMapPath(); + + try { + const content = await fs.readFile(filePath, "utf-8"); + const data = JSON.parse(content) as IdentityMap; + + // Validate version + if (data.version !== CURRENT_VERSION) { + throw new Error( + `Unsupported identity map version: ${data.version} (expected ${CURRENT_VERSION})`, + ); + } + + return data; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + // File doesn't exist, return empty map + return { + version: CURRENT_VERSION, + mappings: {}, + }; + } + throw err; + } +} + +/** + * Save the identity map to disk. + */ +export async function saveIdentityMap(map: IdentityMap): Promise { + const filePath = getIdentityMapPath(); + const dir = path.dirname(filePath); + + // Ensure directory exists + await fs.mkdir(dir, { recursive: true }); + + // Write atomically + const content = JSON.stringify(map, null, 2); + await fs.writeFile(filePath, content, "utf-8"); +} + +/** + * Get a mapping by shared ID. + */ +export async function getMapping( + sharedId: string, +): Promise { + const map = await loadIdentityMap(); + return map.mappings[sharedId] ?? null; +} + +/** + * Create or update a mapping. + */ +export async function setMapping(mapping: IdentityMapping): Promise { + const map = await loadIdentityMap(); + + // Update timestamp + mapping.updatedAt = new Date().toISOString(); + if (!mapping.createdAt) { + mapping.createdAt = mapping.updatedAt; + } + + map.mappings[mapping.id] = mapping; + await saveIdentityMap(map); +} + +/** + * Delete a mapping by shared ID. + */ +export async function deleteMapping(sharedId: string): Promise { + const map = await loadIdentityMap(); + + if (!map.mappings[sharedId]) { + return false; + } + + delete map.mappings[sharedId]; + await saveIdentityMap(map); + return true; +} + +/** + * List all mappings. + */ +export async function listMappings(): Promise { + const map = await loadIdentityMap(); + return Object.values(map.mappings); +} + +/** + * Find a mapping by provider identity. + */ +export async function findMappingByIdentity( + provider: "whatsapp" | "telegram" | "twilio", + identity: string, +): Promise { + const map = await loadIdentityMap(); + + for (const mapping of Object.values(map.mappings)) { + if (mapping.identities[provider] === identity) { + return mapping; + } + } + + return null; +} diff --git a/src/identity/types.ts b/src/identity/types.ts new file mode 100644 index 000000000..12191239a --- /dev/null +++ b/src/identity/types.ts @@ -0,0 +1,35 @@ +/** + * Identity mapping types for cross-provider session sharing. + * + * Allows linking multiple provider identities (e.g., WhatsApp phone number + * and Telegram user ID) to a single shared Claude session ID. + */ + +export type ProviderIdentity = { + /** WhatsApp phone number (e.g., "+1234567890") */ + whatsapp?: string; + /** Telegram user ID (e.g., "123456789") */ + telegram?: string; + /** Twilio phone number (e.g., "+1234567890") */ + twilio?: string; +}; + +export type IdentityMapping = { + /** Unique identifier for this shared identity */ + id: string; + /** Optional human-readable name */ + name?: string; + /** Provider-specific identifiers */ + identities: ProviderIdentity; + /** When this mapping was created */ + createdAt: string; + /** When this mapping was last updated */ + updatedAt: string; +}; + +export type IdentityMap = { + /** Version for future schema migrations */ + version: number; + /** Map of shared ID to identity mapping */ + mappings: Record; +}; diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index 2eb4d5dc7..b04480826 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -217,7 +217,7 @@ export async function runWebHeartbeatOnce(opts: { }; await saveSessionStore(storePath, store); } - const sessionSnapshot = getSessionSnapshot(cfg, to, true); + const sessionSnapshot = await getSessionSnapshot(cfg, to, true); if (verbose) { heartbeatLogger.info( { @@ -416,14 +416,14 @@ export function resolveHeartbeatRecipients( return { recipients: allowFrom, source: "allowFrom" as const }; } -function getSessionSnapshot( +async function getSessionSnapshot( cfg: ReturnType, from: string, isHeartbeat = false, ) { const sessionCfg = cfg.inbound?.reply?.session; const scope = sessionCfg?.scope ?? "per-sender"; - const key = deriveSessionKey(scope, { From: from, To: "", Body: "" }); + const key = await deriveSessionKey(scope, { From: from, To: "", Body: "" }); const store = loadSessionStore(resolveStorePath(sessionCfg?.store)); const entry = store[key]; const idleMinutes = Math.max( @@ -1071,7 +1071,7 @@ export async function monitorWebProvider( console.log(success("heartbeat: skipped (no recent inbound)")); return; } - const snapshot = getSessionSnapshot(cfg, fallbackTo, true); + const snapshot = await getSessionSnapshot(cfg, fallbackTo, true); if (!snapshot.entry) { heartbeatLogger.info( { connectionId, to: fallbackTo, reason: "no-session-for-fallback" }, @@ -1113,7 +1113,7 @@ export async function monitorWebProvider( } try { - const snapshot = getSessionSnapshot(cfg, lastInboundMsg.from); + const snapshot = await getSessionSnapshot(cfg, lastInboundMsg.from); if (isVerbose()) { heartbeatLogger.info( {