chatccc 0.1.6 → 0.2.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.
- package/README.md +10 -12
- package/bin/chatccc.mjs +24 -6
- package/package.json +2 -1
- package/src/cardkit.ts +157 -157
- package/src/cards.ts +214 -169
- package/src/config.ts +223 -80
- package/src/exit-banner.ts +23 -0
- package/src/feishu-api.ts +248 -243
- package/src/index.ts +708 -599
- package/src/session.ts +455 -409
- package/src/shared.ts +285 -185
package/src/shared.ts
CHANGED
|
@@ -1,185 +1,285 @@
|
|
|
1
|
-
import { execSync } from "node:child_process";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
};
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import {
|
|
3
|
+
appendFileSync,
|
|
4
|
+
createWriteStream,
|
|
5
|
+
existsSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
unlinkSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from "node:fs";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { WebSocketServer, WebSocket } from "ws";
|
|
14
|
+
|
|
15
|
+
import { printServiceDidNotStart } from "./exit-banner.ts";
|
|
16
|
+
|
|
17
|
+
/** 与 config.LOG_DIR 一致(避免 shared 依赖 config 造成循环引用) */
|
|
18
|
+
const BANNER_LOG_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "logs");
|
|
19
|
+
|
|
20
|
+
const STARTUP_TRACE_FILE = join(BANNER_LOG_DIR, "startup-trace.log");
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 同步写入启动诊断日志(不依赖 console 劫持与 stream 缓冲)。
|
|
24
|
+
* 用于单实例清理 / taskkill 等可能导致进程突然退出的场景,便于对照 index-*.log 排查。
|
|
25
|
+
*/
|
|
26
|
+
export function appendStartupTrace(message: string, extra?: Record<string, unknown>): void {
|
|
27
|
+
try {
|
|
28
|
+
mkdirSync(BANNER_LOG_DIR, { recursive: true });
|
|
29
|
+
const suffix = extra !== undefined && Object.keys(extra).length ? ` ${JSON.stringify(extra)}` : "";
|
|
30
|
+
appendFileSync(
|
|
31
|
+
STARTUP_TRACE_FILE,
|
|
32
|
+
`[${new Date().toISOString()}] pid=${process.pid} ppid=${process.ppid} cwd=${process.cwd()} | ${message}${suffix}\n`,
|
|
33
|
+
"utf-8"
|
|
34
|
+
);
|
|
35
|
+
} catch {
|
|
36
|
+
// 诊断日志自身不得影响主流程
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// 进程树:当前进程及其所有祖先(用于避免 taskkill /T 误杀启动链)
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
/** 自当前 PID 沿父链上溯直到系统进程,包含自身 */
|
|
45
|
+
export function getProcessAncestorPidSet(): Set<number> {
|
|
46
|
+
const ancestors = new Set<number>();
|
|
47
|
+
let cur: number = process.pid;
|
|
48
|
+
const maxHops = 48;
|
|
49
|
+
for (let i = 0; i < maxHops; i++) {
|
|
50
|
+
ancestors.add(cur);
|
|
51
|
+
if (cur <= 4) break;
|
|
52
|
+
let parent: number | undefined;
|
|
53
|
+
if (process.platform === "win32") {
|
|
54
|
+
try {
|
|
55
|
+
const out = execSync(
|
|
56
|
+
`powershell -NoProfile -Command "(Get-CimInstance Win32_Process -Filter 'ProcessId=${cur}' -ErrorAction SilentlyContinue).ParentProcessId"`,
|
|
57
|
+
{ encoding: "utf8", timeout: 3000, stdio: "pipe", windowsHide: true }
|
|
58
|
+
).trim();
|
|
59
|
+
parent = parseInt(out, 10);
|
|
60
|
+
} catch {
|
|
61
|
+
parent = undefined;
|
|
62
|
+
}
|
|
63
|
+
} else {
|
|
64
|
+
try {
|
|
65
|
+
const st = readFileSync(`/proc/${cur}/stat`, "utf8");
|
|
66
|
+
const rp = st.lastIndexOf(")");
|
|
67
|
+
const fields = st.slice(rp + 2).trim().split(/\s+/);
|
|
68
|
+
parent = parseInt(fields[1] ?? "", 10);
|
|
69
|
+
} catch {
|
|
70
|
+
parent = undefined;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (parent === undefined || Number.isNaN(parent) || parent === cur) break;
|
|
74
|
+
cur = parent;
|
|
75
|
+
}
|
|
76
|
+
return ancestors;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// 杀死进程
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
export function killByPid(pid: string | number): void {
|
|
84
|
+
const pidNum = typeof pid === "string" ? parseInt(pid, 10) : pid;
|
|
85
|
+
appendStartupTrace("killByPid: begin", { target: pidNum, self: process.pid, ppid: process.ppid });
|
|
86
|
+
try {
|
|
87
|
+
process.kill(pidNum, "SIGTERM");
|
|
88
|
+
} catch {
|
|
89
|
+
// 不存在,忽略
|
|
90
|
+
}
|
|
91
|
+
let taskkillOut = "";
|
|
92
|
+
try {
|
|
93
|
+
taskkillOut = execSync(`taskkill /PID ${pidNum} /F /T`, { encoding: "utf8", stdio: "pipe" });
|
|
94
|
+
} catch (e: unknown) {
|
|
95
|
+
const err = e as { status?: number; stdout?: Buffer | string; stderr?: Buffer | string };
|
|
96
|
+
const s = (x: Buffer | string | undefined) =>
|
|
97
|
+
(typeof x === "string" ? x : x?.toString("utf8") ?? "").trim().slice(0, 500);
|
|
98
|
+
appendStartupTrace("killByPid: taskkill threw (often 进程不存在)", {
|
|
99
|
+
target: pidNum,
|
|
100
|
+
status: err.status,
|
|
101
|
+
stdout: s(err.stdout),
|
|
102
|
+
stderr: s(err.stderr),
|
|
103
|
+
});
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
appendStartupTrace("killByPid: taskkill ok", { target: pidNum, stdout: String(taskkillOut).trim().slice(0, 500) });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// 在绑定中继端口之前:结束占用该端口的 LISTENING/UDP 绑定进程(任意类型,非祖先链)
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
function collectListeningPidsOnPortWindows(port: number, netstatOut: string): number[] {
|
|
114
|
+
const portToken = `:${port}`;
|
|
115
|
+
const seen = new Set<number>();
|
|
116
|
+
const out: number[] = [];
|
|
117
|
+
for (const rawLine of netstatOut.split("\n")) {
|
|
118
|
+
const line = rawLine.trim();
|
|
119
|
+
if (!line) continue;
|
|
120
|
+
const upper = line.toUpperCase();
|
|
121
|
+
if (upper.startsWith("TCP")) {
|
|
122
|
+
if (!upper.includes("LISTENING")) continue;
|
|
123
|
+
if (!line.includes(portToken)) continue;
|
|
124
|
+
} else if (upper.startsWith("UDP")) {
|
|
125
|
+
if (!line.includes(portToken)) continue;
|
|
126
|
+
} else {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const m = line.match(/(\d+)\s*$/);
|
|
130
|
+
if (!m) continue;
|
|
131
|
+
const n = parseInt(m[1], 10);
|
|
132
|
+
if (Number.isNaN(n) || seen.has(n)) continue;
|
|
133
|
+
seen.add(n);
|
|
134
|
+
out.push(n);
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 在 createRelayServer 之前调用:清掉本机该端口上仍在监听的旧进程(不杀当前进程树祖先) */
|
|
140
|
+
export function freeRelayListenPort(port: number): void {
|
|
141
|
+
const ancestors = getProcessAncestorPidSet();
|
|
142
|
+
appendStartupTrace("freeRelayListenPort: begin", {
|
|
143
|
+
port,
|
|
144
|
+
ancestorPids: [...ancestors].sort((a, b) => a - b).join(","),
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
if (process.platform !== "win32") {
|
|
148
|
+
appendStartupTrace("freeRelayListenPort: skip (non-Windows)", { port });
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
const portOut = execSync(`netstat -ano | findstr :${port}`, { encoding: "utf8", windowsHide: true });
|
|
154
|
+
const pids = collectListeningPidsOnPortWindows(port, portOut);
|
|
155
|
+
appendStartupTrace("freeRelayListenPort: listening PIDs", { port, pids: pids.join(",") || "(none)" });
|
|
156
|
+
for (const pidNum of pids) {
|
|
157
|
+
if (ancestors.has(pidNum)) continue;
|
|
158
|
+
console.log(`[KILL] Free port ${port}: killing LISTENING/UDP holder PID ${pidNum}...`);
|
|
159
|
+
appendStartupTrace("freeRelayListenPort: killByPid", { port, pid: pidNum });
|
|
160
|
+
killByPid(pidNum);
|
|
161
|
+
}
|
|
162
|
+
} catch {
|
|
163
|
+
appendStartupTrace("freeRelayListenPort: netstat/findstr (no rows or error)", { port });
|
|
164
|
+
}
|
|
165
|
+
appendStartupTrace("freeRelayListenPort: end", { port });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
// 单实例保证:PID 文件互斥(端口占用在 freeRelayListenPort + 监听前处理)
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
export function cleanupPidFile(pidFile: string): void {
|
|
173
|
+
try {
|
|
174
|
+
if (existsSync(pidFile) && readFileSync(pidFile, "utf8").trim() === String(process.pid)) {
|
|
175
|
+
unlinkSync(pidFile);
|
|
176
|
+
}
|
|
177
|
+
} catch { /* ok */ }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function ensureSingleInstance(pidFile: string): void {
|
|
181
|
+
const ancestors = getProcessAncestorPidSet();
|
|
182
|
+
appendStartupTrace("ensureSingleInstance: begin", { pidFile });
|
|
183
|
+
if (existsSync(pidFile)) {
|
|
184
|
+
const oldPid = readFileSync(pidFile, "utf8").trim();
|
|
185
|
+
const oldPidNum = parseInt(oldPid, 10);
|
|
186
|
+
if (oldPid && oldPid !== String(process.pid)) {
|
|
187
|
+
if (!Number.isNaN(oldPidNum) && ancestors.has(oldPidNum)) {
|
|
188
|
+
appendStartupTrace("ensureSingleInstance: skip killing pid from file (in ancestor chain)", { oldPid });
|
|
189
|
+
} else {
|
|
190
|
+
console.log(`[INSTANCE] Killing old PID from file: ${oldPid}...`);
|
|
191
|
+
appendStartupTrace("ensureSingleInstance: killing pid from file", { oldPid });
|
|
192
|
+
killByPid(oldPid);
|
|
193
|
+
}
|
|
194
|
+
} else {
|
|
195
|
+
appendStartupTrace("ensureSingleInstance: pid file present, skip self", { oldPid, self: process.pid });
|
|
196
|
+
}
|
|
197
|
+
} else {
|
|
198
|
+
appendStartupTrace("ensureSingleInstance: no pid file yet");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
mkdirSync(join(pidFile, ".."), { recursive: true });
|
|
202
|
+
writeFileSync(pidFile, String(process.pid));
|
|
203
|
+
console.log(`[INSTANCE] Registered PID ${process.pid}`);
|
|
204
|
+
appendStartupTrace("ensureSingleInstance: registered", { pid: process.pid });
|
|
205
|
+
|
|
206
|
+
// 进程退出时自动清理 PID 文件;SIGINT/SIGTERM 由各 main() 自行接管
|
|
207
|
+
process.on("exit", (code) => {
|
|
208
|
+
appendStartupTrace("process exit handler", { code, pid: process.pid });
|
|
209
|
+
cleanupPidFile(pidFile);
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
// 文件日志:同时输出到控制台和日志文件
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
export function setupFileLogging(logDir: string, prefix: string): { logPath: string; flush: () => void } {
|
|
218
|
+
mkdirSync(logDir, { recursive: true });
|
|
219
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
220
|
+
const logPath = join(logDir, `${prefix}-${ts}.log`);
|
|
221
|
+
const logStream = createWriteStream(logPath, { flags: "a" });
|
|
222
|
+
const origConsoleLog = console.log.bind(console);
|
|
223
|
+
const origConsoleError = console.error.bind(console);
|
|
224
|
+
let pending = false;
|
|
225
|
+
const writeLine = (level: string, args: unknown[]) => {
|
|
226
|
+
const line = args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" ");
|
|
227
|
+
pending = true;
|
|
228
|
+
logStream.write(`[${new Date().toISOString()}] [${level}] ${line}\n`, () => { pending = false; });
|
|
229
|
+
};
|
|
230
|
+
console.log = (...args: unknown[]) => {
|
|
231
|
+
writeLine("LOG", args);
|
|
232
|
+
origConsoleLog(...args);
|
|
233
|
+
};
|
|
234
|
+
console.error = (...args: unknown[]) => {
|
|
235
|
+
writeLine("ERR", args);
|
|
236
|
+
origConsoleError(...args);
|
|
237
|
+
};
|
|
238
|
+
const flush = () => {
|
|
239
|
+
if (pending) logStream.end();
|
|
240
|
+
};
|
|
241
|
+
origConsoleLog(`Log file: ${logPath}`);
|
|
242
|
+
return { logPath, flush };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
// 本地 WebSocket 中继服务器(同一端口、多客户端广播)
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
export function createRelayServer(port: number): {
|
|
250
|
+
server: WebSocketServer;
|
|
251
|
+
broadcast: (data: unknown) => void;
|
|
252
|
+
} {
|
|
253
|
+
const clients = new Set<WebSocket>();
|
|
254
|
+
const server = new WebSocketServer({ host: "127.0.0.1", port });
|
|
255
|
+
|
|
256
|
+
server.on("error", (err: NodeJS.ErrnoException) => {
|
|
257
|
+
console.error(`[启动] 本地中继 WebSocket 监听失败:端口 ${port}(${err.code ?? "?"} — ${err.message})`);
|
|
258
|
+
console.error(
|
|
259
|
+
" 处理建议: 关闭占用该端口的其它程序,或在 .env 中设置 CHATCCC_PORT=其它未占用端口(如 18081)。"
|
|
260
|
+
);
|
|
261
|
+
printServiceDidNotStart(`本地中继端口 ${port} 无法监听(${err.code ?? "?"} — ${err.message})`);
|
|
262
|
+
process.exit(1);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
server.on("connection", (ws) => {
|
|
266
|
+
console.log(`[RELAY] Client connected (total: ${clients.size + 1})`);
|
|
267
|
+
clients.add(ws);
|
|
268
|
+
ws.on("close", () => {
|
|
269
|
+
clients.delete(ws);
|
|
270
|
+
console.log(`[RELAY] Client disconnected (total: ${clients.size})`);
|
|
271
|
+
});
|
|
272
|
+
ws.on("error", () => { clients.delete(ws); });
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
console.log(`[RELAY] Local relay listening on ws://127.0.0.1:${port}`);
|
|
276
|
+
|
|
277
|
+
const broadcast = (data: unknown): void => {
|
|
278
|
+
const json = typeof data === "string" ? data : JSON.stringify(data);
|
|
279
|
+
for (const ws of clients) {
|
|
280
|
+
if (ws.readyState === WebSocket.OPEN) ws.send(json);
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
return { server, broadcast };
|
|
285
|
+
}
|