cc-viewer 1.6.288 → 1.6.289

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.
@@ -1,478 +1,32 @@
1
- // DingTalk Stream bridge — connects one bound Claude Code PTY session to a DingTalk bot.
1
+ // DingTalk bridge — thin back-compat shim.
2
2
  //
3
- // Inbound (DingTalk session): TOPIC_ROBOT callback ack immediately msgId dedup
4
- // access control /stop interrupt OR inject the text as a prompt (bracketed paste).
5
- // Outbound (session → DingTalk): on the debounced turn_end, read the Claude session
6
- // transcript JSONL (path forwarded from the Stop hook), assemble the last main-agent
7
- // text turn, chunk it, and push via the proactive App API (sessionWebhook is long expired).
8
- //
9
- // Design notes:
10
- // - This module NEVER imports pty-manager / server.js. All PTY access + the streaming-busy
11
- // probe are injected via `deps`, so the unit test mocks them with zero node-pty / network.
12
- // - The Stream client and outbound HTTP have test seams (__setClientFactory/__setFetchForTests).
13
- // - Outbound text comes from the transcript JSONL on purpose: raw PTY bytes are ANSI/TUI
14
- // noise, and the live log-reconstruction path carries the known mainAgent-dedup bug.
15
- import { existsSync, readFileSync, appendFileSync } from 'node:fs';
16
- import { join } from 'node:path';
17
- import { LOG_DIR } from '../../findcc.js';
18
- import { t } from '../i18n.js';
3
+ // The bridge logic is now split into the generic orchestrator (server/lib/im-bridge-core.js) and
4
+ // the DingTalk adapter (server/lib/adapters/dingtalk-adapter.js). This module preserves the
5
+ // original DingTalk-specific public API (and test seams) so server/routes/dingtalk.js, server.js,
6
+ // and the existing dingtalk-bridge tests keep working unchanged. Importing it registers the
7
+ // DingTalk adapter with the core.
8
+ import * as core from './im-bridge-core.js';
9
+ import { __setClientFactory } from './adapters/dingtalk-adapter.js';
19
10
 
20
- // ─── module-level singleton state (mirrors pty-manager's singleton style) ───
21
- let client = null;
22
- let running = false;
23
- let connected = false;
24
- let lastError = null;
25
- let bridgeDeps = null; // saved for reloadBridge
26
- let boundConversation = null; // { conversationId, conversationType, robotCode }
27
- let pendingReply = null; // reply target for the in-flight turn
28
- let lastRepliedTurnTs = null; // ts of the last turn replied to — idempotency for a doubled turn_end (same ts), without suppressing later turns that repeat a short reply
29
- let tokenCache = null; // { appKey, accessToken, expiresAt }
30
- let pendingReplyTimer = null; // self-heal timer if a turn_end never arrives
31
- const seenMsgIds = []; // FIFO LRU for redelivery dedup
32
- const SEEN_MAX = 500;
33
- const queue = []; // inbound prompts waiting for the session to be free
34
- const sendTimes = []; // outbound timestamps for the rate limiter
35
- const RATE_WINDOW_MS = 60_000;
36
- const RATE_MAX = 18; // stay under DingTalk's 20/min
37
- const MAX_CHUNKS_PER_TURN = 5;
38
- const MAX_QUEUE = 50; // cap inbound backlog so an authorized sender can't grow it unbounded (memory + amplified injection)
39
- const PENDING_TIMEOUT_MS = 10 * 60_000; // clear a stuck pendingReply so the queue can't wedge forever
40
- const STOP_WORDS = new Set(['/stop', 'stop', '停止', 'esc', '/esc']);
11
+ const ID = 'dingtalk';
41
12
 
42
13
  // ─── test seams ───
