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.
- package/dist/assets/App-5qf4V-IT.css +1 -0
- package/dist/assets/App-zw1VXo0m.js +1 -0
- package/dist/assets/{MdxEditorPanel-B7qeMjuJ.js → MdxEditorPanel-D-yZyfRx.js} +1 -1
- package/dist/assets/{Mobile-fEx_3vW1.js → Mobile-CH0mudvq.js} +1 -1
- package/dist/assets/index-CwWxkILC.js +2 -0
- package/dist/assets/seqResourceLoaders-CoDjpIz4.js +2 -0
- package/dist/index.html +1 -1
- package/package.json +9 -2
- package/server/i18n.js +36 -0
- package/server/lib/adapters/dingtalk-adapter.js +129 -0
- package/server/lib/adapters/discord-adapter.js +140 -0
- package/server/lib/adapters/feishu-adapter.js +133 -0
- package/server/lib/adapters/wecom-adapter.js +146 -0
- package/server/lib/dingtalk-bridge.js +28 -474
- package/server/lib/dingtalk-config.js +11 -153
- package/server/lib/im-bridge-core.js +530 -0
- package/server/lib/im-config.js +246 -0
- package/server/routes/im.js +133 -0
- package/server/routes/preferences.js +13 -6
- package/server/server.js +31 -18
- package/dist/assets/App-BRgb-Ukj.css +0 -1
- package/dist/assets/App-D73sTzGX.js +0 -1
- package/dist/assets/index-C_SecKTB.js +0 -2
- package/dist/assets/seqResourceLoaders-wAP4dArl.js +0 -2
|
@@ -1,478 +1,32 @@
|
|
|
1
|
-
// DingTalk
|
|
1
|
+
// DingTalk bridge — thin back-compat shim.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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
|
-
|
|
44
|
-
export
|
|
45
|
-
|
|
46
|
-
export
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
export
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
export
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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 —
|
|
1
|
+
// DingTalk bridge config — thin back-compat shim over the generic server/lib/im-config.js.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
|
|
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
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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); }
|