@yvhitxcel/opencode-remote 0.16.3 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,10 @@
1
- // Claude Code CLI agent adapter
1
+ // Claude Code CLI agent adapter — --print + explicit context prompt
2
+ // @ts-nocheck — spawn options type differs between @types/node versions
2
3
  import { spawn } from 'child_process';
3
4
  import { platform } from 'os';
5
+ import { tmpdir } from 'os';
6
+ import { join } from 'path';
7
+ import { registerAgentProcess, unregisterAgentProcess } from '../../../core/agent-registry.js';
4
8
 
5
9
  const LIUV_CRASH_PATTERNS = [
6
10
  'Assertion failed',
@@ -23,18 +27,13 @@ export class ClaudeCodeAgentAdapter {
23
27
  }
24
28
 
25
29
  async sendPrompt(_sessionId, prompt, history, options = {}) {
26
- const projectDir = options.projectDir;
27
- const contextualPrompt = this.buildContextualPrompt(prompt, history);
28
-
29
- const args = ['--print', contextualPrompt];
30
-
31
- return this.callClaude(args, projectDir);
32
- }
33
-
34
- buildContextualPrompt(prompt, history) {
35
- if (!history || history.length === 0) return prompt;
36
- const historyText = history.map(msg => `[${msg.role}]: ${msg.content}`).join('\n\n');
37
- return `Previous:\n${historyText}\n\n${prompt}`;
30
+ const threadId = options.threadId;
31
+ const contextualPrompt = buildContextualPrompt(prompt, history);
32
+ // 跑在临时目录,避免 claude 扫描项目源码崩在 misc-lib.mjs
33
+ const safeCwd = join(tmpdir(), 'opencode-remote-claude');
34
+ const { mkdirSync, existsSync } = await import('fs');
35
+ if (!existsSync(safeCwd)) mkdirSync(safeCwd, { recursive: true });
36
+ return this.callClaude(['--print', contextualPrompt], safeCwd, threadId);
38
37
  }
39
38
 
40
39
  isCrashNoise(line) {
@@ -42,70 +41,83 @@ export class ClaudeCodeAgentAdapter {
42
41
  }
43
42
 
44
43
  extractErrorMessage(stdout, stderr, code) {
45
- // 先检查 stdout:--print 模式把错误也输出到 stdout
46
44
  const stdoutLines = stdout.trim().split('\n').map(l => l.trim()).filter(Boolean);
47
45
  const stdoutErrors = stdoutLines.filter(l => !this.isCrashNoise(l));
48
- if (stdoutErrors.length > 0) {
49
- return stdoutErrors.join('\n');
50
- }
51
-
52
- // 再检查 stderr
46
+ if (stdoutErrors.length > 0) return stdoutErrors.join('\n');
53
47
  const stderrLines = stderr.trim().split('\n').map(l => l.trim()).filter(Boolean);
54
48
  const stderrReal = stderrLines.filter(l => !this.isCrashNoise(l));
55
- if (stderrReal.length > 0) {
56
- return stderrReal.join('\n');
57
- }
58
-
59
- // 如果全是崩溃噪音,尝试从任一流中找 Error 关键词
49
+ if (stderrReal.length > 0) return stderrReal.join('\n');
60
50
  const all = [...stdoutLines, ...stderrLines];
61
51
  const firstRelevant = all.find(l => /Error|error|ERROR|^\d{3}/.test(l));
62
52
  if (firstRelevant) return firstRelevant;
63
-
64
- // 兜底
65
53
  return `进程异常退出 (code: ${code})`;
66
54
  }
67
55
 
68
- callClaude(args, projectDir) {
56
+ callClaude(args, safeCwd, threadId) {
69
57
  return new Promise((resolve) => {
70
58
  const opts = {
71
59
  stdio: ['ignore', 'pipe', 'pipe'],
72
60
  shell: true,
61
+ cwd: safeCwd,
73
62
  };
74
- if (projectDir) {
75
- opts.cwd = projectDir;
76
- }
77
-
78
- console.log(`[claude-code] Spawning: claude ${args.join(' ')} in ${opts.cwd || process.cwd()}`);
79
- const proc = spawn('claude', args, opts);
63
+ // shell:true on Windows cmd.exe (and /bin/sh on POSIX) interprets several chars
64
+ // as command syntax. Strip them so user message content can't trigger command
65
+ // splitting, redirection, or quoting issues:
66
+ // \n \r — command separator (cmd.exe), treated as command end
67
+ // & | < > ^ command separator / pipe / redirect / escape
68
+ // " ' ` — quote chars (could break out of intended argument)
69
+ // We strip rather than escape to avoid platform-specific escape syntax.
70
+ const safeArgs = args.map(a => {
71
+ if (typeof a !== 'string') return a;
72
+ return a
73
+ .replace(/[\r\n]+/g, ' ')
74
+ .replace(/[&|<>^"`]/g, '')
75
+ .replace(/\s+/g, ' ')
76
+ .trim();
77
+ });
78
+ console.log(`[claude-code] ${safeArgs.join(' ')}`);
79
+ const proc = spawn('claude', safeArgs, opts);
80
+ if (threadId) registerAgentProcess(threadId, proc, 'claude-code');
80
81
  let stdout = '';
81
82
  let stderr = '';
83
+ let killed = false;
82
84
  proc.stdout?.on('data', (data) => { stdout += data.toString(); });
83
85
  proc.stderr?.on('data', (data) => { stderr += data.toString(); });
84
86
 
87
+ const TIMEOUT_MS = parseInt(process.env.OPENCODE_TIMEOUT || '600', 10) * 1000;
85
88
  const timeout = setTimeout(() => {
86
- console.log(`[claude-code] Process still running after 30s, stderr so far: ${stderr.slice(-200)}`);
87
- }, 30000);
89
+ killed = true;
90
+ console.warn(`[claude-code] Timeout ${TIMEOUT_MS / 1000}s`);
91
+ try { proc.kill('SIGKILL'); } catch {}
92
+ resolve(`⏰ Claude Code 超时 (${TIMEOUT_MS / 1000}s)`);
93
+ }, TIMEOUT_MS);
88
94
 
89
95
  proc.on('close', (code) => {
90
96
  clearTimeout(timeout);
91
- console.log(`[claude-code] Process exited with code ${code}, stdout=${stdout.length} bytes, stderr=${stderr.length} bytes`);
92
-
93
- if (code === 0) {
94
- resolve(stdout.trim());
95
- return;
96
- }
97
-
97
+ if (threadId) unregisterAgentProcess(threadId);
98
+ if (killed) return;
99
+ console.log(`[claude-code] exit ${code}, ${stdout.length} bytes`);
100
+ if (code === 0) { resolve(stdout.trim()); return; }
98
101
  const errorMsg = this.extractErrorMessage(stdout, stderr, code);
99
- console.log(`[claude-code] Process failed, raw stderr:\n${stderr.trim().slice(-1000)}`);
100
- console.log(`[claude-code] Error detail:\n${errorMsg}`);
101
-
102
- resolve(`❌ Claude Code 错误 (exit code ${code}): ${errorMsg}`);
102
+ resolve(`❌ Claude Code 错误 (exit ${code}): ${errorMsg}`);
103
103
  });
104
104
  proc.on('error', (err) => {
105
105
  clearTimeout(timeout);
106
- console.log(`[claude-code] Spawn error: ${err.message}`);
106
+ if (threadId) unregisterAgentProcess(threadId);
107
107
  resolve(`❌ Claude Code 启动失败: ${err.message}`);
108
108
  });
109
109
  });
110
110
  }
111
111
  }
112
+
113
+ function buildContextualPrompt(prompt, history) {
114
+ if (!history || history.length === 0) return prompt;
115
+ const lines = history.slice(-10).map(m => {
116
+ const label = m.role === 'user' ? 'User' : 'Assistant';
117
+ return `${label}: ${m.content}`;
118
+ }).join('\n');
119
+ return `[Previous conversation — for context only, answer the LATEST question below]\n\n${lines}\n\n[Latest question]\n${prompt}`;
120
+ }
121
+
122
+ // Exported for tests
123
+ export { buildContextualPrompt };
@@ -1,6 +1,8 @@
1
1
  // OpenAI Codex CLI agent adapter
2
+ // @ts-nocheck — spawn options type differs between @types/node versions
2
3
  import { spawn } from 'child_process';
3
4
  import { platform } from 'os';
5
+ import { registerAgentProcess, unregisterAgentProcess } from '../../../core/agent-registry.js';
4
6
 
5
7
  const CRASH_PATTERNS = [
6
8
  'Assertion failed',
@@ -22,15 +24,18 @@ export class CodexAgentAdapter {
22
24
  });
23
25
  }
24
26
 
25
- async sendPrompt(_sessionId, prompt, history) {
27
+ async sendPrompt(_sessionId, prompt, history, options = {}) {
26
28
  const contextualPrompt = this.buildContextualPrompt(prompt, history);
27
- return this.callCodex(contextualPrompt);
29
+ return this.callCodex(contextualPrompt, options.threadId);
28
30
  }
29
31
 
30
32
  buildContextualPrompt(prompt, history) {
31
33
  if (!history || history.length === 0) return prompt;
32
- const historyText = history.map(msg => `[${msg.role}]: ${msg.content}`).join('\n\n');
33
- return `Context:\n${historyText}\n\n${prompt}`;
34
+ const lines = history.slice(-10).map(msg => {
35
+ const label = msg.role === 'user' ? 'User' : 'AI';
36
+ return `${label}: ${msg.content}`;
37
+ }).join('\n');
38
+ return `[Previous conversation — for context only, answer the LATEST question below]\n\n${lines}\n\n[Latest question]\n${prompt}`;
34
39
  }
35
40
 
36
41
  extractErrorMessage(stdout, stderr) {
@@ -43,17 +48,38 @@ export class CodexAgentAdapter {
43
48
  return first || null;
44
49
  }
45
50
 
46
- callCodex(prompt) {
51
+ callCodex(prompt, threadId) {
47
52
  return new Promise((resolve) => {
48
- const proc = spawn('codex', ['--prompt', prompt], {
53
+ // shell:true on Windows cmd.exe (and /bin/sh on POSIX) interprets several chars
54
+ // as command syntax. Strip them so user message content can't trigger command
55
+ // splitting, redirection, or quoting issues.
56
+ const safePrompt = typeof prompt === 'string'
57
+ ? prompt
58
+ .replace(/[\r\n]+/g, ' ')
59
+ .replace(/[&|<>^"`]/g, '')
60
+ .replace(/\s+/g, ' ').trim()
61
+ : prompt;
62
+ const proc = spawn('codex', ['exec', safePrompt], {
49
63
  stdio: ['ignore', 'pipe', 'pipe'],
50
64
  shell: true,
51
65
  });
66
+ if (threadId) registerAgentProcess(threadId, proc, 'codex');
52
67
  let stdout = '';
53
68
  let stderr = '';
69
+ let killed = false;
54
70
  proc.stdout?.on('data', (data) => { stdout += data.toString(); });
55
71
  proc.stderr?.on('data', (data) => { stderr += data.toString(); });
72
+ const TIMEOUT_MS = parseInt(process.env.OPENCODE_TIMEOUT || '600', 10) * 1000;
73
+ const timeout = setTimeout(() => {
74
+ killed = true;
75
+ console.warn(`[codex] Timeout after ${TIMEOUT_MS / 1000}s, killing process`);
76
+ try { proc.kill('SIGKILL'); } catch {}
77
+ resolve(`⏰ Codex 超时 (${TIMEOUT_MS / 1000}s),任务已终止`);
78
+ }, TIMEOUT_MS);
56
79
  proc.on('close', (code) => {
80
+ clearTimeout(timeout);
81
+ if (threadId) unregisterAgentProcess(threadId);
82
+ if (killed) return;
57
83
  if (code === 0) {
58
84
  resolve(stdout.trim());
59
85
  } else {
@@ -1,6 +1,8 @@
1
1
  // GitHub Copilot CLI agent adapter
2
+ // @ts-nocheck — spawn options type differs between @types/node versions
2
3
  import { spawn } from 'child_process';
3
4
  import { platform } from 'os';
5
+ import { registerAgentProcess, unregisterAgentProcess } from '../../../core/agent-registry.js';
4
6
 
5
7
  const CRASH_PATTERNS = [
6
8
  'Assertion failed',
@@ -22,15 +24,18 @@ export class CopilotAgentAdapter {
22
24
  });
23
25
  }
24
26
 
25
- async sendPrompt(_sessionId, prompt, history) {
27
+ async sendPrompt(_sessionId, prompt, history, options = {}) {
26
28
  const contextualPrompt = this.buildContextualPrompt(prompt, history);
27
- return this.callCopilot(contextualPrompt);
29
+ return this.callCopilot(contextualPrompt, options.threadId);
28
30
  }
29
31
 
30
32
  buildContextualPrompt(prompt, history) {
31
33
  if (!history || history.length === 0) return prompt;
32
- const historyText = history.map(msg => `[${msg.role}]: ${msg.content}`).join('\n\n');
33
- return `Context:\n${historyText}\n\n${prompt}`;
34
+ const lines = history.slice(-10).map(msg => {
35
+ const label = msg.role === 'user' ? 'User' : 'AI';
36
+ return `${label}: ${msg.content}`;
37
+ }).join('\n');
38
+ return `[Previous conversation — for context only, answer the LATEST question below]\n\n${lines}\n\n[Latest question]\n${prompt}`;
34
39
  }
35
40
 
36
41
  extractErrorMessage(stdout, stderr) {
@@ -43,17 +48,38 @@ export class CopilotAgentAdapter {
43
48
  return first || null;
44
49
  }
45
50
 
46
- callCopilot(prompt) {
51
+ callCopilot(prompt, threadId) {
47
52
  return new Promise((resolve) => {
48
- const proc = spawn('copilot', ['suggest', '--prompt', prompt], {
53
+ // shell:true on Windows cmd.exe (and /bin/sh on POSIX) interprets several chars
54
+ // as command syntax. Strip them so user message content can't trigger command
55
+ // splitting, redirection, or quoting issues.
56
+ const safePrompt = typeof prompt === 'string'
57
+ ? prompt
58
+ .replace(/[\r\n]+/g, ' ')
59
+ .replace(/[&|<>^"`]/g, '')
60
+ .replace(/\s+/g, ' ').trim()
61
+ : prompt;
62
+ const proc = spawn('copilot', ['-p', safePrompt], {
49
63
  stdio: ['ignore', 'pipe', 'pipe'],
50
64
  shell: true,
51
65
  });
66
+ if (threadId) registerAgentProcess(threadId, proc, 'copilot');
52
67
  let stdout = '';
53
68
  let stderr = '';
69
+ let killed = false;
54
70
  proc.stdout?.on('data', (data) => { stdout += data.toString(); });
55
71
  proc.stderr?.on('data', (data) => { stderr += data.toString(); });
72
+ const TIMEOUT_MS = parseInt(process.env.OPENCODE_TIMEOUT || '600', 10) * 1000;
73
+ const timeout = setTimeout(() => {
74
+ killed = true;
75
+ console.warn(`[copilot] Timeout after ${TIMEOUT_MS / 1000}s, killing process`);
76
+ try { proc.kill('SIGKILL'); } catch {}
77
+ resolve(`⏰ Copilot 超时 (${TIMEOUT_MS / 1000}s),任务已终止`);
78
+ }, TIMEOUT_MS);
56
79
  proc.on('close', (code) => {
80
+ clearTimeout(timeout);
81
+ if (threadId) unregisterAgentProcess(threadId);
82
+ if (killed) return;
57
83
  if (code === 0) {
58
84
  resolve(stdout.trim());
59
85
  } else {
@@ -1,6 +1,8 @@
1
1
  // OpenCode CLI agent adapter
2
+ // @ts-nocheck — spawn options type differs between @types/node versions
2
3
  import { spawn } from 'child_process';
3
4
  import { platform } from 'os';
5
+ import { registerAgentProcess, unregisterAgentProcess } from '../../../core/agent-registry.js';
4
6
 
5
7
  const CRASH_PATTERNS = [
6
8
  'Assertion failed',
@@ -23,18 +25,20 @@ export class OpenCodeAgentAdapter {
23
25
  }
24
26
 
25
27
  async sendPrompt(_sessionId, prompt, history, options = {}) {
28
+ const threadId = options.threadId;
26
29
  const contextualPrompt = this.buildContextualPrompt(prompt, history);
27
- return this.callOpenCode(contextualPrompt);
30
+ return this.callOpenCode(contextualPrompt, threadId);
28
31
  }
29
-
32
+
30
33
  buildContextualPrompt(prompt, history) {
31
34
  if (!history || history.length === 0) return prompt;
32
- const historyText = history
33
- .map(msg => `[${msg.role === 'user' ? 'User' : 'Assistant'}]: ${msg.content}`)
34
- .join('\n\n');
35
- return `Previous conversation:\n${historyText}\n\nCurrent request: ${prompt}`;
35
+ const lines = history.slice(-10).map(msg => {
36
+ const label = msg.role === 'user' ? 'User' : 'AI';
37
+ return `${label}: ${msg.content}`;
38
+ }).join('\n');
39
+ return `[Previous conversation — for context only, answer the LATEST question below]\n\n${lines}\n\n[Latest question]\n${prompt}`;
36
40
  }
37
-
41
+
38
42
  extractErrorMessage(stdout, stderr) {
39
43
  const lines = [...stdout.trim().split('\n'), ...stderr.trim().split('\n')]
40
44
  .map(l => l.trim()).filter(Boolean)
@@ -45,18 +49,38 @@ export class OpenCodeAgentAdapter {
45
49
  .find(l => /Error|error|ERROR|^\d{3}/.test(l));
46
50
  return first || null;
47
51
  }
48
-
49
- callOpenCode(prompt) {
52
+
53
+ callOpenCode(prompt, threadId) {
50
54
  return new Promise((resolve) => {
51
- const proc = spawn('opencode', ['run', '--format', 'json', prompt], {
55
+ // shell:true on Windows cmd.exe (and /bin/sh on POSIX) interprets several chars
56
+ // as command syntax. Strip them so user message content can't trigger command
57
+ // splitting, redirection, or quoting issues.
58
+ const safePrompt = typeof prompt === 'string'
59
+ ? prompt
60
+ .replace(/[\r\n]+/g, ' ')
61
+ .replace(/[&|<>^"`]/g, '')
62
+ .replace(/\s+/g, ' ')
63
+ .trim()
64
+ : prompt;
65
+ const proc = spawn('opencode', ['run', '--format', 'json', safePrompt], {
52
66
  stdio: ['ignore', 'pipe', 'pipe'],
53
67
  shell: true,
54
68
  });
69
+ if (threadId) registerAgentProcess(threadId, proc, 'opencode');
55
70
 
56
71
  let stdout = '';
57
72
  let stderr = '';
58
73
  let fullText = '';
59
74
  let resigned = false;
75
+ let killed = false;
76
+
77
+ const TIMEOUT_MS = parseInt(process.env.OPENCODE_TIMEOUT || '600', 10) * 1000;
78
+ const timeout = setTimeout(() => {
79
+ killed = true;
80
+ console.warn(`[opencode-agent] Timeout after ${TIMEOUT_MS / 1000}s, killing process`);
81
+ try { proc.kill('SIGKILL'); } catch {}
82
+ resolve(`⏰ OpenCode 超时 (${TIMEOUT_MS / 1000}s),任务已终止`);
83
+ }, TIMEOUT_MS);
60
84
 
61
85
  const STUCK_PATTERNS = [
62
86
  'Free usage exceeded', 'quota exceeded', 'rate limit',
@@ -88,7 +112,7 @@ export class OpenCodeAgentAdapter {
88
112
  try {
89
113
  const event = JSON.parse(line);
90
114
  if (event.text) fullText += event.text;
91
- } catch {}
115
+ } catch (e) { console.debug('[opencode-agent] stdout parse:', e.message); }
92
116
  }
93
117
  });
94
118
 
@@ -98,7 +122,9 @@ export class OpenCodeAgentAdapter {
98
122
  });
99
123
 
100
124
  proc.on('close', (code) => {
101
- if (resigned) return;
125
+ clearTimeout(timeout);
126
+ if (threadId) unregisterAgentProcess(threadId);
127
+ if (resigned || killed) return;
102
128
  if (code !== 0) {
103
129
  const detail = this.extractErrorMessage(stdout, stderr);
104
130
  const hint = detail
@@ -38,15 +38,12 @@ export class TelegramAdapter {
38
38
  async sendCommandMenu(threadId, title) {
39
39
  if (!this.bot) return;
40
40
  const groups = [
41
- ['🟢 常用', ['/help', '/status', '/start', '/reset']],
42
- ['🔄 任务', ['/loop', '/refresh', '/restart']],
43
- ['🤖 AI', ['/model', '/agents', '/oc', '/cc']],
44
- ['🧠 专家', ['/tutorial', '/z', '/diagnose']],
45
- ['📂 会话', ['/sessions', '/delsessions', '/copy', '/revert']],
46
- ['⬆️ 文件', ['/upload', '/delete']],
41
+ ['/help', '/start', '/reset', '/diagnose'],
42
+ ['/restart', '/model', '/oc', '/cc'],
43
+ ['/cx', '/copilot'],
47
44
  ];
48
45
  const keyboard = [];
49
- for (const [, cmds] of groups) {
46
+ for (const cmds of groups) {
50
47
  const row = cmds.map(cmd => ({ text: cmd, callback_data: `cmd:${cmd.slice(1)}` }));
51
48
  keyboard.push(row);
52
49
  }
@@ -55,14 +52,30 @@ export class TelegramAdapter {
55
52
  });
56
53
  }
57
54
 
55
+ // BotAdapter 接口方法
56
+ async reply(threadId, text) { return this.sendMessage(threadId, text); }
57
+ async sendTypingIndicator(threadId) { return this.sendTyping(threadId, true); }
58
+ async sendTypingEnd(threadId) { return this.sendTyping(threadId, false); }
59
+ async updateMessage(threadId, messageId, text) {
60
+ if (!this.bot || !messageId) return;
61
+ try { await this.bot.api.editMessageText(threadId, Number(messageId), text); } catch (e) { console.warn('[telegram] updateMessage failed:', e.message); }
62
+ }
63
+ async deleteMessage(threadId, messageId) {
64
+ if (!this.bot || !messageId) return;
65
+ try { await this.bot.api.deleteMessage(threadId, Number(messageId)); }
66
+ catch (e) { console.debug('[telegram] deleteMessage failed:', e.message); }
67
+ }
68
+
58
69
  async sendTyping(threadId, isTyping) {
59
70
  if (!this.bot) return;
60
71
  if (isTyping) {
61
- try { await this.bot.api.sendChatAction(threadId, 'typing'); } catch {}
72
+ try { await this.bot.api.sendChatAction(threadId, 'typing'); }
73
+ catch (e) { console.debug('[telegram] sendChatAction failed:', e.message); }
62
74
  const existing = this.typingIntervals.get(threadId);
63
75
  if (existing) clearInterval(existing);
64
76
  const interval = setInterval(async () => {
65
- try { await this.bot.api.sendChatAction(threadId, 'typing'); } catch {}
77
+ try { await this.bot.api.sendChatAction(threadId, 'typing'); }
78
+ catch (e) { console.debug('[telegram] typing-tick failed:', e.message); }
66
79
  }, 4000);
67
80
  this.typingIntervals.set(threadId, interval);
68
81
  } else {
@@ -1,8 +1,8 @@
1
1
  import { registry } from '../core/registry.js';
2
- import { sessionManager } from '../core/session.js';
3
2
  import { initOpenCode, createSession, sendMessage as sendToOpenCode, checkConnection } from '../opencode/client.js';
4
3
  import { parseMessage, routeMessage } from '../core/router.js';
5
4
  import { telegramAdapter } from './adapter.js';
5
+ import { splitMessage } from '../utils/message-split.js';
6
6
 
7
7
  export async function startBot() {
8
8
  const { loadConfig } = await import('../core/config.js');
@@ -17,7 +17,6 @@ export async function startBot() {
17
17
  process.exit(1);
18
18
  }
19
19
 
20
- await sessionManager.start();
21
20
  await registry.loadBuiltInPlugins();
22
21
  await telegramAdapter.start(config);
23
22
 
@@ -68,10 +67,6 @@ export async function startBot() {
68
67
  if (parsed.type === 'command' && parsed.command === 'reset') {
69
68
  openCodeSessions.delete(message.threadId);
70
69
  opencodeSessionId = null;
71
- try {
72
- const session = await sessionManager.getExistingSession(platform, channelId, message.threadId);
73
- if (session) await sessionManager.resetConversation(platform, channelId, message.threadId);
74
- } catch (e) { console.warn('[Telegram] Reset error:', e.message); }
75
70
  }
76
71
 
77
72
  if (parsed.type === 'default') {
@@ -7,13 +7,35 @@ function createWeixinAdapter(baseUrl, token, botId) {
7
7
  const processedMessages = new Map();
8
8
  const DEDUP_WINDOW_MS = 30_000;
9
9
 
10
- function isDuplicate(messageId) {
11
- if (!messageId) return false;
12
- const seenAt = processedMessages.get(messageId);
13
- if (seenAt && Date.now() - seenAt < DEDUP_WINDOW_MS) return true;
14
- processedMessages.set(messageId, Date.now());
10
+ // 定期清理 contextTokens 和 typingTickets,防内存泄漏
11
+ const CLEANUP_INTERVAL_MS = 30 * 60 * 1000;
12
+ let cleanupTimer = null;
13
+ function startCleanup() {
14
+ if (cleanupTimer) return;
15
+ cleanupTimer = setInterval(() => {
16
+ const cutoff = Date.now() - CLEANUP_INTERVAL_MS;
17
+ for (const [k, v] of contextTokens) { if (typeof v !== 'object' || !v._ts || v._ts < cutoff) contextTokens.delete(k); }
18
+ for (const [k, v] of typingTickets) { if (typeof v !== 'object' || !v._ts || v._ts < cutoff) typingTickets.delete(k); }
19
+ if (processedMessages.size > 1000) {
20
+ const now = Date.now();
21
+ for (const [id, ts] of processedMessages.entries()) {
22
+ if (now - ts > DEDUP_WINDOW_MS) processedMessages.delete(id);
23
+ }
24
+ }
25
+ }, CLEANUP_INTERVAL_MS);
26
+ if (cleanupTimer.unref) cleanupTimer.unref();
27
+ }
28
+ startCleanup();
29
+
30
+ function isDuplicate(messageId, contentKey) {
31
+ // 优先用内容去重: 微信两条消息可能 messageId 不同但内容相同
32
+ const key = contentKey || messageId;
33
+ if (!key) return false;
34
+ const now = Date.now();
35
+ const seenAt = processedMessages.get(key);
36
+ if (seenAt && now - seenAt < DEDUP_WINDOW_MS) return true;
37
+ processedMessages.set(key, now);
15
38
  if (processedMessages.size > 1000) {
16
- const now = Date.now();
17
39
  for (const [id, ts] of processedMessages.entries()) {
18
40
  if (now - ts > DEDUP_WINDOW_MS) processedMessages.delete(id);
19
41
  }
@@ -29,7 +51,8 @@ function createWeixinAdapter(baseUrl, token, botId) {
29
51
  _token: token,
30
52
  _botId: botId,
31
53
  async reply(threadId, text) {
32
- let contextToken = contextTokens.get(threadId);
54
+ let entry = contextTokens.get(threadId);
55
+ let contextToken = entry?.value || entry;
33
56
  let retryCount = 0;
34
57
  const maxRetries = 2;
35
58
 
@@ -40,7 +63,7 @@ function createWeixinAdapter(baseUrl, token, botId) {
40
63
  const r = await getConfig({ baseUrl, token, ilinkUserId: threadId, contextToken: undefined });
41
64
  contextToken = r.context_token || r.typing_ticket;
42
65
  if (contextToken) {
43
- contextTokens.set(threadId, contextToken);
66
+ contextTokens.set(threadId, { value: contextToken, _ts: Date.now() });
44
67
  console.log(`[Weixin] Got contextToken: ${contextToken.slice(0, 8)}...`);
45
68
  } else if (r.errcode === -14) {
46
69
  console.log(`[Weixin] Session timeout, retrying with fresh token...`);
@@ -88,24 +111,24 @@ function createWeixinAdapter(baseUrl, token, botId) {
88
111
  throw err;
89
112
  },
90
113
  async sendTypingIndicator(threadId) {
91
- const cachedTicket = typingTickets.get(threadId);
92
- let ticket = cachedTicket;
114
+ const entry = typingTickets.get(threadId);
115
+ let ticket = entry?.value || entry;
93
116
 
94
117
  if (!ticket) {
95
118
  try {
96
- const r = await getConfig({ baseUrl, token, ilinkUserId: threadId, contextToken: contextTokens.get(threadId) });
119
+ const r = await getConfig({ baseUrl, token, ilinkUserId: threadId, contextToken: (contextTokens.get(threadId) || {}).value });
97
120
  if (r.errcode === -14) {
98
121
  contextTokens.delete(threadId);
99
122
  typingTickets.delete(threadId);
100
123
  const freshConfig = await getConfig({ baseUrl, token, ilinkUserId: threadId, contextToken: undefined });
101
124
  ticket = freshConfig.typing_ticket;
102
125
  if (freshConfig.context_token) {
103
- contextTokens.set(threadId, freshConfig.context_token);
126
+ contextTokens.set(threadId, { value: freshConfig.context_token, _ts: Date.now() });
104
127
  }
105
128
  } else {
106
129
  ticket = r.typing_ticket;
107
130
  }
108
- if (ticket) typingTickets.set(threadId, ticket);
131
+ if (ticket) typingTickets.set(threadId, { value: ticket, _ts: Date.now() });
109
132
  } catch { console.debug('[typing] getConfig failed'); }
110
133
  }
111
134
  if (ticket) {
@@ -118,7 +141,7 @@ function createWeixinAdapter(baseUrl, token, botId) {
118
141
  try {
119
142
  const freshConfig = await getConfig({ baseUrl, token, ilinkUserId: threadId, contextToken: undefined });
120
143
  if (freshConfig.typing_ticket) {
121
- typingTickets.set(threadId, freshConfig.typing_ticket);
144
+ typingTickets.set(threadId, { value: freshConfig.typing_ticket, _ts: Date.now() });
122
145
  await sendTyping({ baseUrl, token, body: { ilink_user_id: threadId, typing_ticket: freshConfig.typing_ticket, status: 1 } });
123
146
  }
124
147
  } catch { console.debug('[typing] retry getConfig failed'); }
@@ -132,4 +155,3 @@ function createWeixinAdapter(baseUrl, token, botId) {
132
155
  }
133
156
 
134
157
  export { createWeixinAdapter };
135
- export default createWeixinAdapter;