chatccc 0.2.39 → 0.2.41
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/demo/ilink_echo_probe.ts +222 -0
- package/package.json +6 -1
- package/src/__tests__/crash-logging.test.ts +62 -6
- package/src/shared.ts +63 -40
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenILink WeChat Echo Probe
|
|
3
|
+
* ===========================
|
|
4
|
+
* Minimal scan-login and text echo demo for the WeChat iLink channel.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* npm run demo:ilink-echo
|
|
8
|
+
*
|
|
9
|
+
* Behavior:
|
|
10
|
+
* When a user sends text to the logged-in WeChat account, this demo replies
|
|
11
|
+
* with "收到:" plus the received text.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { createRequire } from "node:module";
|
|
16
|
+
import { dirname, join } from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
Client as OpenIlinkWire,
|
|
21
|
+
extractText,
|
|
22
|
+
type GetUpdatesResponse,
|
|
23
|
+
type WeixinMessage,
|
|
24
|
+
} from "@openilink/openilink-sdk-node";
|
|
25
|
+
|
|
26
|
+
import { setupFileLogging } from "../src/shared.ts";
|
|
27
|
+
|
|
28
|
+
interface TerminalQrRenderer {
|
|
29
|
+
generate(
|
|
30
|
+
input: string,
|
|
31
|
+
opts: { small: boolean },
|
|
32
|
+
callback: (qrcode: string) => void,
|
|
33
|
+
): void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface EchoProbeSnapshot {
|
|
37
|
+
token?: string;
|
|
38
|
+
baseUrl?: string;
|
|
39
|
+
pollCursor?: string;
|
|
40
|
+
botId?: string;
|
|
41
|
+
userId?: string;
|
|
42
|
+
lastSeenAt?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
46
|
+
const PROJECT_ROOT = join(__dirname, "..");
|
|
47
|
+
const SNAPSHOT_PATH = join(PROJECT_ROOT, "state", "ilink-echo-probe.json");
|
|
48
|
+
const LOG_DIR = join(__dirname, "logs");
|
|
49
|
+
const requireFromDemo = createRequire(import.meta.url);
|
|
50
|
+
const terminalQr = requireFromDemo("qrcode-terminal") as TerminalQrRenderer;
|
|
51
|
+
|
|
52
|
+
setupFileLogging(LOG_DIR, "ilink-echo-probe");
|
|
53
|
+
|
|
54
|
+
let acceptingEvents = true;
|
|
55
|
+
|
|
56
|
+
function ensureParentDir(filePath: string): void {
|
|
57
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readSnapshot(): EchoProbeSnapshot {
|
|
61
|
+
if (!existsSync(SNAPSHOT_PATH)) {
|
|
62
|
+
return {};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
return JSON.parse(readFileSync(SNAPSHOT_PATH, "utf8")) as EchoProbeSnapshot;
|
|
67
|
+
} catch (error) {
|
|
68
|
+
console.warn(`Cannot read saved iLink probe state: ${(error as Error).message}`);
|
|
69
|
+
return {};
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function writeSnapshot(snapshot: EchoProbeSnapshot): void {
|
|
74
|
+
ensureParentDir(SNAPSHOT_PATH);
|
|
75
|
+
writeFileSync(SNAPSHOT_PATH, `${JSON.stringify(snapshot, null, 2)}\n`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function updateSnapshot(patch: Partial<EchoProbeSnapshot>): EchoProbeSnapshot {
|
|
79
|
+
const next = {
|
|
80
|
+
...readSnapshot(),
|
|
81
|
+
...patch,
|
|
82
|
+
lastSeenAt: new Date().toISOString(),
|
|
83
|
+
};
|
|
84
|
+
writeSnapshot(next);
|
|
85
|
+
return next;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function peerKeyOf(message: WeixinMessage): string {
|
|
89
|
+
return String(message.from_user_id ?? "");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function textOf(message: WeixinMessage): string {
|
|
93
|
+
return extractText(message).trim();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function printScanMaterial(content: string): void {
|
|
97
|
+
console.log("\n========== iLink scan content ==========");
|
|
98
|
+
console.log(content);
|
|
99
|
+
console.log("========== iLink terminal QR ==========");
|
|
100
|
+
terminalQr.generate(content, { small: true }, (renderedQr) => {
|
|
101
|
+
console.log(renderedQr);
|
|
102
|
+
});
|
|
103
|
+
console.log("========== end iLink QR ==========\n");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function prepareProbeWire(saved: EchoProbeSnapshot): Promise<OpenIlinkWire> {
|
|
107
|
+
const ilinkWire = new OpenIlinkWire("", {
|
|
108
|
+
base_url: saved.baseUrl,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
console.log("Starting iLink QR login. The QR is shown on every run.");
|
|
112
|
+
const loginResult = await ilinkWire.loginWithQr({
|
|
113
|
+
on_qrcode: (content) => {
|
|
114
|
+
printScanMaterial(content);
|
|
115
|
+
},
|
|
116
|
+
on_scanned: () => {
|
|
117
|
+
console.log("QR scanned. Confirm login in WeChat.");
|
|
118
|
+
},
|
|
119
|
+
on_expired: (attempt, maxAttempts) => {
|
|
120
|
+
console.log(`QR expired, refreshing (${attempt}/${maxAttempts}).`);
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
if (!loginResult.connected) {
|
|
125
|
+
throw new Error(`iLink QR login failed: ${loginResult.message}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
updateSnapshot({
|
|
129
|
+
token: loginResult.bot_token ?? ilinkWire.token,
|
|
130
|
+
baseUrl: loginResult.base_url ?? ilinkWire.baseUrl,
|
|
131
|
+
botId: loginResult.bot_id,
|
|
132
|
+
userId: loginResult.user_id,
|
|
133
|
+
pollCursor: "",
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
console.log(`iLink login ready. BotID=${loginResult.bot_id ?? ""}`);
|
|
137
|
+
return ilinkWire;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function mirrorIncomingLine(
|
|
141
|
+
ilinkWire: OpenIlinkWire,
|
|
142
|
+
message: WeixinMessage,
|
|
143
|
+
): Promise<void> {
|
|
144
|
+
const peerKey = peerKeyOf(message);
|
|
145
|
+
if (!peerKey) {
|
|
146
|
+
console.warn("Skip message without from_user_id.");
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const incomingText = textOf(message);
|
|
151
|
+
const outgoingText = incomingText ? `收到:${incomingText}` : "收到:[non-text message]";
|
|
152
|
+
const contextToken = message.context_token;
|
|
153
|
+
|
|
154
|
+
if (contextToken) {
|
|
155
|
+
await ilinkWire.sendText(peerKey, outgoingText, contextToken);
|
|
156
|
+
} else {
|
|
157
|
+
await ilinkWire.push(peerKey, outgoingText);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
console.log(`Echoed to ${peerKey}: ${outgoingText.slice(0, 120)}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function rememberPollingCursor(response: GetUpdatesResponse): void {
|
|
164
|
+
const pollCursor = response.sync_buf ?? response.get_updates_buf;
|
|
165
|
+
if (!pollCursor) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
updateSnapshot({ pollCursor });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function installStopHooks(): void {
|
|
172
|
+
const stop = (): void => {
|
|
173
|
+
if (!acceptingEvents) {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
acceptingEvents = false;
|
|
177
|
+
console.log("Stopping iLink echo probe...");
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
process.once("SIGINT", stop);
|
|
181
|
+
process.once("SIGTERM", stop);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function runProbe(): Promise<void> {
|
|
185
|
+
installStopHooks();
|
|
186
|
+
|
|
187
|
+
const saved = readSnapshot();
|
|
188
|
+
const ilinkWire = await prepareProbeWire(saved);
|
|
189
|
+
const latest = readSnapshot();
|
|
190
|
+
|
|
191
|
+
console.log("Listening for WeChat messages. Send text to the small account.");
|
|
192
|
+
console.log(`State file: ${SNAPSHOT_PATH}`);
|
|
193
|
+
console.log(`Log dir: ${LOG_DIR}`);
|
|
194
|
+
|
|
195
|
+
await ilinkWire.monitor(
|
|
196
|
+
async (message) => {
|
|
197
|
+
try {
|
|
198
|
+
await mirrorIncomingLine(ilinkWire, message);
|
|
199
|
+
} catch (error) {
|
|
200
|
+
console.error(`Echo failed: ${(error as Error).stack ?? (error as Error).message}`);
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
initial_buf: latest.pollCursor ?? "",
|
|
205
|
+
on_buf_update: (pollCursor) => updateSnapshot({ pollCursor }),
|
|
206
|
+
on_response: rememberPollingCursor,
|
|
207
|
+
on_error: (error) => {
|
|
208
|
+
console.error(`iLink monitor error: ${error.stack ?? error.message}`);
|
|
209
|
+
},
|
|
210
|
+
on_session_expired: () => {
|
|
211
|
+
console.error("iLink session expired. Restart with --fresh to scan again.");
|
|
212
|
+
acceptingEvents = false;
|
|
213
|
+
},
|
|
214
|
+
should_continue: () => acceptingEvents,
|
|
215
|
+
},
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
runProbe().catch((error: Error) => {
|
|
220
|
+
console.error(`Fatal iLink echo probe error: ${error.stack ?? error.message}`);
|
|
221
|
+
process.exitCode = 1;
|
|
222
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chatccc",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.41",
|
|
4
4
|
"description": "Feishu bot bridge for Claude Code",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"files": [
|
|
12
12
|
"src/",
|
|
13
13
|
"bin/",
|
|
14
|
+
"demo/ilink_echo_probe.ts",
|
|
14
15
|
"im-skills/",
|
|
15
16
|
"images/img_readme_*.jpg",
|
|
16
17
|
"images/img_readme_*.png",
|
|
@@ -31,19 +32,23 @@
|
|
|
31
32
|
"demo:permission-check": "tsx demo/permission_check.ts",
|
|
32
33
|
"demo:claude-hi": "tsx demo/claude_say_hi.ts",
|
|
33
34
|
"demo:codex-hi": "tsx demo/codex_say_hi.ts",
|
|
35
|
+
"demo:ilink-echo": "tsx demo/ilink_echo_probe.ts",
|
|
34
36
|
"test": "vitest run",
|
|
35
37
|
"test:watch": "vitest"
|
|
36
38
|
},
|
|
37
39
|
"dependencies": {
|
|
38
40
|
"@anthropic-ai/claude-agent-sdk": "0.2.133",
|
|
39
41
|
"@larksuiteoapi/node-sdk": "^1.59.0",
|
|
42
|
+
"@openilink/openilink-sdk-node": "^0.6.0",
|
|
40
43
|
"nodemailer": "^8.0.7",
|
|
44
|
+
"qrcode-terminal": "^0.12.0",
|
|
41
45
|
"sharp": "^0.34.5",
|
|
42
46
|
"tsx": "^4.0.0",
|
|
43
47
|
"ws": "^8.18.0"
|
|
44
48
|
},
|
|
45
49
|
"devDependencies": {
|
|
46
50
|
"@types/node": "^20.0.0",
|
|
51
|
+
"@types/qrcode-terminal": "^0.12.2",
|
|
47
52
|
"@types/ws": "^8.18.1",
|
|
48
53
|
"typescript": "^5.0.0",
|
|
49
54
|
"vitest": "^4.1.5"
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
import {
|
|
1
|
+
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { describe, it, expect, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
import { buildCrashLoggingHandlers, installCrashLogging, setupFileLogging } from "../shared.ts";
|
|
4
7
|
|
|
5
8
|
describe("buildCrashLoggingHandlers", () => {
|
|
6
9
|
it("uncaughtException: 写诊断、刷新日志、调用 onFatal", () => {
|
|
@@ -141,7 +144,7 @@ describe("buildCrashLoggingHandlers", () => {
|
|
|
141
144
|
});
|
|
142
145
|
});
|
|
143
146
|
|
|
144
|
-
describe("installCrashLogging", () => {
|
|
147
|
+
describe("installCrashLogging", () => {
|
|
145
148
|
it("注册所有相关事件监听器", () => {
|
|
146
149
|
const before = {
|
|
147
150
|
uncaught: process.listenerCount("uncaughtException"),
|
|
@@ -206,5 +209,58 @@ describe("installCrashLogging", () => {
|
|
|
206
209
|
} finally {
|
|
207
210
|
cleanup();
|
|
208
211
|
}
|
|
209
|
-
});
|
|
210
|
-
});
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe("setupFileLogging", () => {
|
|
216
|
+
it("flush 后继续写日志不会触发 write after end,且日志已落盘", async () => {
|
|
217
|
+
const originalLog = console.log;
|
|
218
|
+
const originalError = console.error;
|
|
219
|
+
console.log = vi.fn() as never;
|
|
220
|
+
console.error = vi.fn() as never;
|
|
221
|
+
const dir = await mkdtemp(join(tmpdir(), "chatccc-log-"));
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
const fileLog = setupFileLogging(dir, "index");
|
|
225
|
+
|
|
226
|
+
console.log("before flush");
|
|
227
|
+
fileLog.flush();
|
|
228
|
+
|
|
229
|
+
expect(() => console.error("after flush")).not.toThrow();
|
|
230
|
+
fileLog.flush();
|
|
231
|
+
|
|
232
|
+
const content = await readFile(fileLog.logPath, "utf8");
|
|
233
|
+
expect(content).toContain("[LOG] before flush");
|
|
234
|
+
expect(content).toContain("[ERR] after flush");
|
|
235
|
+
} finally {
|
|
236
|
+
console.log = originalLog;
|
|
237
|
+
console.error = originalError;
|
|
238
|
+
await rm(dir, { recursive: true, force: true });
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("日志参数无法 JSON 序列化时也不会让 console 调用抛错", async () => {
|
|
243
|
+
const originalLog = console.log;
|
|
244
|
+
const originalError = console.error;
|
|
245
|
+
console.log = vi.fn() as never;
|
|
246
|
+
console.error = vi.fn() as never;
|
|
247
|
+
const dir = await mkdtemp(join(tmpdir(), "chatccc-log-"));
|
|
248
|
+
|
|
249
|
+
try {
|
|
250
|
+
const fileLog = setupFileLogging(dir, "index");
|
|
251
|
+
const circular: Record<string, unknown> = { name: "root" };
|
|
252
|
+
circular.self = circular;
|
|
253
|
+
|
|
254
|
+
expect(() => console.log("circular", circular)).not.toThrow();
|
|
255
|
+
fileLog.flush();
|
|
256
|
+
|
|
257
|
+
const content = await readFile(fileLog.logPath, "utf8");
|
|
258
|
+
expect(content).toContain("[LOG] circular");
|
|
259
|
+
expect(content).toContain("self");
|
|
260
|
+
} finally {
|
|
261
|
+
console.log = originalLog;
|
|
262
|
+
console.error = originalError;
|
|
263
|
+
await rm(dir, { recursive: true, force: true });
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
});
|
package/src/shared.ts
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
import { execSync } from "node:child_process";
|
|
2
|
-
import {
|
|
3
|
-
appendFileSync,
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
} from "node:
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import { WebSocketServer, WebSocket } from "ws";
|
|
2
|
+
import {
|
|
3
|
+
appendFileSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
unlinkSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from "node:fs";
|
|
10
|
+
import { createServer } from "node:http";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { inspect } from "node:util";
|
|
14
|
+
import { WebSocketServer, WebSocket } from "ws";
|
|
15
15
|
|
|
16
16
|
import { printServiceDidNotStart } from "./exit-banner.ts";
|
|
17
17
|
|
|
@@ -355,33 +355,56 @@ export function installCrashLogging(opts: CrashHandlersOptions = {}): InstallCra
|
|
|
355
355
|
// 文件日志:同时输出到控制台和日志文件
|
|
356
356
|
// ---------------------------------------------------------------------------
|
|
357
357
|
|
|
358
|
-
export function setupFileLogging(logDir: string, prefix: string): { logPath: string; flush: () => void } {
|
|
359
|
-
mkdirSync(logDir, { recursive: true });
|
|
360
|
-
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
361
|
-
const logPath = join(logDir, `${prefix}-${ts}.log`);
|
|
362
|
-
|
|
363
|
-
const origConsoleLog = console.log.bind(console);
|
|
364
|
-
const origConsoleError = console.error.bind(console);
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
};
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
358
|
+
export function setupFileLogging(logDir: string, prefix: string): { logPath: string; flush: () => void } {
|
|
359
|
+
mkdirSync(logDir, { recursive: true });
|
|
360
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
361
|
+
const logPath = join(logDir, `${prefix}-${ts}.log`);
|
|
362
|
+
writeFileSync(logPath, "", { flag: "a", encoding: "utf8" });
|
|
363
|
+
const origConsoleLog = console.log.bind(console);
|
|
364
|
+
const origConsoleError = console.error.bind(console);
|
|
365
|
+
const formatArg = (arg: unknown): string => {
|
|
366
|
+
if (typeof arg === "string") return arg;
|
|
367
|
+
if (arg instanceof Error) return arg.stack ?? arg.message;
|
|
368
|
+
return inspect(arg, { depth: 6, breakLength: Infinity, maxArrayLength: 200 });
|
|
369
|
+
};
|
|
370
|
+
const writeLine = (level: string, args: unknown[]) => {
|
|
371
|
+
try {
|
|
372
|
+
const line = args.map(formatArg).join(" ");
|
|
373
|
+
appendFileSync(logPath, `[${new Date().toISOString()}] [${level}] ${line}\n`, "utf8");
|
|
374
|
+
} catch (err) {
|
|
375
|
+
try {
|
|
376
|
+
appendStartupTrace("fileLog write failed", { level, error: err instanceof Error ? err.message : String(err) });
|
|
377
|
+
} catch {
|
|
378
|
+
// 日志系统自身不能影响主流程
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
console.log = (...args: unknown[]) => {
|
|
383
|
+
writeLine("LOG", args);
|
|
384
|
+
try {
|
|
385
|
+
origConsoleLog(...args);
|
|
386
|
+
} catch {
|
|
387
|
+
// 控制台输出失败也不能拖垮服务
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
console.error = (...args: unknown[]) => {
|
|
391
|
+
writeLine("ERR", args);
|
|
392
|
+
try {
|
|
393
|
+
origConsoleError(...args);
|
|
394
|
+
} catch {
|
|
395
|
+
// 控制台输出失败也不能拖垮服务
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
const flush = () => {
|
|
399
|
+
try {
|
|
400
|
+
appendFileSync(logPath, "", "utf8");
|
|
401
|
+
} catch (err) {
|
|
402
|
+
appendStartupTrace("fileLog flush failed", { error: err instanceof Error ? err.message : String(err) });
|
|
403
|
+
}
|
|
404
|
+
};
|
|
405
|
+
origConsoleLog(`Log file: ${logPath}`);
|
|
406
|
+
return { logPath, flush };
|
|
407
|
+
}
|
|
385
408
|
|
|
386
409
|
// ---------------------------------------------------------------------------
|
|
387
410
|
// 本地 WebSocket 中继服务器(同一端口、多客户端广播)
|