mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-02 05:31:02 +00:00
- fix: sanitize remote filenames with basename() and isolate uploads in UUID subdirs to prevent path traversal and collision (#2-4, #27) - fix: use crypto.randomInt() for pairing codes instead of Math.random() (#5) - fix: pass config.sessionScope instead of hardcoded 'user' (#6); add per-channel scope overrides via setChannelScope() for startAll (#7) - fix: removeSession now returns removed session IDs and persists when chatId is provided (#8) - fix: /clear only removes the cleared session from instructedSessions, not all sessions (#9) - fix: DingTalk @mention stripping now removes only the first mention instead of all mentions (#10) - fix: remove dead TELEGRAF_COMMANDS Set and its guard (#13) - fix: WeChat cursor saved after message processing, not before (#14) - fix: crash recovery uses time-window counting instead of resettable counter to prevent infinite restart loops (#17) - fix: call channel.disconnect() before exit on crash exhaustion (#18)
234 lines
6.6 KiB
TypeScript
234 lines
6.6 KiB
TypeScript
import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';
|
|
import type { SessionScope, SessionTarget } from './types.js';
|
|
import type { AcpBridge } from './AcpBridge.js';
|
|
|
|
interface PersistedEntry {
|
|
sessionId: string;
|
|
target: SessionTarget;
|
|
cwd: string;
|
|
}
|
|
|
|
export class SessionRouter {
|
|
private toSession: Map<string, string> = new Map(); // routing key → session ID
|
|
private toTarget: Map<string, SessionTarget> = new Map(); // session ID → target
|
|
private toCwd: Map<string, string> = new Map(); // session ID → cwd
|
|
|
|
private bridge: AcpBridge;
|
|
private defaultCwd: string;
|
|
private defaultScope: SessionScope;
|
|
private channelScopes: Map<string, SessionScope> = new Map();
|
|
private persistPath: string | undefined;
|
|
|
|
constructor(
|
|
bridge: AcpBridge,
|
|
defaultCwd: string,
|
|
scope: SessionScope = 'user',
|
|
persistPath?: string,
|
|
) {
|
|
this.bridge = bridge;
|
|
this.defaultCwd = defaultCwd;
|
|
this.defaultScope = scope;
|
|
this.persistPath = persistPath;
|
|
}
|
|
|
|
/** Replace the bridge instance (used after crash recovery restart). */
|
|
setBridge(bridge: AcpBridge): void {
|
|
this.bridge = bridge;
|
|
}
|
|
|
|
/** Set scope override for a specific channel. */
|
|
setChannelScope(channelName: string, scope: SessionScope): void {
|
|
this.channelScopes.set(channelName, scope);
|
|
}
|
|
|
|
private routingKey(
|
|
channelName: string,
|
|
senderId: string,
|
|
chatId: string,
|
|
threadId?: string,
|
|
): string {
|
|
const scope = this.channelScopes.get(channelName) || this.defaultScope;
|
|
switch (scope) {
|
|
case 'thread':
|
|
return `${channelName}:${threadId || chatId}`;
|
|
case 'single':
|
|
return `${channelName}:__single__`;
|
|
case 'user':
|
|
default:
|
|
return `${channelName}:${senderId}:${chatId}`;
|
|
}
|
|
}
|
|
|
|
async resolve(
|
|
channelName: string,
|
|
senderId: string,
|
|
chatId: string,
|
|
threadId?: string,
|
|
cwd?: string,
|
|
): Promise<string> {
|
|
const key = this.routingKey(channelName, senderId, chatId, threadId);
|
|
const existing = this.toSession.get(key);
|
|
if (existing) {
|
|
return existing;
|
|
}
|
|
|
|
const sessionCwd = cwd || this.defaultCwd;
|
|
const sessionId = await this.bridge.newSession(sessionCwd);
|
|
this.toSession.set(key, sessionId);
|
|
this.toTarget.set(sessionId, { channelName, senderId, chatId, threadId });
|
|
this.toCwd.set(sessionId, sessionCwd);
|
|
this.persist();
|
|
return sessionId;
|
|
}
|
|
|
|
getTarget(sessionId: string): SessionTarget | undefined {
|
|
return this.toTarget.get(sessionId);
|
|
}
|
|
|
|
hasSession(channelName: string, senderId: string, chatId?: string): boolean {
|
|
const key = chatId
|
|
? this.routingKey(channelName, senderId, chatId)
|
|
: `${channelName}:${senderId}`;
|
|
// If chatId is provided, do exact lookup; otherwise prefix-scan for any match
|
|
if (chatId) return this.toSession.has(key);
|
|
for (const k of this.toSession.keys()) {
|
|
if (k.startsWith(`${channelName}:${senderId}`)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Remove session(s) for the given sender. Returns the removed session IDs.
|
|
*/
|
|
removeSession(
|
|
channelName: string,
|
|
senderId: string,
|
|
chatId?: string,
|
|
): string[] {
|
|
const removedIds: string[] = [];
|
|
if (chatId) {
|
|
const key = this.routingKey(channelName, senderId, chatId);
|
|
const sessionId = this.deleteByKey(key);
|
|
if (sessionId) removedIds.push(sessionId);
|
|
} else {
|
|
// No chatId: remove all sessions for this sender on this channel
|
|
const prefix = `${channelName}:${senderId}`;
|
|
for (const k of [...this.toSession.keys()]) {
|
|
if (k.startsWith(prefix)) {
|
|
const sessionId = this.deleteByKey(k);
|
|
if (sessionId) removedIds.push(sessionId);
|
|
}
|
|
}
|
|
}
|
|
if (removedIds.length > 0) this.persist();
|
|
return removedIds;
|
|
}
|
|
|
|
private deleteByKey(key: string): string | null {
|
|
const sessionId = this.toSession.get(key);
|
|
if (!sessionId) return null;
|
|
this.toSession.delete(key);
|
|
this.toTarget.delete(sessionId);
|
|
this.toCwd.delete(sessionId);
|
|
return sessionId;
|
|
}
|
|
|
|
/** Get all session entries for crash recovery. */
|
|
getAll(): Array<{ key: string; sessionId: string; target: SessionTarget }> {
|
|
const entries: Array<{
|
|
key: string;
|
|
sessionId: string;
|
|
target: SessionTarget;
|
|
}> = [];
|
|
for (const [key, sessionId] of this.toSession) {
|
|
const target = this.toTarget.get(sessionId);
|
|
if (target) {
|
|
entries.push({ key, sessionId, target });
|
|
}
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
/**
|
|
* Restore session mappings from a previous bridge.
|
|
* Called after bridge restart — attempts loadSession for each saved mapping.
|
|
* Failed loads are silently dropped (new session on next message).
|
|
*/
|
|
async restoreSessions(): Promise<{
|
|
restored: number;
|
|
failed: number;
|
|
}> {
|
|
if (!this.persistPath || !existsSync(this.persistPath)) {
|
|
return { restored: 0, failed: 0 };
|
|
}
|
|
|
|
let entries: Record<string, PersistedEntry>;
|
|
try {
|
|
entries = JSON.parse(readFileSync(this.persistPath, 'utf-8'));
|
|
} catch {
|
|
return { restored: 0, failed: 0 };
|
|
}
|
|
|
|
let restored = 0;
|
|
let failed = 0;
|
|
|
|
for (const [key, entry] of Object.entries(entries)) {
|
|
try {
|
|
const sessionId = await this.bridge.loadSession(
|
|
entry.sessionId,
|
|
entry.cwd,
|
|
);
|
|
this.toSession.set(key, sessionId);
|
|
this.toTarget.set(sessionId, entry.target);
|
|
this.toCwd.set(sessionId, entry.cwd);
|
|
restored++;
|
|
} catch {
|
|
// Session can't be loaded — will create fresh on next message
|
|
failed++;
|
|
}
|
|
}
|
|
|
|
// Update persist file to only include successfully restored sessions
|
|
if (failed > 0) {
|
|
this.persist();
|
|
}
|
|
|
|
return { restored, failed };
|
|
}
|
|
|
|
/** Clear in-memory state and delete persist file. Used on clean shutdown. */
|
|
clearAll(): void {
|
|
this.toSession.clear();
|
|
this.toTarget.clear();
|
|
this.toCwd.clear();
|
|
if (this.persistPath && existsSync(this.persistPath)) {
|
|
try {
|
|
unlinkSync(this.persistPath);
|
|
} catch {
|
|
// best-effort
|
|
}
|
|
}
|
|
}
|
|
|
|
private persist(): void {
|
|
if (!this.persistPath) return;
|
|
|
|
const data: Record<string, PersistedEntry> = {};
|
|
for (const [key, sessionId] of this.toSession) {
|
|
const target = this.toTarget.get(sessionId);
|
|
if (target) {
|
|
data[key] = {
|
|
sessionId,
|
|
target,
|
|
cwd: this.toCwd.get(sessionId) || this.defaultCwd,
|
|
};
|
|
}
|
|
}
|
|
|
|
try {
|
|
writeFileSync(this.persistPath, JSON.stringify(data, null, 2), 'utf-8');
|
|
} catch {
|
|
// best-effort — don't break message flow for persistence failure
|
|
}
|
|
}
|
|
}
|