43
- let clientFactory = null;
44
- export function __setClientFactory(fn) { clientFactory = fn; }
45
- let fetchImpl = null;
46
- export function __setFetchForTests(fn) { fetchImpl = fn; }
47
- function dtFetch(...args) { return (fetchImpl || globalThis.fetch)(...args); }
48
- let maxQueueOverride = null;
49
- export function __setMaxQueueForTests(n) { maxQueueOverride = n; } // lets tests exercise the cap without sending >RATE_MAX notices
50
- function queueCap() { return maxQueueOverride ?? MAX_QUEUE; }
51
- /** Reset all singleton state test helper. */
52
- export function __resetForTests() {
53
- client = null; running = false; connected = false; lastError = null; bridgeDeps = null;
54
- boundConversation = null; pendingReply = null; lastRepliedTurnTs = null; tokenCache = null;
55
- maxQueueOverride = null;
56
- if (pendingReplyTimer) { clearTimeout(pendingReplyTimer); pendingReplyTimer = null; }
57
- seenMsgIds.length = 0; queue.length = 0; sendTimes.length = 0;
58
- }
59
-
60
- // ─── pendingReply lifecycle ───
61
- // pendingReply marks "a bridge-injected turn is in flight; the next turn_end is its reply".
62
- // It is armed on injection and MUST be cleared on reply, /stop, or timeout — otherwise the
63
- // one-at-a-time queue gate (which checks pendingReply) wedges the bridge forever.
64
- function clearPending() {
65
- pendingReply = null;
66
- if (pendingReplyTimer) { clearTimeout(pendingReplyTimer); pendingReplyTimer = null; }
67
- }
68
- function armPending(target) {
69
- pendingReply = {
70
- conversationId: target.conversationId, conversationType: target.conversationType,
71
- robotCode: target.robotCode, senderStaffId: target.senderStaffId, since: Date.now(),
72
- };
73
- if (pendingReplyTimer) clearTimeout(pendingReplyTimer);
74
- pendingReplyTimer = setTimeout(() => {
75
- audit('reply-timeout', { conversationId: pendingReply?.conversationId });
76
- pendingReply = null; pendingReplyTimer = null;
77
- drainQueue(); // turn_end never came (hook missing, model awaiting input, …) → unwedge
78
- }, PENDING_TIMEOUT_MS);
79
- if (typeof pendingReplyTimer.unref === 'function') pendingReplyTimer.unref();
80
- }
81
-
82
- // ─── small helpers ───
83
- function audit(event, data) {
84
- try {
85
- appendFileSync(join(LOG_DIR, 'dingtalk-audit.log'),
86
- JSON.stringify({ ts: new Date().toISOString(), event, ...data }) + '\n');
87
- } catch { /* best-effort */ }
88
- }
89
-
90
- /** Bracketed-paste + submit, matching the frontend's ptyChunkBuilder. Kept local so the
91
- * bridge never imports pty-manager (which would pull node-pty into the unit test). */
92
- function bracketPasteSubmit(text) {
93
- return ['\x1b[200~' + text + '\x1b[201~', '\r'];
94
- }
95
-
96
- // Leading sentinel prepended to injected prompts so the conversation view can show a DingTalk
97
- // icon next to the message. KEEP IN SYNC with IM_ORIGIN_RE in src/utils/imOrigin.js.
98
- const IM_ORIGIN_MARKER = '⟦im:dingtalk⟧';
99
-
100
- /** Prepend the IM-origin marker, EXCEPT for slash commands (a marker prefix would stop the CLI
101
- * from recognizing `/clear` etc.). trim() guards leading whitespace / full-width spaces. */
102
- function markOrigin(content) {
103
- if (content.trim().startsWith('/')) return content;
104
- return IM_ORIGIN_MARKER + content;
105
- }
106
-
107
- /**
108
- * Strip the bracketed-paste terminator/initiator and all C0 control bytes (except newline
109
- * and tab) from untrusted inbound text. Without this, a crafted message containing
110
- * `\x1b[201~` (or other ESC sequences) would break out of the paste frame and inject raw
111
- * keystrokes into the Claude TUI. ESC itself (0x1b) is in the 0x0e–0x1f range so it's removed.
112
- */
113
- function sanitizeInbound(text) {
114
- return String(text)
115
- .replace(/\x1b\[20[01]~/g, '')
116
- // Strip C0 controls EXCEPT tab(0x09)/newline(0x0a). CR(0x0d) is removed too: it is the
117
- // submit key in bracketPasteSubmit, so leaving it in inbound text would be a submit byte
118
- // smuggled into the paste frame — defense-in-depth, not relying on the TUI's paste mode.
119
- // eslint-disable-next-line no-control-regex
120
- .replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '');
121
- }
122
-
123
- function remember(msgId) {
124
- if (!msgId) return false;
125
- if (seenMsgIds.includes(msgId)) return true;
126
- seenMsgIds.push(msgId);
127
- if (seenMsgIds.length > SEEN_MAX) seenMsgIds.shift();
128
- return false;
129
- }
130
-
131
- function ack(res) {
132
- try {
133
- const id = res?.headers?.messageId;
134
- if (!id || !client) return;
135
- if (typeof client.socketCallBackResponse === 'function') {
136
- client.socketCallBackResponse(id, { success: true });
137
- } else if (typeof client.send === 'function') {
138
- client.send(id, JSON.stringify({ status: 'SUCCESS', message: 'OK' }));
139
- }
140
- } catch { /* best-effort; ack failure only risks a redelivery, caught by dedup */ }
141
- }
142
-
143
- function isStopCommand(text) {
144
- return STOP_WORDS.has(text.trim().toLowerCase());
145
- }
146
-
147
- // ─── transcript extraction (the safe outbound text source) ───
148
- function parseLine(line) {
149
- try { const o = JSON.parse(line); return o && typeof o === 'object' ? o : null; }
150
- catch { return null; }
151
- }
152
-
153
- function isRealUserPrompt(obj) {
154
- const c = obj.message?.content;
155
- if (typeof c === 'string') return c.trim().length > 0;
156
- if (Array.isArray(c)) return c.some(b => b && b.type !== 'tool_result'); // tool_result-only = continuation
157
- return false;
158
- }
159
-
160
- /**
161
- * Read the LAST main-agent text turn from a Claude Code transcript JSONL. Walks backward
162
- * from EOF, collecting contiguous assistant `text` blocks, stopping at the previous real
163
- * user prompt. Skips thinking/tool_use blocks, sidechain (subagent) entries, and any
164
- * non-message metadata sidecar lines.
165
- */
166
- export function extractLastAssistantText(transcriptPath) {
167
- if (!transcriptPath || !existsSync(transcriptPath)) return '';
168
- let lines;
169
- try { lines = readFileSync(transcriptPath, 'utf-8').split('\n'); }
170
- catch { return ''; }
171
- const parts = [];
172
- for (let i = lines.length - 1; i >= 0; i--) {
173
- const line = lines[i].trim();
174
- if (!line) continue;
175
- const obj = parseLine(line);
176
- if (!obj || !obj.type) continue;
177
- if (obj.type === 'assistant') {
178
- if (obj.isSidechain) continue;
179
- const content = obj.message?.content;
180
- if (Array.isArray(content)) {
181
- const txt = content.filter(b => b && b.type === 'text').map(b => b.text).join('\n').trim();
182
- if (txt) parts.unshift(txt);
183
- }
184
- continue;
185
- }
186
- if (obj.type === 'user') {
187
- if (obj.isSidechain) continue; // subagent prompt — not the main-agent turn boundary
188
- if (isRealUserPrompt(obj)) break; // start of this turn — stop
189
- continue; // tool_result continuation — keep scanning
190
- }
191
- // system / summary / file-history-snapshot / metadata sidecars → skip
192
- }
193
- return parts.join('\n\n').trim();
194
- }
195
-
196
- // ─── chunking + rate limiting ───
197
- export function chunkText(text, max) {
198
- if (!text) return [];
199
- if (text.length <= max) return [text];
200
- const chunks = [];
201
- let buf = '';
202
- for (const seg of text.split(/(\n\n)/)) {
203
- if ((buf + seg).length <= max) { buf += seg; continue; }
204
- if (buf) { chunks.push(buf); buf = ''; }
205
- if (seg.length > max) {
206
- let rest = seg;
207
- while (rest.length > max) {
208
- let cut = rest.lastIndexOf('\n', max);
209
- if (cut <= 0) cut = rest.lastIndexOf(' ', max);
210
- if (cut <= 0) cut = max;
211
- chunks.push(rest.slice(0, cut));
212
- rest = rest.slice(cut);
213
- }
214
- buf = rest;
215
- } else {
216
- buf = seg;
217
- }
218
- }
219
- if (buf) chunks.push(buf);
220
- return chunks.map(c => c.trim()).filter(Boolean);
221
- }
222
-
223
- async function rateLimitGate() {
224
- const now = Date.now();
225
- while (sendTimes.length && now - sendTimes[0] > RATE_WINDOW_MS) sendTimes.shift();
226
- if (sendTimes.length >= RATE_MAX) {
227
- const wait = RATE_WINDOW_MS - (now - sendTimes[0]) + 50;
228
- await new Promise(r => setTimeout(r, wait));
229
- return rateLimitGate();
230
- }
231
- sendTimes.push(Date.now());
232
- }
233
-
234
- // ─── DingTalk App API (proactive send; sessionWebhook is expired by reply time) ───
235
- const TOKEN_URL = 'https://api.dingtalk.com/v1.0/oauth2/accessToken';
236
- const GROUP_SEND_URL = 'https://api.dingtalk.com/v1.0/robot/groupMessages/send';
237
- const OTO_SEND_URL = 'https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend';
238
-
239
- async function getAccessToken(cfg) {
240
- if (tokenCache && tokenCache.appKey === cfg.appKey && tokenCache.expiresAt > Date.now() + 300_000) {
241
- return tokenCache.accessToken;
242
- }
243
- const r = await dtFetch(TOKEN_URL, {
244
- method: 'POST',
245
- headers: { 'Content-Type': 'application/json' },
246
- body: JSON.stringify({ appKey: cfg.appKey, appSecret: cfg.appSecret }),
247
- });
248
- const j = await r.json().catch(() => ({}));
249
- if (!r.ok || !j.accessToken) throw new Error(`token ${r.status}: ${j.message || j.code || 'failed'}`);
250
- tokenCache = { appKey: cfg.appKey, accessToken: j.accessToken, expiresAt: Date.now() + (j.expireIn || 7200) * 1000 };
251
- return j.accessToken;
252
- }
253
-
254
- async function sendOne(cfg, target, content) {
255
- await rateLimitGate();
256
- const token = await getAccessToken(cfg);
257
- const msgParam = JSON.stringify({ title: t('server.dingtalk.replyChunkTitle'), text: content });
258
- const isGroup = String(target.conversationType) === '2';
259
- const url = isGroup ? GROUP_SEND_URL : OTO_SEND_URL;
260
- const body = isGroup
261
- ? { robotCode: target.robotCode, openConversationId: target.conversationId, msgKey: 'sampleMarkdown', msgParam }
262
- : { robotCode: target.robotCode, userIds: [target.senderStaffId].filter(Boolean), msgKey: 'sampleMarkdown', msgParam };
263
- const r = await dtFetch(url, {
264
- method: 'POST',
265
- headers: { 'Content-Type': 'application/json', 'x-acs-dingtalk-access-token': token },
266
- body: JSON.stringify(body),
267
- });
268
- if (!r.ok) {
269
- const j = await r.json().catch(() => ({}));
270
- throw new Error(`send ${r.status}: ${j.message || j.code || 'failed'}`);
271
- }
272
- }
273
-
274
- async function sendReply(cfg, target, text) {
275
- let chunks = chunkText(text, cfg.maxChunkChars);
276
- if (chunks.length > MAX_CHUNKS_PER_TURN) {
277
- chunks = chunks.slice(0, MAX_CHUNKS_PER_TURN);
278
- chunks[MAX_CHUNKS_PER_TURN - 1] += '\n\n' + t('server.dingtalk.truncated');
279
- }
280
- for (const c of chunks) {
281
- try { await sendOne(cfg, target, c); }
282
- catch (e) { lastError = String(e?.message || e); audit('send-error', { error: lastError }); break; }
283
- }
284
- audit('out', { conversationId: target.conversationId, chunks: chunks.length });
285
- }
286
-
287
- // ─── inbound ───
288
- async function onInbound(res) {
289
- ack(res); // MUST be first: DingTalk redelivers if not acked within ~5-15s
290
- let msg;
291
- try { msg = JSON.parse(res?.data ?? '{}'); } catch { return; }
292
- const msgId = res?.headers?.messageId;
293
- if (remember(msgId)) return; // redelivery
294
-
295
- const text = sanitizeInbound(msg.text?.content ?? '').trim();
296
- const conversationId = msg.conversationId;
297
- const conversationType = msg.conversationType;
298
- const senderStaffId = msg.senderStaffId;
299
- const robotCode = msg.robotCode || msg.chatbotUserId;
300
- const cfg = bridgeDeps.getConfig();
301
- const target = { conversationId, conversationType, robotCode, senderStaffId };
302
-
303
- // access control: allowlist (if any) else bind-first-conversation
304
- if (cfg.allowStaffIds.length > 0) {
305
- if (!cfg.allowStaffIds.includes(senderStaffId)) {
306
- audit('reject-staff', { senderStaffId, conversationId });
307
- void sendReply(cfg, target, t('server.dingtalk.notAuthorized'));
308
- return;
309
- }
310
- } else if (!boundConversation) {
311
- boundConversation = { conversationId, conversationType, robotCode };
312
- audit('bind', { conversationId });
313
- } else if (conversationId !== boundConversation.conversationId) {
314
- audit('reject-conversation', { conversationId });
315
- void sendReply(cfg, target, t('server.dingtalk.notBound'));
316
- return;
317
- }
318
-
319
- audit('in', { msgId, senderStaffId, conversationId, len: text.length });
320
- if (!text) return; // non-text messages (image/voice/file) are ignored in v1
321
-
322
- if (isStopCommand(text)) {
323
- bridgeDeps.writeToPty('\x1b'); // ESC interrupts the current turn (NOT killPty)
324
- audit('stop', { conversationId });
325
- // Interrupting may mean the in-flight turn never emits a turn_end; clear the pending
326
- // reply and resume the queue so /stop can never wedge the bridge.
327
- clearPending();
328
- void sendReply(cfg, target, t('server.dingtalk.interrupted'));
329
- drainQueue();
330
- return;
331
- }
332
-
333
- if (queue.length >= queueCap()) {
334
- audit('queue-full', { conversationId, queued: queue.length });
335
- void sendReply(cfg, target, t('server.dingtalk.queueFull'));
336
- return;
337
- }
338
- queue.push({ ...target, content: text });
339
- if (pendingReply || bridgeDeps.isStreaming()) {
340
- void sendReply(cfg, target, t('server.dingtalk.busyQueued'));
341
- }
342
- drainQueue();
343
- }
344
-
345
- function drainQueue() {
346
- while (queue.length) {
347
- if (pendingReply || bridgeDeps.isStreaming()) return; // a turn is in flight
348
- const item = queue[0];
349
- const st = bridgeDeps.getPtyState();
350
- if (!st.running || bridgeDeps.getPtyKind() !== 'claude') {
351
- queue.shift();
352
- const cfg = bridgeDeps.getConfig();
353
- void sendReply(cfg, item, t('server.dingtalk.noSession'));
354
- continue;
355
- }
356
- queue.shift();
357
- const cfg = bridgeDeps.getConfig();
358
- const skipPerm = bridgeDeps.getPtySkipPermissions();
359
- // P2-5: optional hard block — when the session runs skip-permissions AND the admin opted
360
- // in, refuse to inject (remote input would execute with no approval) and tell the sender.
361
- if (skipPerm && cfg.blockOnSkipPermissions) {
362
- audit('skip-perm-blocked', { conversationId: item.conversationId });
363
- void sendReply(cfg, item, t('server.dingtalk.skipPermBlocked'));
364
- continue; // not armed, not injected — move to the next queued prompt
365
- }
366
- armPending(item);
367
- const injectedSince = pendingReply.since; // identity of this injection, for the failure guard
368
- if (skipPerm) {
369
- audit('skip-perm-warning', { conversationId: item.conversationId });
370
- void sendReply(cfg, item, t('server.dingtalk.skipPermWarning'));
371
- }
372
- // P1-1: react to a failed injection (PTY gone/died mid-write → onComplete(false)). Without
373
- // this the prompt never submits, no turn_end ever comes, and pendingReply wedges the queue
374
- // until the 10-min timeout. Only act if this same injection is still pending (a /stop or
375
- // timeout may have cleared it in the settle window).
376
- bridgeDeps.writeToPtySequential(bracketPasteSubmit(markOrigin(item.content)), (ok) => {
377
- if (ok) return;
378
- if (!pendingReply || pendingReply.since !== injectedSince) return;
379
- audit('inject-failed', { conversationId: item.conversationId });
380
- clearPending();
381
- void sendReply(bridgeDeps.getConfig(), item, t('server.dingtalk.injectFailed'));
382
- drainQueue();
383
- }, { settleMs: 250 });
384
- return; // one at a time; resume on the next turn_end
385
- }
386
- }
387
-
388
- // ─── outbound trigger (called from server.js _emitTurnEnd) ───
389
- export async function notifyTurnEnd(sessionId, ts, transcriptPath) {
390
- if (!pendingReply) { drainQueue(); return; } // only reply to turns the bridge initiated
391
- // A turn_end whose turn ended before we injected belongs to an earlier (e.g. local) turn,
392
- // not ours — don't consume our pending slot or leak that turn's text. (Narrow window;
393
- // full per-turn correlation is a v2 item.)
394
- if (ts && pendingReply.since && ts < pendingReply.since) { drainQueue(); return; }
395
- const target = pendingReply;
396
- clearPending();
397
- // Idempotency for a doubled turn_end of the SAME turn (a re-broadcast carries the same ts).
398
- // Keyed on ts, NOT reply text — the old text signature wrongly suppressed a later turn whose
399
- // reply happened to repeat a short confirmation (e.g. "完成。").
400
- if (ts && ts === lastRepliedTurnTs) { drainQueue(); return; }
401
- lastRepliedTurnTs = ts || null;
402
- let text = extractLastAssistantText(transcriptPath);
403
- if (!text) text = t('server.dingtalk.noTextReply');
404
- try { await sendReply(bridgeDeps.getConfig(), target, text); }
405
- catch (e) { lastError = String(e?.message || e); audit('send-error', { error: lastError }); }
406
- drainQueue(); // process the next queued prompt, if any
407
- }
408
-
409
- // ─── lifecycle ───
410
- export async function startBridge(deps) {
411
- if (deps) bridgeDeps = deps;
412
- if (running) return;
413
- // Guard: reloadBridge() (from the config route) calls startBridge with no deps. If the
414
- // bridge was never primed with deps (non-CLI mode, where no singleton PTY exists), refuse
415
- // to start — otherwise onInbound would dereference a null bridgeDeps.
416
- if (!bridgeDeps || typeof bridgeDeps.getConfig !== 'function') { audit('start-skipped', { reason: 'no-deps' }); return; }
417
- const cfg = bridgeDeps.getConfig();
418
- if (!cfg || !cfg.enabled || !cfg.appKey || !cfg.appSecret) return; // off / incomplete → no-op
419
- try {
420
- if (clientFactory) {
421
- client = clientFactory({ clientId: cfg.appKey, clientSecret: cfg.appSecret });
422
- if (typeof client.registerCallbackListener === 'function') client.registerCallbackListener('__test__', onInbound);
423
- } else {
424
- const mod = await import('dingtalk-stream');
425
- const { DWClient, TOPIC_ROBOT } = mod;
426
- client = new DWClient({ clientId: cfg.appKey, clientSecret: cfg.appSecret });
427
- client.registerCallbackListener(TOPIC_ROBOT, onInbound);
428
- }
429
- await client.connect?.();
430
- running = true;
431
- connected = true;
432
- lastError = null;
433
- audit('start', { appKeyTail: cfg.appKey.slice(-4) });
434
- } catch (e) {
435
- lastError = String(e?.message || e);
436
- connected = false;
437
- audit('start-error', { error: lastError });
438
- }
439
- }
440
-
441
- export async function stopBridge() {
442
- try { await client?.disconnect?.(); } catch { /* best-effort */ }
443
- client = null;
444
- running = false;
445
- connected = false;
446
- boundConversation = null;
447
- clearPending();
448
- queue.length = 0;
449
- }
450
-
451
- export async function reloadBridge(deps) {
452
- await stopBridge();
453
- await startBridge(deps);
454
- }
455
-
456
- export function isBridgeRunning() { return running; }
457
-
458
- export function getBridgeStatus() {
459
- const tail = bridgeDeps?.getConfig?.()?.appKey?.slice(-4) || '';
460
- return {
461
- running,
462
- connected,
463
- lastError,
464
- boundConversationId: boundConversation?.conversationId || null,
465
- appKeyTail: tail,
466
- };
467
- }
468
-
469
- /** Validate credentials without opening a Stream connection (the Test button). */
470
- export async function testConnection(cfg) {
471
- try {
472
- tokenCache = null; // force a fresh fetch
473
- await getAccessToken(cfg);
474
- return { ok: true };
475
- } catch (e) {
476
- return { ok: false, detail: String(e?.message || e) };
477
- }
478
- }
14
+ export { __setClientFactory };
15
+ export const __setFetchForTests = (fn) => core.__setFetchForTests(fn);
16
+ export const __setMaxQueueForTests = (n) => core.__setMaxQueueForTests(ID, n);
17
+ export const __resetForTests = () => core.__resetForTests(ID);
18
+
19
+ // ─── lifecycle (delegate to the core, scoped to the DingTalk platform) ───
20
+ export const startBridge = (deps) => core.startBridge(ID, deps);
21
+ export const stopBridge = () => core.stopBridge(ID);
22
+ export const reloadBridge = (deps) => core.reloadBridge(ID, deps);
23
+ export const isBridgeRunning = () => core.isBridgeRunning(ID);
24
+ export const getBridgeStatus = () => core.getBridgeStatus(ID);
25
+ export const testConnection = (cfg) => core.testConnection(ID, cfg);
26
+
27
+ // notifyTurnEnd is global (the core routes by which platform owns the in-flight turn).
28
+ export const notifyTurnEnd = (sessionId, ts, transcriptPath) => core.notifyTurnEnd(sessionId, ts, transcriptPath);
29
+
30
+ // ─── pure helpers (re-exported unchanged) ───
31
+ export const extractLastAssistantText = core.extractLastAssistantText;
32
+ export const chunkText = core.chunkText;
@@ -1,157 +1,15 @@
1
- // DingTalk bridge config — pure storage logic, unit-tested.
1
+ // DingTalk bridge config — thin back-compat shim over the generic server/lib/im-config.js.
2
2
  //
