From 8f1963fedb4b5ea3ddf70e3570a1dd980fba6309 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Fri, 27 Feb 2026 14:14:47 +0000 Subject: [PATCH] user-scoped channel sessions, whatsapp disconnect cleanup, browser relay token fix Channel session IDs now include userId (channel-{provider}-{userId}-{contextId}) to prevent cross-user contamination in multi-user setups. WhatsApp disconnect properly logs out and clears cached auth. Browser relay uses server-derived token directly. Co-Authored-By: Claude Opus 4.6 --- .../browser-relay/background-utils.js | 4 +-- src/extensions/browser-relay/options.js | 5 ++-- src/servers/channels/discord/handler.ts | 8 +++--- src/servers/channels/send-and-await.ts | 27 +++++++++++-------- src/servers/channels/telegram/handler.ts | 8 +++--- src/servers/channels/whatsapp/bot.ts | 13 +++++++++ src/servers/channels/whatsapp/handler.ts | 8 +++--- 7 files changed, 45 insertions(+), 28 deletions(-) diff --git a/src/extensions/browser-relay/background-utils.js b/src/extensions/browser-relay/background-utils.js index 629f67f8..e3c93624 100644 --- a/src/extensions/browser-relay/background-utils.js +++ b/src/extensions/browser-relay/background-utils.js @@ -38,8 +38,8 @@ export async function buildRelayWsUrl(host, port, gatewayToken) { "Missing relay token in extension settings (chrome.storage.local.relayToken)", ); } - const relayToken = await deriveRelayToken(token, port); - return `ws://${host}:${port}/extension?token=${encodeURIComponent(relayToken)}`; + // Token is already derived server-side — use it directly + return `ws://${host}:${port}/extension?token=${encodeURIComponent(token)}`; } export function isRetryableReconnectError(err) { diff --git a/src/extensions/browser-relay/options.js b/src/extensions/browser-relay/options.js index 0a7c838d..e14a9028 100644 --- a/src/extensions/browser-relay/options.js +++ b/src/extensions/browser-relay/options.js @@ -1,4 +1,3 @@ -import { deriveRelayToken } from './background-utils.js' import { classifyRelayCheckException, classifyRelayCheckResponse } from './options-validation.js' const DEFAULT_PORT = 18792 @@ -32,11 +31,11 @@ async function checkRelayReachable(host, port, token) { return } try { - const relayToken = await deriveRelayToken(trimmedToken, port) + // Token is already derived server-side — use it directly const res = await chrome.runtime.sendMessage({ type: 'relayCheck', url, - token: relayToken, + token: trimmedToken, }) const result = classifyRelayCheckResponse(res, host, port) if (result.action === 'throw') throw new Error(result.error) diff --git a/src/servers/channels/discord/handler.ts b/src/servers/channels/discord/handler.ts index 726fedca..8632ad50 100644 --- a/src/servers/channels/discord/handler.ts +++ b/src/servers/channels/discord/handler.ts @@ -153,7 +153,7 @@ async function handleCommand(ctx: CommandContext): Promise { await channel.send('No models available.'); return true; } - const current = getSessionModel('discord', discordId); + const current = getSessionModel('discord', ctx.userId, discordId); const grouped = new Map(); for (const m of models) { const list = grouped.get(m.provider) ?? []; @@ -170,7 +170,7 @@ async function handleCommand(ctx: CommandContext): Promise { } if (lower === '!model') { - const current = getSessionModel('discord', discordId); + const current = getSessionModel('discord', ctx.userId, discordId); await channel.send(current ? `Current model: **${current}**` : 'No active session yet — the default model will be used on your next message.'); return true; } @@ -178,7 +178,7 @@ async function handleCommand(ctx: CommandContext): Promise { if (lower.startsWith('!model ')) { const requested = content.slice('!model '.length).trim(); if (!requested) { - const current = getSessionModel('discord', discordId); + const current = getSessionModel('discord', ctx.userId, discordId); await channel.send(current ? `Current model: **${current}**` : 'No active session yet.'); return true; } @@ -188,7 +188,7 @@ async function handleCommand(ctx: CommandContext): Promise { await channel.send(`Model not found: \`${requested}\`\nUse \`!models\` to see available models.`); return true; } - setSessionModel('discord', discordId, match.id); + setSessionModel('discord', ctx.userId, discordId, match.id); await channel.send(`Model switched to **${match.id}**. The new model will be used on your next message.`); return true; } diff --git a/src/servers/channels/send-and-await.ts b/src/servers/channels/send-and-await.ts index b0f12c0c..2c26b62c 100644 --- a/src/servers/channels/send-and-await.ts +++ b/src/servers/channels/send-and-await.ts @@ -39,6 +39,10 @@ const sessionCallbacks = new Map(); // Channel model overrides — survive session eviction/recreation const channelModelOverrides = new Map(); +function buildSessionId(context: string, userId: number, contextId: string): string { + return `channel-${context}-${userId}-${contextId}`; +} + async function getUserDefaultModel(userId: number): Promise { try { const settings = await getUserSettings(userId); @@ -49,14 +53,14 @@ async function getUserDefaultModel(userId: number): Promise { } } -export function getSessionModel(context: string, contextId: string): string | null { - const sessionId = `channel-${context}-${contextId}`; +export function getSessionModel(context: string, userId: number, contextId: string): string | null { + const sessionId = buildSessionId(context, userId, contextId); const session = sessionManager.getSession(sessionId); return session?.model ?? channelModelOverrides.get(sessionId) ?? null; } -export function setSessionModel(context: string, contextId: string, model: string): void { - const sessionId = `channel-${context}-${contextId}`; +export function setSessionModel(context: string, userId: number, contextId: string, model: string): void { + const sessionId = buildSessionId(context, userId, contextId); // Store override independently of session — survives idle eviction channelModelOverrides.set(sessionId, model); @@ -76,8 +80,8 @@ export function setSessionModel(context: string, contextId: string, model: strin } export async function sendAndAwait(params: SendAndAwaitParams): Promise { - const { context, contextId } = params; - const sessionId = `channel-${context}-${contextId}`; + const { userId, context, contextId } = params; + const sessionId = buildSessionId(context, userId, contextId); // Serialize per session — if two messages arrive at once, second waits for first const existing = sessionLocks.get(sessionId) ?? Promise.resolve(); @@ -112,19 +116,20 @@ async function doSend(sessionId: string, params: SendAndAwaitParams): Promise channel override > existing session model > user default > system default + // Resolve model: explicit param > !model override > user default > existing session > system default const existingSession = sessionManager.getSession(sessionId); - let model = params.model ?? channelModelOverrides.get(sessionId) ?? existingSession?.model; + const override = channelModelOverrides.get(sessionId); + let model = params.model ?? override; if (!model) { const userDefault = await getUserDefaultModel(userId); - model = userDefault ?? DEFAULT_MODEL; + model = userDefault ?? existingSession?.model ?? DEFAULT_MODEL; } const session = sessionManager.getOrCreate(sessionId, email, cwd, model, null, context, contextId); session.model = model; session.meta.model = model; session.userId = userId; - logger.info('Channel doSend', { sessionId, model, hasProcess: !!session.piProcess }); + logger.info('Channel doSend', { sessionId, model, hasProcess: !!session.piProcess, userId, email }); return new Promise((resolve, reject) => { let resultText = ''; @@ -296,7 +301,7 @@ async function doSend(sessionId: string, params: SendAndAwaitParams): Promise { await send('No models available.'); return true; } - const current = getSessionModel('telegram', telegramId); + const current = getSessionModel('telegram', ctx.userId, telegramId); const grouped = new Map(); for (const m of models) { const list = grouped.get(m.provider) ?? []; @@ -170,7 +170,7 @@ async function handleCommand(ctx: CommandContext): Promise { } if (lower === '!model') { - const current = getSessionModel('telegram', telegramId); + const current = getSessionModel('telegram', ctx.userId, telegramId); await send( current ? `Current model: *${current}*` @@ -182,7 +182,7 @@ async function handleCommand(ctx: CommandContext): Promise { if (lower.startsWith('!model ')) { const requested = content.slice('!model '.length).trim(); if (!requested) { - const current = getSessionModel('telegram', telegramId); + const current = getSessionModel('telegram', ctx.userId, telegramId); await send(current ? `Current model: *${current}*` : 'No active session yet.'); return true; } @@ -192,7 +192,7 @@ async function handleCommand(ctx: CommandContext): Promise { await send(`Model not found: \`${requested}\`\nUse \`!models\` to see available models.`); return true; } - setSessionModel('telegram', telegramId, match.id); + setSessionModel('telegram', ctx.userId, telegramId, match.id); await send(`Model switched to *${match.id}*. The new model will be used on your next message.`); return true; } diff --git a/src/servers/channels/whatsapp/bot.ts b/src/servers/channels/whatsapp/bot.ts index e550a2dd..7a2000d7 100644 --- a/src/servers/channels/whatsapp/bot.ts +++ b/src/servers/channels/whatsapp/bot.ts @@ -1,4 +1,5 @@ import { join } from 'path'; +import { rmSync } from 'node:fs'; import { Client, LocalAuth } from 'whatsapp-web.js'; import { getServerIntegration, upsertServerIntegration } from 'officerdb'; import { DATA_PATH } from '@@/data-path'; @@ -117,6 +118,18 @@ export async function startWhatsAppBotIfConfigured(): Promise { } export async function disconnectWhatsApp(): Promise { + if (client) { + try { + await client.logout(); + } catch { + // May fail if not authenticated + } + } await stopWhatsAppBot(); + + // Remove cached session so a new QR is shown on next connect + const authPath = join(DATA_PATH, '.wwebjs_auth'); + rmSync(authPath, { recursive: true, force: true }); + await upsertServerIntegration('whatsapp', {}, false); } diff --git a/src/servers/channels/whatsapp/handler.ts b/src/servers/channels/whatsapp/handler.ts index 8db5488b..b6cb40ea 100644 --- a/src/servers/channels/whatsapp/handler.ts +++ b/src/servers/channels/whatsapp/handler.ts @@ -154,7 +154,7 @@ async function handleCommand(ctx: CommandContext): Promise { await send('No models available.'); return true; } - const current = getSessionModel('whatsapp', whatsappId); + const current = getSessionModel('whatsapp', ctx.userId, whatsappId); const grouped = new Map(); for (const m of models) { const list = grouped.get(m.provider) ?? []; @@ -171,7 +171,7 @@ async function handleCommand(ctx: CommandContext): Promise { } if (lower === '!model') { - const current = getSessionModel('whatsapp', whatsappId); + const current = getSessionModel('whatsapp', ctx.userId, whatsappId); await send( current ? `Current model: *${current}*` @@ -183,7 +183,7 @@ async function handleCommand(ctx: CommandContext): Promise { if (lower.startsWith('!model ')) { const requested = content.slice('!model '.length).trim(); if (!requested) { - const current = getSessionModel('whatsapp', whatsappId); + const current = getSessionModel('whatsapp', ctx.userId, whatsappId); await send(current ? `Current model: *${current}*` : 'No active session yet.'); return true; } @@ -193,7 +193,7 @@ async function handleCommand(ctx: CommandContext): Promise { await send(`Model not found: \`${requested}\`\nUse \`!models\` to see available models.`); return true; } - setSessionModel('whatsapp', whatsappId, match.id); + setSessionModel('whatsapp', ctx.userId, whatsappId, match.id); await send(`Model switched to *${match.id}*. The new model will be used on your next message.`); return true; }