chatccc 0.2.214 → 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/README.md +12 -7
- package/config.sample.json +2 -1
- package/package.json +1 -1
- package/src/__tests__/cards.test.ts +37 -1
- package/src/__tests__/codex-adapter.test.ts +18 -3
- package/src/__tests__/config-reload.test.ts +6 -5
- package/src/__tests__/config-sample.test.ts +8 -7
- package/src/__tests__/feishu-message-ingress.test.ts +138 -0
- package/src/__tests__/orchestrator.test.ts +60 -1
- package/src/__tests__/session.test.ts +23 -1
- package/src/__tests__/sim-platform.test.ts +4 -3
- package/src/__tests__/web-ui.test.ts +17 -2
- package/src/adapters/codex-adapter.ts +40 -6
- package/src/cards.ts +43 -2
- package/src/config.ts +10 -6
- package/src/feishu-api.ts +1 -1
- package/src/feishu-message-ingress.ts +195 -0
- package/src/index.ts +112 -81
- package/src/orchestrator.ts +116 -21
- package/src/session.ts +34 -9
- package/src/web-ui.ts +54 -37
package/src/cards.ts
CHANGED
|
@@ -553,8 +553,12 @@ export function buildModelCard(
|
|
|
553
553
|
lines.push("", "没有可切换的模型。请在 config.json 中配置模型字段。");
|
|
554
554
|
}
|
|
555
555
|
|
|
556
|
-
|
|
557
|
-
|
|
556
|
+
if (tool === "codex") {
|
|
557
|
+
lines.push("输入 `/fast` 查看或切换当前会话的 Fast 模式");
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const buttons: ButtonDef[] = [];
|
|
561
|
+
for (const m of models.slice(0, 20)) {
|
|
558
562
|
const shortName = m.includes("/") ? m.slice(m.lastIndexOf("/") + 1) : m;
|
|
559
563
|
buttons.push({
|
|
560
564
|
text: `/model ${shortName}`,
|
|
@@ -574,6 +578,43 @@ export function buildModelCard(
|
|
|
574
578
|
});
|
|
575
579
|
}
|
|
576
580
|
|
|
581
|
+
export function buildFastModeCard(enabled: boolean): string {
|
|
582
|
+
const mode = enabled ? "ON (Fast)" : "OFF (Standard)";
|
|
583
|
+
return JSON.stringify({
|
|
584
|
+
config: { wide_screen_mode: true },
|
|
585
|
+
header: {
|
|
586
|
+
template: enabled ? "green" : "blue",
|
|
587
|
+
title: { content: "Codex Fast 模式", tag: "plain_text" },
|
|
588
|
+
},
|
|
589
|
+
elements: [
|
|
590
|
+
{
|
|
591
|
+
tag: "div",
|
|
592
|
+
text: {
|
|
593
|
+
tag: "lark_md",
|
|
594
|
+
content: [
|
|
595
|
+
`**当前模式:** ${mode}`,
|
|
596
|
+
"",
|
|
597
|
+
"切换将在下一条消息生效,当前生成不中断。",
|
|
598
|
+
].join("\n"),
|
|
599
|
+
},
|
|
600
|
+
},
|
|
601
|
+
{ tag: "hr" },
|
|
602
|
+
buildButtons([
|
|
603
|
+
{
|
|
604
|
+
text: "ON",
|
|
605
|
+
value: JSON.stringify({ cmd: "/fast on" }),
|
|
606
|
+
type: enabled ? "primary" : "default",
|
|
607
|
+
},
|
|
608
|
+
{
|
|
609
|
+
text: "OFF",
|
|
610
|
+
value: JSON.stringify({ cmd: "/fast off" }),
|
|
611
|
+
type: enabled ? "default" : "primary",
|
|
612
|
+
},
|
|
613
|
+
]),
|
|
614
|
+
],
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
|
|
577
618
|
export function buildEffortCard(
|
|
578
619
|
currentEffort: string,
|
|
579
620
|
efforts: string[],
|
package/src/config.ts
CHANGED
|
@@ -97,8 +97,10 @@ export interface CodexConfig {
|
|
|
97
97
|
path: string;
|
|
98
98
|
model: string;
|
|
99
99
|
/** /model 可切换的单个备选模型;留空则不加入候选列表 */
|
|
100
|
-
alternativeModel: string;
|
|
100
|
+
alternativeModel: string;
|
|
101
101
|
effort: string;
|
|
102
|
+
/** Codex Priority service tier. False explicitly forces the standard tier. */
|
|
103
|
+
fastMode: boolean;
|
|
102
104
|
}
|
|
103
105
|
|
|
104
106
|
export interface CccConfig {
|
|
@@ -442,7 +444,7 @@ function loadConfig(): AppConfig {
|
|
|
442
444
|
avatarBatteryMode: "apiPercent",
|
|
443
445
|
onDemandMonthlyBudget: 1000,
|
|
444
446
|
},
|
|
445
|
-
codex: { enabled: false, defaultAgent: false, path: "", model: "", alternativeModel: "", effort: "" },
|
|
447
|
+
codex: { enabled: false, defaultAgent: false, path: "", model: "", alternativeModel: "", effort: "", fastMode: false },
|
|
446
448
|
ccc: { DEEPSEEK_API_KEY: "", DEEPSEEK_BASE_URL: DEFAULT_CCC_DEEPSEEK_BASE_URL, model: DEFAULT_CCC_MODEL },
|
|
447
449
|
};
|
|
448
450
|
|
|
@@ -495,7 +497,7 @@ function loadConfig(): AppConfig {
|
|
|
495
497
|
avatarBatteryMode?: unknown;
|
|
496
498
|
onDemandMonthlyBudget?: unknown;
|
|
497
499
|
};
|
|
498
|
-
codex?: { enabled?: unknown; defaultAgent?: unknown; path?: unknown; command?: unknown; model?: unknown; alternativeModel?: unknown; effort?: unknown };
|
|
500
|
+
codex?: { enabled?: unknown; defaultAgent?: unknown; path?: unknown; command?: unknown; model?: unknown; alternativeModel?: unknown; effort?: unknown; fastMode?: unknown };
|
|
499
501
|
ccc?: { DEEPSEEK_API_KEY?: unknown; DEEPSEEK_BASE_URL?: unknown; model?: unknown };
|
|
500
502
|
webUi?: { openOnStart?: unknown };
|
|
501
503
|
chromeDevtools?: { enabled?: unknown; port?: unknown; chromePath?: unknown };
|
|
@@ -555,9 +557,10 @@ function loadConfig(): AppConfig {
|
|
|
555
557
|
Boolean(
|
|
556
558
|
(typeof codexRaw.path === "string" && codexRaw.path.trim()) ||
|
|
557
559
|
(typeof codexRaw.command === "string" && (codexRaw.command as string).trim()) ||
|
|
558
|
-
(typeof codexRaw.model === "string" && (codexRaw.model as string).trim()) ||
|
|
559
|
-
(typeof codexRaw.alternativeModel === "string" && (codexRaw.alternativeModel as string).trim()) ||
|
|
560
|
-
(typeof codexRaw.effort === "string" && (codexRaw.effort as string).trim())
|
|
560
|
+
(typeof codexRaw.model === "string" && (codexRaw.model as string).trim()) ||
|
|
561
|
+
(typeof codexRaw.alternativeModel === "string" && (codexRaw.alternativeModel as string).trim()) ||
|
|
562
|
+
(typeof codexRaw.effort === "string" && (codexRaw.effort as string).trim()) ||
|
|
563
|
+
codexRaw.fastMode === true,
|
|
561
564
|
);
|
|
562
565
|
|
|
563
566
|
const claudeEnabled = resolveEnabled(claude.enabled, claudeNonEmpty);
|
|
@@ -649,6 +652,7 @@ function loadConfig(): AppConfig {
|
|
|
649
652
|
model: normalizeOptionalConfigField(codexRaw.model, { label: "codex.model" }),
|
|
650
653
|
alternativeModel: normalizeOptionalConfigField(codexRaw.alternativeModel, { label: "codex.alternativeModel" }),
|
|
651
654
|
effort: normalizeOptionalConfigField(codexRaw.effort, { label: "codex.effort" }),
|
|
655
|
+
fastMode: codexRaw.fastMode === true,
|
|
652
656
|
},
|
|
653
657
|
ccc: {
|
|
654
658
|
DEEPSEEK_API_KEY: normalizeOptionalConfigField(cccRaw.DEEPSEEK_API_KEY, { label: "ccc.DEEPSEEK_API_KEY" }),
|
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();
|