3
- // Persisted as the `dingtalk` key inside the same LOG_DIR/preferences.json the rest of
4
- // cc-viewer uses (mirrors server/lib/auth.js). Unlike auth, the DingTalk binding is
5
- // GLOBAL ONLY (one bot one cc-viewer instance) — there is no per-project scope, which
6
- // would fight the singleton-PTY model.
7
- //
8
- // appKey (≈ OAuth client_id, low sensitivity) and appSecret (a real secret) are both
9
- // base64-encoded on disk so preferences.json never shows them in literal plaintext. This
10
- // is light obfuscation, NOT encryption. The admin API masks appSecret entirely.
11
- import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from 'node:fs';
12
- import { join, dirname } from 'node:path';
13
- import { LOG_DIR } from '../../findcc.js';
14
-
15
- export const DEFAULT_DT_CONFIG = {
16
- enabled: false,
17
- appKey: '',
18
- appSecret: '',
19
- allowStaffIds: [],
20
- maxChunkChars: 3800,
21
- // When true, refuse to inject remote input into a --dangerously-skip-permissions session
22
- // (where it would execute with no approval). Default false preserves the warn-and-inject
23
- // behavior.
24
- blockOnSkipPermissions: false,
25
- };
26
-
27
- const MIN_CHUNK = 500;
28
- const MAX_CHUNK = 5000;
29
- const DEFAULT_CHUNK = 3800;
30
-
31
- /** Path computed fresh each call: LOG_DIR is a live binding and tests redirect it via CCV_LOG_DIR before import. */
32
- export function getPrefsPath() {
33
- return join(LOG_DIR, 'preferences.json');
34
- }
35
-
36
- function readPrefs() {
37
- try {
38
- const p = getPrefsPath();
39
- if (!existsSync(p)) return {};
40
- const obj = JSON.parse(readFileSync(p, 'utf-8'));
41
- return obj && typeof obj === 'object' ? obj : {};
42
- } catch {
43
- return {};
44
- }
45
- }
46
-
47
- function writePrefs(prefs) {
48
- const p = getPrefsPath();
49
- const dir = dirname(p);
50
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
51
- writeFileSync(p, JSON.stringify(prefs, null, 2), { mode: 0o600 });
52
- // writeFileSync's mode only applies on creation; re-assert 0600 — the file now carries
53
- // the (base64) appSecret.
54
- try { chmodSync(p, 0o600); } catch { /* best-effort; non-POSIX or race */ }
55
- }
56
-
57
- export function encodeSecret(plain) {
58
- return plain ? Buffer.from(plain, 'utf-8').toString('base64') : '';
59
- }
60
- export function decodeSecret(stored) {
61
- if (!stored || typeof stored !== 'string') return '';
62
- try { return Buffer.from(stored, 'base64').toString('utf-8'); } catch { return ''; }
63
- }
64
-
65
- function clampChunk(n) {
66
- const v = Number(n);
67
- if (!Number.isFinite(v)) return DEFAULT_CHUNK;
68
- return Math.min(MAX_CHUNK, Math.max(MIN_CHUNK, Math.round(v)));
69
- }
70
-
71
- function normalizeStaffIds(v) {
72
- if (!Array.isArray(v)) return [];
73
- const seen = new Set();
74
- const out = [];
75
- for (const s of v) {
76
- if (typeof s !== 'string') continue;
77
- const t = s.trim();
78
- if (!t || seen.has(t)) continue;
79
- seen.add(t);
80
- out.push(t);
81
- }
82
- return out;
83
- }
84
-
85
- /** Pure normalization (no disk I/O). Returns the in-memory plaintext shape. */
86
- export function normalizeDingTalk(cfg) {
87
- return {
88
- enabled: !!(cfg && cfg.enabled),
89
- appKey: cfg && typeof cfg.appKey === 'string' ? cfg.appKey.trim() : '',
90
- appSecret: cfg && typeof cfg.appSecret === 'string' ? cfg.appSecret.trim() : '',
91
- allowStaffIds: normalizeStaffIds(cfg && cfg.allowStaffIds),
92
- maxChunkChars: clampChunk(cfg && cfg.maxChunkChars),
93
- blockOnSkipPermissions: !!(cfg && cfg.blockOnSkipPermissions),
94
- };
95
- }
96
-
97
- function decodeStored(d) {
98
- return {
99
- enabled: !!(d && d.enabled),
100
- appKey: decodeSecret(d && d.appKey),
101
- appSecret: decodeSecret(d && d.appSecret),
102
- allowStaffIds: normalizeStaffIds(d && d.allowStaffIds),
103
- maxChunkChars: clampChunk(d && d.maxChunkChars),
104
- blockOnSkipPermissions: !!(d && d.blockOnSkipPermissions),
105
- };
106
- }
107
-
108
- function encodeForDisk(n) {
109
- return {
110
- enabled: n.enabled,
111
- appKey: encodeSecret(n.appKey),
112
- appSecret: encodeSecret(n.appSecret),
113
- allowStaffIds: n.allowStaffIds,
114
- maxChunkChars: n.maxChunkChars,
115
- blockOnSkipPermissions: n.blockOnSkipPermissions,
116
- };
117
- }
3
+ // The storage logic is now generic (keyed by platform descriptor); this module preserves the
4
+ // original DingTalk-specific public API so server/routes/dingtalk.js and the existing
5
+ // dingtalk-config tests keep working unchanged. New platforms use im-config.js directly.
6
+ import { getDescriptor, normalize, loadConfig, loadState, saveConfig } from './im-config.js';
118
7
 
