chatccc 0.2.215 → 0.2.216
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.
- package/package.json +1 -1
- package/src/__tests__/feishu-message-ingress.test.ts +138 -0
- package/src/__tests__/sim-platform.test.ts +4 -3
- package/src/feishu-api.ts +1 -1
- package/src/feishu-message-ingress.ts +195 -0
- package/src/index.ts +112 -81
- package/src/session.ts +8 -4
package/package.json
CHANGED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
FeishuMessageLedger,
|
|
9
|
+
createAckFirstEventHandler,
|
|
10
|
+
} from "../feishu-message-ingress.ts";
|
|
11
|
+
|
|
12
|
+
const tempDirs: string[] = [];
|
|
13
|
+
|
|
14
|
+
async function createLedger(maxEntries = 5): Promise<FeishuMessageLedger> {
|
|
15
|
+
const dir = await mkdtemp(join(tmpdir(), "chatccc-feishu-ingress-"));
|
|
16
|
+
tempDirs.push(dir);
|
|
17
|
+
return new FeishuMessageLedger(join(dir, "messages.json"), maxEntries);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
afterEach(async () => {
|
|
21
|
+
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("createAckFirstEventHandler", () => {
|
|
25
|
+
it("returns before starting the asynchronous event worker", async () => {
|
|
26
|
+
let scheduled: (() => void) | undefined;
|
|
27
|
+
const schedule = vi.fn((task: () => void) => {
|
|
28
|
+
scheduled = task;
|
|
29
|
+
});
|
|
30
|
+
const worker = vi.fn(async () => {});
|
|
31
|
+
const onError = vi.fn();
|
|
32
|
+
const handler = createAckFirstEventHandler(worker, onError, schedule);
|
|
33
|
+
|
|
34
|
+
await expect(handler({ message: "test" })).resolves.toBeUndefined();
|
|
35
|
+
expect(schedule).toHaveBeenCalledOnce();
|
|
36
|
+
expect(worker).not.toHaveBeenCalled();
|
|
37
|
+
|
|
38
|
+
scheduled?.();
|
|
39
|
+
await vi.waitFor(() => expect(worker).toHaveBeenCalledWith({ message: "test" }));
|
|
40
|
+
expect(onError).not.toHaveBeenCalled();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("reports detached worker failures without rejecting the SDK callback", async () => {
|
|
44
|
+
const error = new Error("worker failed");
|
|
45
|
+
const onError = vi.fn();
|
|
46
|
+
const handler = createAckFirstEventHandler(
|
|
47
|
+
async () => {
|
|
48
|
+
throw error;
|
|
49
|
+
},
|
|
50
|
+
onError,
|
|
51
|
+
(task) => task(),
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
await expect(handler({})).resolves.toBeUndefined();
|
|
55
|
+
await vi.waitFor(() => expect(onError).toHaveBeenCalledWith(error));
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("FeishuMessageLedger", () => {
|
|
60
|
+
it("rejects an already accepted message after a process restart", async () => {
|
|
61
|
+
const first = await createLedger();
|
|
62
|
+
await first.load();
|
|
63
|
+
|
|
64
|
+
expect(await first.accept({
|
|
65
|
+
messageId: "om_001",
|
|
66
|
+
chatId: "oc_chat",
|
|
67
|
+
createTime: 100,
|
|
68
|
+
})).toBe("accepted");
|
|
69
|
+
|
|
70
|
+
const restarted = new FeishuMessageLedger(first.filePath, 5);
|
|
71
|
+
await restarted.load();
|
|
72
|
+
|
|
73
|
+
expect(await restarted.accept({
|
|
74
|
+
messageId: "om_001",
|
|
75
|
+
chatId: "oc_chat",
|
|
76
|
+
createTime: 100,
|
|
77
|
+
})).toBe("duplicate");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("rejects a unique old event after restoring the chat high-water mark", async () => {
|
|
81
|
+
const first = await createLedger();
|
|
82
|
+
await first.load();
|
|
83
|
+
|
|
84
|
+
expect(await first.accept({
|
|
85
|
+
messageId: "om_new",
|
|
86
|
+
chatId: "oc_chat",
|
|
87
|
+
createTime: 200,
|
|
88
|
+
})).toBe("accepted");
|
|
89
|
+
|
|
90
|
+
const restarted = new FeishuMessageLedger(first.filePath, 5);
|
|
91
|
+
await restarted.load();
|
|
92
|
+
|
|
93
|
+
expect(await restarted.accept({
|
|
94
|
+
messageId: "om_old",
|
|
95
|
+
chatId: "oc_chat",
|
|
96
|
+
createTime: 100,
|
|
97
|
+
})).toBe("stale");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("accepts distinct messages with the same create timestamp", async () => {
|
|
101
|
+
const ledger = await createLedger();
|
|
102
|
+
await ledger.load();
|
|
103
|
+
|
|
104
|
+
expect(await ledger.accept({
|
|
105
|
+
messageId: "om_001",
|
|
106
|
+
chatId: "oc_chat",
|
|
107
|
+
createTime: 100,
|
|
108
|
+
})).toBe("accepted");
|
|
109
|
+
expect(await ledger.accept({
|
|
110
|
+
messageId: "om_002",
|
|
111
|
+
chatId: "oc_chat",
|
|
112
|
+
createTime: 100,
|
|
113
|
+
})).toBe("accepted");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("keeps only the configured number of recent message IDs", async () => {
|
|
117
|
+
const ledger = await createLedger(2);
|
|
118
|
+
await ledger.load();
|
|
119
|
+
|
|
120
|
+
await ledger.accept({ messageId: "om_001", chatId: "oc_chat", createTime: 100 });
|
|
121
|
+
await ledger.accept({ messageId: "om_002", chatId: "oc_chat", createTime: 200 });
|
|
122
|
+
await ledger.accept({ messageId: "om_003", chatId: "oc_chat", createTime: 300 });
|
|
123
|
+
|
|
124
|
+
const restarted = new FeishuMessageLedger(ledger.filePath, 2);
|
|
125
|
+
await restarted.load();
|
|
126
|
+
|
|
127
|
+
expect(await restarted.accept({
|
|
128
|
+
messageId: "om_001",
|
|
129
|
+
chatId: "oc_other",
|
|
130
|
+
createTime: 100,
|
|
131
|
+
})).toBe("accepted");
|
|
132
|
+
expect(await restarted.accept({
|
|
133
|
+
messageId: "om_003",
|
|
134
|
+
chatId: "oc_chat",
|
|
135
|
+
createTime: 300,
|
|
136
|
+
})).toBe("duplicate");
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -73,9 +73,10 @@ describe("SimulatedPlatform", () => {
|
|
|
73
73
|
});
|
|
74
74
|
|
|
75
75
|
it("纯函数 formatDelayNotice 正常工作", () => {
|
|
76
|
-
const notice = SimulatedPlatform.formatDelayNotice(Date.now() - 20 * 60 * 1000, "测试消息");
|
|
77
|
-
expect(notice).toBeDefined();
|
|
78
|
-
expect(notice).toContain("延迟送达");
|
|
76
|
+
const notice = SimulatedPlatform.formatDelayNotice(Date.now() - 20 * 60 * 1000, "测试消息");
|
|
77
|
+
expect(notice).toBeDefined();
|
|
78
|
+
expect(notice).toContain("延迟送达");
|
|
79
|
+
expect(notice).not.toContain("因服务离线");
|
|
79
80
|
// 近期消息不触发
|
|
80
81
|
expect(SimulatedPlatform.formatDelayNotice(Date.now(), "test")).toBeNull();
|
|
81
82
|
});
|
package/src/feishu-api.ts
CHANGED
|
@@ -1023,7 +1023,7 @@ export function formatDelayNotice(createTimeMs: number, messageText?: string, no
|
|
|
1023
1023
|
}
|
|
1024
1024
|
|
|
1025
1025
|
const contentLine = messageText ? `\n> 原始内容:${messageText.slice(0, 200)}` : "";
|
|
1026
|
-
return `> ⚠️ 延迟送达提醒:此消息于 ${sendTimeStr}
|
|
1026
|
+
return `> ⚠️ 延迟送达提醒:此消息于 ${sendTimeStr} 发送,现延迟约 ${delayStr}后送达${contentLine}`;
|
|
1027
1027
|
}
|
|
1028
1028
|
|
|
1029
1029
|
/**
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
|
|
5
|
+
export const MAX_PROCESSED = 5000;
|
|
6
|
+
|
|
7
|
+
export type FeishuMessageDisposition = "accepted" | "duplicate" | "stale";
|
|
8
|
+
|
|
9
|
+
export interface FeishuMessageIdentity {
|
|
10
|
+
messageId?: string;
|
|
11
|
+
chatId: string;
|
|
12
|
+
createTime: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface PersistedMessageEntry {
|
|
16
|
+
messageId: string;
|
|
17
|
+
chatId: string;
|
|
18
|
+
createTime: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface PersistedMessageLedger {
|
|
22
|
+
version: 1;
|
|
23
|
+
entries: PersistedMessageEntry[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type ScheduleTask = (task: () => void) => void;
|
|
27
|
+
|
|
28
|
+
const defaultSchedule: ScheduleTask = (task) => {
|
|
29
|
+
setImmediate(task);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The Feishu SDK waits for an event handler's return value before sending its
|
|
34
|
+
* WebSocket response. Schedule the real work for the next event-loop turn so
|
|
35
|
+
* the SDK callback can return and acknowledge the event immediately.
|
|
36
|
+
*/
|
|
37
|
+
export function createAckFirstEventHandler<T>(
|
|
38
|
+
worker: (data: T) => Promise<void>,
|
|
39
|
+
onError: (error: unknown) => void,
|
|
40
|
+
schedule: ScheduleTask = defaultSchedule,
|
|
41
|
+
): (data: T) => Promise<void> {
|
|
42
|
+
return async (data) => {
|
|
43
|
+
schedule(() => {
|
|
44
|
+
void worker(data).catch(onError);
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isPersistedEntry(value: unknown): value is PersistedMessageEntry {
|
|
50
|
+
if (!value || typeof value !== "object") return false;
|
|
51
|
+
const entry = value as Record<string, unknown>;
|
|
52
|
+
return typeof entry.messageId === "string"
|
|
53
|
+
&& entry.messageId.length > 0
|
|
54
|
+
&& typeof entry.chatId === "string"
|
|
55
|
+
&& typeof entry.createTime === "number"
|
|
56
|
+
&& Number.isFinite(entry.createTime);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export class FeishuMessageLedger {
|
|
60
|
+
private readonly messageIds: Set<string>;
|
|
61
|
+
private entries: PersistedMessageEntry[] = [];
|
|
62
|
+
private latestCreateTimeByChat = new Map<string, number>();
|
|
63
|
+
private persistTail: Promise<void> = Promise.resolve();
|
|
64
|
+
|
|
65
|
+
constructor(
|
|
66
|
+
public readonly filePath: string,
|
|
67
|
+
private readonly maxEntries = MAX_PROCESSED,
|
|
68
|
+
messageIds?: Set<string>,
|
|
69
|
+
) {
|
|
70
|
+
this.messageIds = messageIds ?? new Set<string>();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async load(): Promise<void> {
|
|
74
|
+
this.clearMemory();
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const raw = await readFile(this.filePath, "utf-8");
|
|
78
|
+
const parsed = JSON.parse(raw) as Partial<PersistedMessageLedger>;
|
|
79
|
+
const entries = Array.isArray(parsed.entries)
|
|
80
|
+
? parsed.entries.filter(isPersistedEntry)
|
|
81
|
+
: [];
|
|
82
|
+
this.entries = entries.slice(-this.maxEntries);
|
|
83
|
+
this.rebuildIndexes();
|
|
84
|
+
|
|
85
|
+
if (entries.length > this.maxEntries) {
|
|
86
|
+
await this.persist();
|
|
87
|
+
}
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
90
|
+
console.error(
|
|
91
|
+
`[FEISHU-DEDUP] Failed to load ${this.filePath}: ${(error as Error).message}`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async accept(identity: FeishuMessageIdentity): Promise<FeishuMessageDisposition> {
|
|
98
|
+
const { messageId, chatId, createTime } = identity;
|
|
99
|
+
|
|
100
|
+
if (messageId && this.messageIds.has(messageId)) {
|
|
101
|
+
return "duplicate";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const latestCreateTime = this.latestCreateTimeByChat.get(chatId);
|
|
105
|
+
if (latestCreateTime !== undefined && createTime < latestCreateTime) {
|
|
106
|
+
return "stale";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (!messageId) {
|
|
110
|
+
this.recordLatestCreateTime(chatId, createTime);
|
|
111
|
+
return "accepted";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
this.entries.push({ messageId, chatId, createTime });
|
|
115
|
+
this.messageIds.add(messageId);
|
|
116
|
+
this.recordLatestCreateTime(chatId, createTime);
|
|
117
|
+
|
|
118
|
+
if (this.entries.length > this.maxEntries) {
|
|
119
|
+
this.entries = this.entries.slice(-this.maxEntries);
|
|
120
|
+
this.rebuildIndexes();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
await this.persist();
|
|
125
|
+
} catch (error) {
|
|
126
|
+
console.error(
|
|
127
|
+
`[FEISHU-DEDUP] Failed to persist ${this.filePath}: ${(error as Error).message}`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
return "accepted";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
clearMemory(): void {
|
|
134
|
+
this.entries = [];
|
|
135
|
+
this.messageIds.clear();
|
|
136
|
+
this.latestCreateTimeByChat.clear();
|
|
137
|
+
this.persistTail = Promise.resolve();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private recordLatestCreateTime(chatId: string, createTime: number): void {
|
|
141
|
+
const current = this.latestCreateTimeByChat.get(chatId);
|
|
142
|
+
if (current === undefined || createTime > current) {
|
|
143
|
+
this.latestCreateTimeByChat.set(chatId, createTime);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private rebuildIndexes(): void {
|
|
148
|
+
this.messageIds.clear();
|
|
149
|
+
this.latestCreateTimeByChat.clear();
|
|
150
|
+
for (const entry of this.entries) {
|
|
151
|
+
this.messageIds.add(entry.messageId);
|
|
152
|
+
this.recordLatestCreateTime(entry.chatId, entry.createTime);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private persist(): Promise<void> {
|
|
157
|
+
const snapshot: PersistedMessageLedger = {
|
|
158
|
+
version: 1,
|
|
159
|
+
entries: this.entries.map((entry) => ({ ...entry })),
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const nextPersist = this.persistTail
|
|
163
|
+
.catch(() => {})
|
|
164
|
+
.then(async () => {
|
|
165
|
+
await mkdir(dirname(this.filePath), { recursive: true });
|
|
166
|
+
const tempPath = `${this.filePath}.${process.pid}.tmp`;
|
|
167
|
+
try {
|
|
168
|
+
await writeFile(tempPath, JSON.stringify(snapshot), "utf-8");
|
|
169
|
+
await rename(tempPath, this.filePath);
|
|
170
|
+
} finally {
|
|
171
|
+
await rm(tempPath, { force: true }).catch(() => {});
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
this.persistTail = nextPersist;
|
|
175
|
+
return nextPersist;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const defaultLedgerPath = join(
|
|
180
|
+
homedir(),
|
|
181
|
+
".chatccc",
|
|
182
|
+
"state",
|
|
183
|
+
"feishu-message-ledger.json",
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
export const processedMessages = new Set<string>();
|
|
187
|
+
export const feishuMessageLedger = new FeishuMessageLedger(
|
|
188
|
+
defaultLedgerPath,
|
|
189
|
+
MAX_PROCESSED,
|
|
190
|
+
processedMessages,
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
export function clearFeishuMessageLedgerMemory(): void {
|
|
194
|
+
feishuMessageLedger.clearMemory();
|
|
195
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -96,11 +96,9 @@ import {
|
|
|
96
96
|
sendCardKitMessage,
|
|
97
97
|
updateCardKitCard,
|
|
98
98
|
} from "./cardkit.ts";
|
|
99
|
-
import {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
processedMessages,
|
|
103
|
-
rebuildBindingsFromRegistry,
|
|
99
|
+
import {
|
|
100
|
+
loadSessionRegistryForBinding,
|
|
101
|
+
rebuildBindingsFromRegistry,
|
|
104
102
|
resetState,
|
|
105
103
|
setSessionPlatform,
|
|
106
104
|
startUnifiedDisplayLoop,
|
|
@@ -115,7 +113,11 @@ import { handleCommand, type PlatformAdapter } from "./orchestrator.ts";
|
|
|
115
113
|
import { createWechatAdapter, startWechatPlatform } from "./wechat-platform.ts";
|
|
116
114
|
import { handleCodexResetCardAction } from "./codex-reset-actions.ts";
|
|
117
115
|
import { resolveFeishuCardActionChatType } from "./card-action-routing.ts";
|
|
118
|
-
import { reloadRuntimeConfig } from "./runtime-reload.ts";
|
|
116
|
+
import { reloadRuntimeConfig } from "./runtime-reload.ts";
|
|
117
|
+
import {
|
|
118
|
+
createAckFirstEventHandler,
|
|
119
|
+
feishuMessageLedger,
|
|
120
|
+
} from "./feishu-message-ingress.ts";
|
|
119
121
|
|
|
120
122
|
// ---------------------------------------------------------------------------
|
|
121
123
|
// Feishu 平台适配器
|
|
@@ -262,12 +264,105 @@ function parseCardAction(data: unknown): CardActionResult | null {
|
|
|
262
264
|
// WebSocket relay broadcast
|
|
263
265
|
// ---------------------------------------------------------------------------
|
|
264
266
|
|
|
265
|
-
let broadcastToRelay: (data: unknown) => void = () => {};
|
|
266
|
-
const wechatSignal = { stopped: false };
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
267
|
+
let broadcastToRelay: (data: unknown) => void = () => {};
|
|
268
|
+
const wechatSignal = { stopped: false };
|
|
269
|
+
|
|
270
|
+
async function processFeishuMessageEvent(data: Evt): Promise<void> {
|
|
271
|
+
const traceId = makeTraceId();
|
|
272
|
+
try {
|
|
273
|
+
broadcastToRelay(data);
|
|
274
|
+
|
|
275
|
+
const event = getInnerEvent(data);
|
|
276
|
+
const message = event.message;
|
|
277
|
+
if (!message) return;
|
|
278
|
+
|
|
279
|
+
const messageId = message.message_id;
|
|
280
|
+
const chatId = message.chat_id ?? "";
|
|
281
|
+
const chatType = message.chat_type ?? "group";
|
|
282
|
+
const msgTimestamp = parseInt(message.create_time ?? "0", 10) || Date.now();
|
|
283
|
+
const disposition = await feishuMessageLedger.accept({
|
|
284
|
+
messageId,
|
|
285
|
+
chatId,
|
|
286
|
+
createTime: msgTimestamp,
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
if (disposition === "duplicate") {
|
|
290
|
+
console.log(`[MSG] Duplicate message ignored: ${messageId ?? "(missing id)"}`);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if (disposition === "stale") {
|
|
294
|
+
logTrace(traceId, "DONE", {
|
|
295
|
+
outcome: "skip_stale_feishu_message",
|
|
296
|
+
messageId,
|
|
297
|
+
chatId,
|
|
298
|
+
msgTimestamp,
|
|
299
|
+
});
|
|
300
|
+
console.log(
|
|
301
|
+
`[${ts()}] [SKIP] Stale Feishu message ignored: messageId=${messageId ?? "(missing id)"} chatId=${chatId} createTime=${msgTimestamp}`,
|
|
302
|
+
);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const text = await formatMessageContent(message);
|
|
307
|
+
const openId = event.sender?.sender_id?.open_id ?? "";
|
|
308
|
+
|
|
309
|
+
console.log(
|
|
310
|
+
`[MSG] id=${messageId ?? "(missing)"} sender=${openId} chat=${chatId} type=${chatType} text="${text}"`,
|
|
311
|
+
);
|
|
312
|
+
appendChatLog(chatId, openId, text);
|
|
313
|
+
|
|
314
|
+
if (messageId) {
|
|
315
|
+
getTenantAccessToken().then((freshToken) =>
|
|
316
|
+
addReaction(freshToken, messageId).catch((err) =>
|
|
317
|
+
console.error(`[${ts()}] Reaction failed: ${(err as Error).message}`)
|
|
318
|
+
)
|
|
319
|
+
).catch((err) =>
|
|
320
|
+
console.error(`[${ts()}] Reaction token failed: ${(err as Error).message}`)
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (!text) return;
|
|
325
|
+
logTrace(traceId, "RECV", {
|
|
326
|
+
messageId,
|
|
327
|
+
chatId,
|
|
328
|
+
chatType,
|
|
329
|
+
text: text.slice(0, 100),
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
const delayNotice = formatDelayNotice(msgTimestamp, text);
|
|
333
|
+
if (delayNotice) {
|
|
334
|
+
const delayToken = await getTenantAccessToken();
|
|
335
|
+
await sendCardReply(delayToken, chatId, "延迟送达", delayNotice, "yellow").catch(() => {});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
await handleCommand(
|
|
339
|
+
feishuPlatform,
|
|
340
|
+
text,
|
|
341
|
+
chatId,
|
|
342
|
+
openId,
|
|
343
|
+
msgTimestamp,
|
|
344
|
+
chatType,
|
|
345
|
+
traceId,
|
|
346
|
+
buildUpdateCommandId("message", messageId),
|
|
347
|
+
);
|
|
348
|
+
} catch (err) {
|
|
349
|
+
logTrace(traceId, "ERROR", { message: (err as Error).message });
|
|
350
|
+
console.error(
|
|
351
|
+
`[${ts()}] [FATAL] im.message.receive_v1 worker crashed: ${(err as Error).message}`,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const handleFeishuMessageEventAckFirst = createAckFirstEventHandler(
|
|
357
|
+
processFeishuMessageEvent,
|
|
358
|
+
(err) => console.error(
|
|
359
|
+
`[${ts()}] [FATAL] Detached Feishu event worker failed: ${(err as Error).message}`,
|
|
360
|
+
),
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
// ---------------------------------------------------------------------------
|
|
364
|
+
// Simulate mode: inject message via HTTP
|
|
365
|
+
// ---------------------------------------------------------------------------
|
|
271
366
|
|
|
272
367
|
async function handleSimInjectMessage(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
|
273
368
|
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
@@ -399,74 +494,9 @@ async function startBotServiceCore(): Promise<void> {
|
|
|
399
494
|
|
|
400
495
|
console.log(`[${ts()}] [AUTH] Token obtained`);
|
|
401
496
|
|
|
402
|
-
const eventDispatcher = new EventDispatcher({});
|
|
403
|
-
eventDispatcher.register({
|
|
404
|
-
"im.message.receive_v1":
|
|
405
|
-
const traceId = makeTraceId();
|
|
406
|
-
try {
|
|
407
|
-
broadcastToRelay(data);
|
|
408
|
-
|
|
409
|
-
const event = getInnerEvent(data);
|
|
410
|
-
const message = event.message;
|
|
411
|
-
if (!message) return;
|
|
412
|
-
|
|
413
|
-
const messageId = message.message_id;
|
|
414
|
-
if (messageId) {
|
|
415
|
-
if (processedMessages.has(messageId)) {
|
|
416
|
-
console.log(`[MSG] Duplicate message ignored: ${messageId}`);
|
|
417
|
-
return;
|
|
418
|
-
}
|
|
419
|
-
processedMessages.add(messageId);
|
|
420
|
-
if (processedMessages.size > MAX_PROCESSED) {
|
|
421
|
-
const it = processedMessages.values();
|
|
422
|
-
for (let i = 0; i < 1000; i++) processedMessages.delete(it.next().value as string);
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
const text = await formatMessageContent(message);
|
|
427
|
-
const sender = event.sender;
|
|
428
|
-
const openId = sender?.sender_id?.open_id ?? "";
|
|
429
|
-
const chatId = message.chat_id ?? "";
|
|
430
|
-
const chatType = message.chat_type ?? "group";
|
|
431
|
-
|
|
432
|
-
console.log(`[MSG] sender=${openId} chat=${chatId} type=${chatType} text="${text}"`);
|
|
433
|
-
appendChatLog(chatId, openId, text);
|
|
434
|
-
|
|
435
|
-
if (messageId) {
|
|
436
|
-
getTenantAccessToken().then((freshToken) =>
|
|
437
|
-
addReaction(freshToken, messageId).catch((err) =>
|
|
438
|
-
console.error(`[${ts()}] Reaction failed: ${(err as Error).message}`)
|
|
439
|
-
)
|
|
440
|
-
).catch((err) =>
|
|
441
|
-
console.error(`[${ts()}] Reaction token failed: ${(err as Error).message}`)
|
|
442
|
-
);
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
if (!text) return;
|
|
446
|
-
const msgTimestamp = parseInt(message.create_time ?? "0", 10) || Date.now();
|
|
447
|
-
logTrace(traceId, "RECV", { chatId, chatType, text: text.slice(0, 100) });
|
|
448
|
-
const delayNotice = formatDelayNotice(msgTimestamp, text);
|
|
449
|
-
if (delayNotice) {
|
|
450
|
-
const delayToken = await getTenantAccessToken();
|
|
451
|
-
await sendCardReply(delayToken, chatId, "延迟送达", delayNotice, "yellow").catch(() => {});
|
|
452
|
-
}
|
|
453
|
-
// 仅 `/update` 会使用这个稳定 ID 做跨重启幂等;其他命令仍沿用
|
|
454
|
-
// processedMessages 的进程内去重。
|
|
455
|
-
await handleCommand(
|
|
456
|
-
feishuPlatform,
|
|
457
|
-
text,
|
|
458
|
-
chatId,
|
|
459
|
-
openId,
|
|
460
|
-
msgTimestamp,
|
|
461
|
-
chatType,
|
|
462
|
-
traceId,
|
|
463
|
-
buildUpdateCommandId("message", messageId),
|
|
464
|
-
);
|
|
465
|
-
} catch (err) {
|
|
466
|
-
logTrace(traceId, "ERROR", { message: (err as Error).message });
|
|
467
|
-
console.error(`[${ts()}] [FATAL] im.message.receive_v1 handler crashed: ${(err as Error).message}`);
|
|
468
|
-
}
|
|
469
|
-
},
|
|
497
|
+
const eventDispatcher = new EventDispatcher({});
|
|
498
|
+
eventDispatcher.register({
|
|
499
|
+
"im.message.receive_v1": handleFeishuMessageEventAckFirst,
|
|
470
500
|
|
|
471
501
|
"card.action.trigger": async (data: Evt) => {
|
|
472
502
|
try {
|
|
@@ -593,7 +623,8 @@ async function startBotServiceCore(): Promise<void> {
|
|
|
593
623
|
} else {
|
|
594
624
|
// 进程首次启动:此时所有 Map 都是空的,resetState 主要是打个 LOG 标识"开始
|
|
595
625
|
// 干净状态"。修正残留的 running stream-state 并重建 session→chat 映射。
|
|
596
|
-
resetState();
|
|
626
|
+
resetState();
|
|
627
|
+
await feishuMessageLedger.load();
|
|
597
628
|
startUnifiedDisplayLoop();
|
|
598
629
|
fixStaleStreamStates().then(async () => {
|
|
599
630
|
const registry = await loadSessionRegistryForBinding();
|
package/src/session.ts
CHANGED
|
@@ -42,6 +42,13 @@ import { resourceMonitor, registerProcess, unregisterProcess } from "./adapters/
|
|
|
42
42
|
import { buildImSkillsPromptCached, exportSkillSubDocs, clearImSkillsPromptCache } from "./im-skills.ts";
|
|
43
43
|
import type { PlatformAdapter } from "./platform-adapter.ts";
|
|
44
44
|
import { hasResponseStalled, observeResponseProgress } from "./response-stall.ts";
|
|
45
|
+
import {
|
|
46
|
+
MAX_PROCESSED,
|
|
47
|
+
clearFeishuMessageLedgerMemory,
|
|
48
|
+
processedMessages,
|
|
49
|
+
} from "./feishu-message-ingress.ts";
|
|
50
|
+
|
|
51
|
+
export { MAX_PROCESSED, processedMessages };
|
|
45
52
|
|
|
46
53
|
// 微信显示循环压缩:头5 + ... + 尾5,避免在最后一步 sendText 中压缩指令回复
|
|
47
54
|
function compressWechatDisplayText(text: string): string {
|
|
@@ -131,9 +138,6 @@ async function createVisibleProgressCard(
|
|
|
131
138
|
// Shared state (imported by index.ts)
|
|
132
139
|
// ---------------------------------------------------------------------------
|
|
133
140
|
|
|
134
|
-
export const processedMessages = new Set<string>();
|
|
135
|
-
export const MAX_PROCESSED = 5000;
|
|
136
|
-
|
|
137
141
|
/** 每个 chatId 上一次已处理消息的时间戳,用于拦截延迟送达的旧消息 */
|
|
138
142
|
export const lastMsgTimestamps = new Map<string, number>();
|
|
139
143
|
|
|
@@ -463,7 +467,7 @@ export function resetState(): void {
|
|
|
463
467
|
}
|
|
464
468
|
chatSessionMap.clear();
|
|
465
469
|
sessionInfoMap.clear();
|
|
466
|
-
|
|
470
|
+
clearFeishuMessageLedgerMemory();
|
|
467
471
|
lastMsgTimestamps.clear();
|
|
468
472
|
chatPlatformMap.clear();
|
|
469
473
|
for (const prompt of activePrompts.values()) {
|