@wu529778790/open-im 1.11.1-beta.19 → 1.11.1-beta.20

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.
@@ -72,6 +72,7 @@ export function setupClawbotHandlers(config, sessionManager) {
72
72
  userId,
73
73
  chatId,
74
74
  text: enrichedText,
75
+ msgId,
75
76
  ctx,
76
77
  handleAIRequest,
77
78
  sendTextReply: (c, t) => sendTextReply(c, t),
@@ -38,6 +38,8 @@ export interface HandleTextFlowParams {
38
38
  chatId: string;
39
39
  /** The trimmed text content of the message. */
40
40
  text: string;
41
+ /** Optional message ID for deduplication. */
42
+ msgId?: string;
41
43
  /** The platform event context (accessControl, commandHandler, requestQueue, etc.). */
42
44
  ctx: PlatformEventContext;
43
45
  /** The platform-specific AI request handler (from createPlatformAIRequestHandler). */
@@ -19,6 +19,27 @@ import { setActiveChatId } from '../shared/active-chats.js';
19
19
  import { setChatUser } from '../shared/chat-user-map.js';
20
20
  import { createLogger, auditLog } from '../logger.js';
21
21
  import { handleEnqueueResult, DEFAULT_QUEUE_FULL_MESSAGE, DEFAULT_QUEUED_MESSAGE } from '../shared/utils.js';
22
+ import { walWrite, walCommit } from '../shared/message-wal.js';
23
+ /* ── 幂等性:消息去重 ── */
24
+ const DEDUP_TTL_MS = 60_000; // 1 分钟内的相同 msgId 视为重复
25
+ const dedupCache = new Map(); // msgId → timestamp
26
+ function isDuplicate(msgId) {
27
+ if (!msgId)
28
+ return false;
29
+ const now = Date.now();
30
+ const prev = dedupCache.get(msgId);
31
+ if (prev && now - prev < DEDUP_TTL_MS)
32
+ return true;
33
+ dedupCache.set(msgId, now);
34
+ // 清理过期条目
35
+ if (dedupCache.size > 1000) {
36
+ for (const [k, v] of dedupCache) {
37
+ if (now - v > DEDUP_TTL_MS)
38
+ dedupCache.delete(k);
39
+ }
40
+ }
41
+ return false;
42
+ }
22
43
  const log = createLogger('TextFlow');
23
44
  /** Default access-denied message. */
24
45
  function defaultAccessDeniedMessage(userId) {
@@ -38,13 +59,21 @@ function defaultAccessDeniedMessage(userId) {
38
59
  * @returns true if the message was processed (handled or enqueued), false if denied.
39
60
  */
40
61
  export async function handleTextFlow(params) {
41
- const { platform, userId, chatId, text, ctx, handleAIRequest, sendTextReply, workDir: workDirOverride, convId: convIdOverride, replyToMessageId, accessDeniedMessage = defaultAccessDeniedMessage, queueFullMessage = DEFAULT_QUEUE_FULL_MESSAGE, queuedMessage = DEFAULT_QUEUED_MESSAGE, customEnqueue, } = params;
62
+ const { platform, userId, chatId, text, msgId, ctx, handleAIRequest, sendTextReply, workDir: workDirOverride, convId: convIdOverride, replyToMessageId, accessDeniedMessage = defaultAccessDeniedMessage, queueFullMessage = DEFAULT_QUEUE_FULL_MESSAGE, queuedMessage = DEFAULT_QUEUED_MESSAGE, customEnqueue, } = params;
42
63
  // 1. Access control check
43
64
  if (!ctx.accessControl.isAllowed(userId)) {
44
65
  log.info(`[${platform}] Access denied for user: ${userId}`);
45
66
  await sendTextReply(chatId, accessDeniedMessage(userId));
46
67
  return false;
47
68
  }
69
+ // 1b. Deduplication check
70
+ if (isDuplicate(msgId)) {
71
+ log.info(`[${platform}] Duplicate message ignored: msgId=${msgId}`);
72
+ return true;
73
+ }
74
+ // 1c. WAL: persist message before processing
75
+ const walMsgId = msgId || `${platform}:${userId}:${Date.now()}`;
76
+ walWrite({ msgId: walMsgId, platform, userId, chatId, text: text.substring(0, 500), timestamp: Date.now() });
48
77
  // 2. Set active chat ID
49
78
  setActiveChatId(platform, chatId);
50
79
  // 3. Set chat-user mapping
@@ -69,6 +98,7 @@ export async function handleTextFlow(params) {
69
98
  };
70
99
  const handled = await ctx.commandHandler.dispatch(text, chatId, userId, platform, dispatchHandler, commandSender);
71
100
  if (handled) {
101
+ walCommit(walMsgId);
72
102
  return true;
73
103
  }
74
104
  }
@@ -105,5 +135,6 @@ export async function handleTextFlow(params) {
105
135
  });
106
136
  await handleEnqueueResult(enqueueResult, (text) => sendTextReply(chatId, text), { queueFull: queueFullMessage, queued: queuedMessage });
107
137
  }
138
+ walCommit(walMsgId);
108
139
  return true;
109
140
  }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Message WAL (Write-Ahead Log) — 消息持久化
3
+ *
4
+ * 进程崩溃时保留未处理的消息,重启后可重放。
5
+ * 实现:JSONL 文件,每条消息一行。
6
+ */
7
+ export interface WALEntry {
8
+ msgId: string;
9
+ platform: string;
10
+ userId: string;
11
+ chatId: string;
12
+ text: string;
13
+ timestamp: number;
14
+ status: 'pending' | 'done';
15
+ }
16
+ /**
17
+ * 写入消息到 WAL(处理前调用)
18
+ */
19
+ export declare function walWrite(entry: Omit<WALEntry, 'status'>): void;
20
+ /**
21
+ * 标记消息为已处理(处理后调用)
22
+ */
23
+ export declare function walCommit(msgId: string): void;
24
+ /**
25
+ * 读取所有未处理的消息(重启时调用)
26
+ */
27
+ export declare function walReadPending(): WALEntry[];
28
+ /**
29
+ * 清空 WAL 文件(所有消息处理完后调用)
30
+ */
31
+ export declare function walClear(): void;
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Message WAL (Write-Ahead Log) — 消息持久化
3
+ *
4
+ * 进程崩溃时保留未处理的消息,重启后可重放。
5
+ * 实现:JSONL 文件,每条消息一行。
6
+ */
7
+ import { createWriteStream, existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import { APP_HOME } from '../constants.js';
10
+ import { createLogger } from '../logger.js';
11
+ const log = createLogger('MessageWAL');
12
+ const WAL_DIR = join(APP_HOME, 'data');
13
+ const WAL_FILE = join(WAL_DIR, 'messages.jsonl');
14
+ let stream = null;
15
+ function ensureDir() {
16
+ if (!existsSync(WAL_DIR))
17
+ mkdirSync(WAL_DIR, { recursive: true });
18
+ }
19
+ /**
20
+ * 写入消息到 WAL(处理前调用)
21
+ */
22
+ export function walWrite(entry) {
23
+ try {
24
+ ensureDir();
25
+ const line = JSON.stringify({ ...entry, status: 'pending' }) + '\n';
26
+ if (!stream) {
27
+ stream = createWriteStream(WAL_FILE, { flags: 'a' });
28
+ }
29
+ stream.write(line);
30
+ }
31
+ catch (err) {
32
+ log.warn('WAL write failed:', err);
33
+ }
34
+ }
35
+ /**
36
+ * 标记消息为已处理(处理后调用)
37
+ */
38
+ export function walCommit(msgId) {
39
+ try {
40
+ ensureDir();
41
+ // 读取所有条目,过滤掉已完成的
42
+ const entries = readAllEntries();
43
+ const pending = entries.filter(e => !(e.msgId === msgId && e.status === 'pending'));
44
+ // 重写文件(只保留未完成的)
45
+ writeFileSync(WAL_FILE, pending.map(e => JSON.stringify(e)).join('\n') + (pending.length ? '\n' : ''), 'utf-8');
46
+ }
47
+ catch (err) {
48
+ log.warn('WAL commit failed:', err);
49
+ }
50
+ }
51
+ /**
52
+ * 读取所有未处理的消息(重启时调用)
53
+ */
54
+ export function walReadPending() {
55
+ try {
56
+ ensureDir();
57
+ const entries = readAllEntries();
58
+ return entries.filter(e => e.status === 'pending');
59
+ }
60
+ catch (err) {
61
+ log.warn('WAL read failed:', err);
62
+ return [];
63
+ }
64
+ }
65
+ /**
66
+ * 清空 WAL 文件(所有消息处理完后调用)
67
+ */
68
+ export function walClear() {
69
+ try {
70
+ ensureDir();
71
+ writeFileSync(WAL_FILE, '', 'utf-8');
72
+ }
73
+ catch (err) {
74
+ log.warn('WAL clear failed:', err);
75
+ }
76
+ }
77
+ function readAllEntries() {
78
+ if (!existsSync(WAL_FILE))
79
+ return [];
80
+ const content = readFileSync(WAL_FILE, 'utf-8').trim();
81
+ if (!content)
82
+ return [];
83
+ return content.split('\n').map(line => {
84
+ try {
85
+ return JSON.parse(line);
86
+ }
87
+ catch {
88
+ return null;
89
+ }
90
+ }).filter((e) => e !== null);
91
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wu529778790/open-im",
3
- "version": "1.11.1-beta.19",
3
+ "version": "1.11.1-beta.20",
4
4
  "description": "Your AI coding assistant, in every chat app. Multi-platform IM bridge for Claude Code, Codex, and CodeBuddy.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",