119
- /** Effective config for the backend (plaintext appKey/appSecret). */
120
- export function loadDingTalkConfig() {
121
- return decodeStored(readPrefs().dingtalk);
122
- }
8
+ export { getPrefsPath, encodeSecret, decodeSecret } from './im-config.js';
123
9
 
124
- /**
125
- * Admin-facing state: appSecret is NEVER returned — only `hasSecret`. appKey is returned
126
- * (low sensitivity, lets the admin confirm which app). The route layer adds live
127
- * connection status.
128
- */
129
- export function loadDingTalkState() {
130
- const c = decodeStored(readPrefs().dingtalk);
131
- return {
132
- enabled: c.enabled,
133
- appKey: c.appKey,
134
- hasSecret: !!c.appSecret,
135
- allowStaffIds: c.allowStaffIds,
136
- maxChunkChars: c.maxChunkChars,
137
- blockOnSkipPermissions: c.blockOnSkipPermissions,
138
- };
139
- }
10
+ export const DEFAULT_DT_CONFIG = getDescriptor('dingtalk').defaults;
140
11
 
141
- /**
142
- * Persist DingTalk config (read-merge-write, preserving all other prefs). If `appSecret`
143
- * is empty AND a secret is already stored, the existing secret is PRESERVED (lets the
144
- * admin edit other fields without re-typing the secret). To remove the secret, disable
145
- * the bridge. Stored base64; returns the in-memory (plaintext) normalized shape.
146
- */
147
- export function saveDingTalkConfig(cfg) {
148
- const prefs = readPrefs();
149
- const normalized = normalizeDingTalk(cfg);
150
- if (!normalized.appSecret) {
151
- const existing = decodeSecret(prefs.dingtalk && prefs.dingtalk.appSecret);
152
- if (existing) normalized.appSecret = existing;
153
- }
154
- prefs.dingtalk = encodeForDisk(normalized);
155
- writePrefs(prefs);
156
- return normalized;
157
- }
12
+ export function normalizeDingTalk(cfg) { return normalize('dingtalk', cfg); }
13
+ export function loadDingTalkConfig() { return loadConfig('dingtalk'); }
14
+ export function loadDingTalkState() { return loadState('dingtalk'); }
15
+ export function saveDingTalkConfig(cfg) { return saveConfig('dingtalk', cfg); }