@oyasmi/pipiclaw 0.1.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.
Files changed (79) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +247 -0
  3. package/dist/agent.d.ts +18 -0
  4. package/dist/agent.d.ts.map +1 -0
  5. package/dist/agent.js +938 -0
  6. package/dist/agent.js.map +1 -0
  7. package/dist/commands.d.ts +9 -0
  8. package/dist/commands.d.ts.map +1 -0
  9. package/dist/commands.js +45 -0
  10. package/dist/commands.js.map +1 -0
  11. package/dist/context.d.ts +139 -0
  12. package/dist/context.d.ts.map +1 -0
  13. package/dist/context.js +432 -0
  14. package/dist/context.js.map +1 -0
  15. package/dist/delivery.d.ts +4 -0
  16. package/dist/delivery.d.ts.map +1 -0
  17. package/dist/delivery.js +221 -0
  18. package/dist/delivery.js.map +1 -0
  19. package/dist/dingtalk.d.ts +109 -0
  20. package/dist/dingtalk.d.ts.map +1 -0
  21. package/dist/dingtalk.js +655 -0
  22. package/dist/dingtalk.js.map +1 -0
  23. package/dist/events.d.ts +51 -0
  24. package/dist/events.d.ts.map +1 -0
  25. package/dist/events.js +287 -0
  26. package/dist/events.js.map +1 -0
  27. package/dist/log.d.ts +33 -0
  28. package/dist/log.d.ts.map +1 -0
  29. package/dist/log.js +188 -0
  30. package/dist/log.js.map +1 -0
  31. package/dist/main.d.ts +3 -0
  32. package/dist/main.d.ts.map +1 -0
  33. package/dist/main.js +298 -0
  34. package/dist/main.js.map +1 -0
  35. package/dist/paths.d.ts +8 -0
  36. package/dist/paths.d.ts.map +1 -0
  37. package/dist/paths.js +10 -0
  38. package/dist/paths.js.map +1 -0
  39. package/dist/sandbox.d.ts +34 -0
  40. package/dist/sandbox.d.ts.map +1 -0
  41. package/dist/sandbox.js +180 -0
  42. package/dist/sandbox.js.map +1 -0
  43. package/dist/shell-escape.d.ts +6 -0
  44. package/dist/shell-escape.d.ts.map +1 -0
  45. package/dist/shell-escape.js +8 -0
  46. package/dist/shell-escape.js.map +1 -0
  47. package/dist/store.d.ts +41 -0
  48. package/dist/store.d.ts.map +1 -0
  49. package/dist/store.js +110 -0
  50. package/dist/store.js.map +1 -0
  51. package/dist/tools/attach.d.ts +14 -0
  52. package/dist/tools/attach.d.ts.map +1 -0
  53. package/dist/tools/attach.js +35 -0
  54. package/dist/tools/attach.js.map +1 -0
  55. package/dist/tools/bash.d.ts +10 -0
  56. package/dist/tools/bash.d.ts.map +1 -0
  57. package/dist/tools/bash.js +78 -0
  58. package/dist/tools/bash.js.map +1 -0
  59. package/dist/tools/edit.d.ts +11 -0
  60. package/dist/tools/edit.d.ts.map +1 -0
  61. package/dist/tools/edit.js +129 -0
  62. package/dist/tools/edit.js.map +1 -0
  63. package/dist/tools/index.d.ts +5 -0
  64. package/dist/tools/index.d.ts.map +1 -0
  65. package/dist/tools/index.js +15 -0
  66. package/dist/tools/index.js.map +1 -0
  67. package/dist/tools/read.d.ts +11 -0
  68. package/dist/tools/read.d.ts.map +1 -0
  69. package/dist/tools/read.js +132 -0
  70. package/dist/tools/read.js.map +1 -0
  71. package/dist/tools/truncate.d.ts +57 -0
  72. package/dist/tools/truncate.d.ts.map +1 -0
  73. package/dist/tools/truncate.js +184 -0
  74. package/dist/tools/truncate.js.map +1 -0
  75. package/dist/tools/write.d.ts +10 -0
  76. package/dist/tools/write.d.ts.map +1 -0
  77. package/dist/tools/write.js +31 -0
  78. package/dist/tools/write.js.map +1 -0
  79. package/package.json +54 -0
@@ -0,0 +1,655 @@
1
+ /**
2
+ * DingTalk communication layer using dingtalk-stream SDK with AI Card streaming.
3
+ *
4
+ * Handles:
5
+ * - Receiving messages via DingTalk Stream Mode (DWClient)
6
+ * - Responding via AI Card (streaming) or plain markdown (fallback)
7
+ * - Access token management
8
+ * - Per-channel message queuing
9
+ */
10
+ import axios from "axios";
11
+ import { DWClient, TOPIC_ROBOT } from "dingtalk-stream";
12
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
13
+ import { dirname, join } from "path";
14
+ import * as log from "./log.js";
15
+ class ChannelQueue {
16
+ queue = [];
17
+ processing = false;
18
+ enqueue(work) {
19
+ this.queue.push(work);
20
+ this.processNext();
21
+ }
22
+ size() {
23
+ return this.queue.length;
24
+ }
25
+ async processNext() {
26
+ if (this.processing || this.queue.length === 0)
27
+ return;
28
+ this.processing = true;
29
+ const work = this.queue.shift();
30
+ try {
31
+ await work();
32
+ }
33
+ catch (err) {
34
+ log.logWarning("Queue error", err instanceof Error ? err.message : String(err));
35
+ }
36
+ this.processing = false;
37
+ this.processNext();
38
+ }
39
+ }
40
+ // ============================================================================
41
+ // Constants
42
+ // ============================================================================
43
+ const DINGTALK_API = "https://api.dingtalk.com";
44
+ const TOKEN_REFRESH_SECS = 90 * 60; // 1.5 hours (tokens expire after 2 hours)
45
+ // ============================================================================
46
+ // DingTalkBot
47
+ // ============================================================================
48
+ export class DingTalkBot {
49
+ handler;
50
+ config;
51
+ // Access token cache
52
+ accessToken = null;
53
+ tokenExpiry = 0;
54
+ // Active AI cards: channelId → AICard
55
+ activeCards = new Map();
56
+ // Conversation metadata cache: channelId → metadata
57
+ convMeta = new Map();
58
+ // Per-channel queues
59
+ queues = new Map();
60
+ // Connection stability
61
+ client = null;
62
+ lastSocketAvailableTime = Date.now();
63
+ activeMessageProcessing = false;
64
+ keepAliveTimer = null;
65
+ isReconnecting = false;
66
+ isStopped = false;
67
+ reconnectAttempts = 0;
68
+ // Deduplication cache (Set for O(1) lookup, order array for FIFO eviction)
69
+ processedIds = new Set();
70
+ processedIdsOrder = [];
71
+ constructor(handler, config) {
72
+ this.handler = handler;
73
+ this.config = config;
74
+ }
75
+ /**
76
+ * Mark an ID as processed. Returns true if this is a new ID, false if already seen.
77
+ * Maintains a FIFO buffer of at most 200 entries.
78
+ */
79
+ markProcessed(id) {
80
+ if (this.processedIds.has(id))
81
+ return false;
82
+ this.processedIds.add(id);
83
+ this.processedIdsOrder.push(id);
84
+ while (this.processedIdsOrder.length > 200) {
85
+ this.processedIds.delete(this.processedIdsOrder.shift());
86
+ }
87
+ return true;
88
+ }
89
+ // ==========================================================================
90
+ // Public API
91
+ // ==========================================================================
92
+ async start() {
93
+ if (!this.config.clientId || !this.config.clientSecret) {
94
+ log.logWarning("DingTalk: clientId / clientSecret not configured");
95
+ return;
96
+ }
97
+ if (!this.config.cardTemplateId) {
98
+ log.logWarning("DingTalk: cardTemplateId not configured — AI Card streaming will not work");
99
+ }
100
+ log.logInfo(`DingTalk: initializing stream (clientId=${this.config.clientId.substring(0, 8)}…)`);
101
+ if (process.env.DINGTALK_FORCE_PROXY !== "true") {
102
+ axios.defaults.proxy = false;
103
+ }
104
+ this.client = new DWClient({
105
+ clientId: this.config.clientId,
106
+ clientSecret: this.config.clientSecret,
107
+ autoReconnect: false,
108
+ keepAlive: false,
109
+ });
110
+ this.client.registerCallbackListener(TOPIC_ROBOT, (msg) => {
111
+ return this.handleRawMessage(msg);
112
+ });
113
+ log.logConnected();
114
+ await this.doReconnect(true); // Initial connection
115
+ }
116
+ handleRawMessage(msg) {
117
+ // 1. Immediate ACK
118
+ if (msg.headers?.messageId && this.client) {
119
+ this.client.socketCallBackResponse(msg.headers.messageId, { status: "SUCCESS", message: "OK" });
120
+ }
121
+ // 2. Protocol deduplication
122
+ const messageId = msg.headers?.messageId;
123
+ if (messageId && !this.markProcessed(messageId)) {
124
+ return { status: "SUCCESS", message: "OK" };
125
+ }
126
+ try {
127
+ const data = typeof msg.data === "string" ? JSON.parse(msg.data) : msg.data;
128
+ // 3. Business logic deduplication
129
+ const msgId = data.msgId;
130
+ if (msgId && !this.markProcessed(msgId)) {
131
+ return { status: "SUCCESS", message: "OK" };
132
+ }
133
+ // Fire-and-forget processing
134
+ this.onStreamMessage(data).catch((err) => {
135
+ log.logWarning("DingTalk handler error", err instanceof Error ? err.message : String(err));
136
+ });
137
+ }
138
+ catch (err) {
139
+ log.logWarning("DingTalk: failed to parse message", err instanceof Error ? err.message : String(err));
140
+ }
141
+ return { status: "SUCCESS", message: "OK" };
142
+ }
143
+ async doReconnect(immediate = false) {
144
+ if (this.isReconnecting || this.isStopped || !this.client)
145
+ return;
146
+ this.isReconnecting = true;
147
+ let connectionFailed = false;
148
+ if (!immediate && this.reconnectAttempts > 0) {
149
+ const delay = Math.min(1000 * 2 ** this.reconnectAttempts + Math.random() * 1000, 30000);
150
+ log.logInfo(`DingTalk: waiting ${Math.round(delay / 1000)}s before reconnecting...`);
151
+ await new Promise((resolve) => setTimeout(resolve, delay));
152
+ }
153
+ try {
154
+ const socket = this.client.socket;
155
+ if (socket?.readyState === 1 || socket?.readyState === 3) {
156
+ await this.client.disconnect();
157
+ }
158
+ await this.client.connect();
159
+ this.lastSocketAvailableTime = Date.now();
160
+ this.reconnectAttempts = 0; // Success, reset backoff
161
+ log.logInfo("DingTalk: connected to stream.");
162
+ // Setup keep alive
163
+ if (this.keepAliveTimer)
164
+ clearInterval(this.keepAliveTimer);
165
+ this.keepAliveTimer = setInterval(() => {
166
+ if (this.isStopped)
167
+ return;
168
+ const elapsed = Date.now() - this.lastSocketAvailableTime;
169
+ if (elapsed > 90 * 1000 && !this.activeMessageProcessing) {
170
+ log.logWarning("DingTalk: connection timeout detected (>90s). Keeping active where possible...");
171
+ }
172
+ try {
173
+ const s = this.client?.socket;
174
+ if (s?.readyState === 1) {
175
+ s.ping();
176
+ }
177
+ }
178
+ catch (_err) {
179
+ // Ignore
180
+ }
181
+ }, 30 * 1000);
182
+ // Setup native socket events
183
+ const s = this.client.socket;
184
+ s?.on("pong", () => {
185
+ this.lastSocketAvailableTime = Date.now();
186
+ });
187
+ s?.on("close", (code, reason) => {
188
+ log.logWarning(`DingTalk: WebSocket closed: code=${code}, reason=${reason}`);
189
+ if (this.isStopped)
190
+ return;
191
+ setTimeout(() => {
192
+ this.doReconnect(true).catch((err) => {
193
+ log.logWarning("DingTalk: reconnect failed", err instanceof Error ? err.message : String(err));
194
+ });
195
+ }, 1000);
196
+ });
197
+ s?.on("message", (raw) => {
198
+ try {
199
+ const msg = JSON.parse(raw);
200
+ if (msg.type === "SYSTEM" && msg.headers?.topic === "disconnect") {
201
+ log.logWarning("DingTalk: disconnect event received from server.");
202
+ if (!this.isStopped) {
203
+ this.doReconnect(true).catch(() => { });
204
+ }
205
+ }
206
+ }
207
+ catch (_e) {
208
+ // skip
209
+ }
210
+ });
211
+ }
212
+ catch (err) {
213
+ this.reconnectAttempts++;
214
+ connectionFailed = true;
215
+ log.logWarning("DingTalk: connection failed", err instanceof Error ? err.message : String(err));
216
+ }
217
+ finally {
218
+ this.isReconnecting = false;
219
+ }
220
+ // Auto-retry on failure with exponential backoff
221
+ if (connectionFailed && !this.isStopped) {
222
+ this.doReconnect().catch(() => { });
223
+ }
224
+ }
225
+ stop() {
226
+ this.isStopped = true;
227
+ if (this.keepAliveTimer)
228
+ clearInterval(this.keepAliveTimer);
229
+ if (this.client) {
230
+ try {
231
+ this.client.disconnect();
232
+ }
233
+ catch (_e) { }
234
+ }
235
+ }
236
+ /**
237
+ * Enqueue an event for processing.
238
+ * Returns true if enqueued, false if queue is full (max 5).
239
+ */
240
+ enqueueEvent(event) {
241
+ const queue = this.getQueue(event.channelId);
242
+ if (queue.size() >= 5) {
243
+ log.logWarning(`Event queue full for ${event.channelId}, discarding: ${event.text.substring(0, 50)}`);
244
+ return false;
245
+ }
246
+ log.logInfo(`Enqueueing event for ${event.channelId}: ${event.text.substring(0, 50)}`);
247
+ queue.enqueue(async () => {
248
+ this.activeMessageProcessing = true;
249
+ try {
250
+ await this.handler.handleEvent(event, this, true);
251
+ }
252
+ finally {
253
+ this.activeMessageProcessing = false;
254
+ this.lastSocketAvailableTime = Date.now();
255
+ }
256
+ });
257
+ return true;
258
+ }
259
+ // ==========================================================================
260
+ // AI Card operations
261
+ // ==========================================================================
262
+ /**
263
+ * Get or create an AI Card for a channel.
264
+ */
265
+ async ensureCard(channelId) {
266
+ if (!this.config.cardTemplateId)
267
+ return;
268
+ const existing = this.activeCards.get(channelId);
269
+ if (existing && !existing.finished)
270
+ return;
271
+ await this.createCard(channelId);
272
+ }
273
+ /**
274
+ * Stream content to the active AI Card for a channel.
275
+ */
276
+ async streamToCard(channelId, content, finalize = false) {
277
+ let card = this.activeCards.get(channelId);
278
+ if ((!card || card.finished) && !finalize && this.config.cardTemplateId && content.trim()) {
279
+ await this.ensureCard(channelId);
280
+ card = this.activeCards.get(channelId);
281
+ }
282
+ if (!card || card.finished) {
283
+ if (finalize) {
284
+ return this.sendPlain(channelId, content);
285
+ }
286
+ return false;
287
+ }
288
+ const streamed = await this.streamCard(card, content, finalize);
289
+ if (!streamed) {
290
+ this.activeCards.delete(channelId);
291
+ }
292
+ return streamed;
293
+ }
294
+ /**
295
+ * Finalize the active card for a channel without falling back to a plain message.
296
+ * Returns true if a card was finalized, false if no active card existed.
297
+ */
298
+ async finalizeExistingCard(channelId, content) {
299
+ let card = this.activeCards.get(channelId);
300
+ if ((!card || card.finished) && this.config.cardTemplateId && content.trim()) {
301
+ await this.ensureCard(channelId);
302
+ card = this.activeCards.get(channelId);
303
+ }
304
+ if (!card || card.finished) {
305
+ return false;
306
+ }
307
+ const finalized = await this.streamCard(card, content, true);
308
+ this.activeCards.delete(channelId);
309
+ return finalized;
310
+ }
311
+ /**
312
+ * Finalize and remove the active card for a channel.
313
+ */
314
+ async finalizeCard(channelId, content) {
315
+ const finalized = await this.finalizeExistingCard(channelId, content);
316
+ if (!finalized) {
317
+ return this.sendPlain(channelId, content);
318
+ }
319
+ return true;
320
+ }
321
+ discardCard(channelId) {
322
+ this.activeCards.delete(channelId);
323
+ }
324
+ /**
325
+ * Send a normal message natively mapping DM and Group to correct endpoints (fallback when no card).
326
+ */
327
+ async sendPlain(channelId, text) {
328
+ const token = await this.getAccessToken();
329
+ if (!token)
330
+ return false;
331
+ const meta = this.getConversationMeta(channelId);
332
+ if (!meta) {
333
+ log.logWarning(`No conversation metadata for ${channelId}, cannot send plain message`);
334
+ return false;
335
+ }
336
+ const robotCode = this.config.robotCode || this.config.clientId;
337
+ const isGroup = meta.conversationType === "2";
338
+ const hasMarkdown = /^#{1,6}\s|^\s*[-*]\s|\*\*.*\*\*|```|`[^`]+`|\[.*?\]\(.*?\)/m.test(text);
339
+ const msgKey = hasMarkdown ? "sampleMarkdown" : "sampleText";
340
+ const msgParam = hasMarkdown ? JSON.stringify({ text, title: "Bot" }) : JSON.stringify({ content: text });
341
+ const url = isGroup
342
+ ? `${DINGTALK_API}/v1.0/robot/groupMessages/send`
343
+ : `${DINGTALK_API}/v1.0/robot/oToMessages/batchSend`;
344
+ const body = {
345
+ robotCode,
346
+ msgKey,
347
+ msgParam,
348
+ };
349
+ if (isGroup) {
350
+ body.openConversationId = meta.conversationId;
351
+ }
352
+ else {
353
+ body.userIds = [meta.senderId];
354
+ }
355
+ try {
356
+ await axios.post(url, body, {
357
+ headers: {
358
+ "x-acs-dingtalk-access-token": token,
359
+ "Content-Type": "application/json",
360
+ },
361
+ });
362
+ return true;
363
+ }
364
+ catch (err) {
365
+ if (axios.isAxiosError(err) && err.response) {
366
+ log.logWarning(`DingTalk plain send failed (${err.response.status})`, JSON.stringify(err.response.data));
367
+ }
368
+ else {
369
+ log.logWarning("DingTalk plain send error", err instanceof Error ? err.message : String(err));
370
+ }
371
+ return false;
372
+ }
373
+ }
374
+ // ==========================================================================
375
+ // Private - AI Card implementation
376
+ // ==========================================================================
377
+ async createCard(channelId) {
378
+ const token = await this.getAccessToken();
379
+ if (!token)
380
+ return null;
381
+ const meta = this.getConversationMeta(channelId);
382
+ if (!meta) {
383
+ log.logWarning(`No conversation metadata for ${channelId}, cannot create card`);
384
+ return null;
385
+ }
386
+ const isGroup = meta.conversationType === "2";
387
+ const instanceId = `card_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`;
388
+ const robotCode = this.config.robotCode || this.config.clientId;
389
+ // openSpaceId format:
390
+ // 群聊: dtv1.card//IM_GROUP.{openConversationId}
391
+ // 单聊: dtv1.card//IM_ROBOT.{userId}
392
+ const openSpaceId = isGroup
393
+ ? `dtv1.card//IM_GROUP.${meta.conversationId}`
394
+ : `dtv1.card//IM_ROBOT.${meta.senderId}`;
395
+ const body = {
396
+ cardTemplateId: this.config.cardTemplateId,
397
+ outTrackId: instanceId,
398
+ cardData: { cardParamMap: {} },
399
+ callbackType: "STREAM",
400
+ imGroupOpenSpaceModel: { supportForward: true },
401
+ imRobotOpenSpaceModel: { supportForward: true },
402
+ openSpaceId,
403
+ userIdType: 1,
404
+ };
405
+ if (isGroup) {
406
+ body.imGroupOpenDeliverModel = { robotCode };
407
+ }
408
+ else {
409
+ body.imRobotOpenDeliverModel = { spaceType: "IM_ROBOT" };
410
+ }
411
+ try {
412
+ await axios.post(`${DINGTALK_API}/v1.0/card/instances/createAndDeliver`, body, {
413
+ headers: {
414
+ "x-acs-dingtalk-access-token": token,
415
+ "Content-Type": "application/json",
416
+ },
417
+ });
418
+ }
419
+ catch (err) {
420
+ if (axios.isAxiosError(err) && err.response) {
421
+ log.logWarning(`DingTalk Card: create failed (${err.response.status})`, JSON.stringify(err.response.data));
422
+ }
423
+ else {
424
+ log.logWarning("DingTalk Card: create failed", err instanceof Error ? err.message : String(err));
425
+ }
426
+ return null;
427
+ }
428
+ const card = {
429
+ instanceId,
430
+ conversationId: meta.conversationId,
431
+ accessToken: token,
432
+ templateKey: this.config.cardTemplateKey || "content",
433
+ createdAt: Date.now() / 1000,
434
+ lastUpdated: Date.now() / 1000,
435
+ content: "",
436
+ finished: false,
437
+ };
438
+ this.activeCards.set(channelId, card);
439
+ return card;
440
+ }
441
+ async streamCard(card, content, finalize = false) {
442
+ // Refresh token if needed
443
+ const ageSecs = Date.now() / 1000 - card.createdAt;
444
+ if (ageSecs > TOKEN_REFRESH_SECS) {
445
+ const token = await this.getAccessToken();
446
+ if (token) {
447
+ card.accessToken = token;
448
+ }
449
+ }
450
+ const body = {
451
+ outTrackId: card.instanceId,
452
+ guid: `${Date.now()}_${Math.random().toString(36).substring(2, 8)}`,
453
+ key: card.templateKey,
454
+ content,
455
+ isFull: true,
456
+ isFinalize: finalize,
457
+ isError: false,
458
+ };
459
+ const start = Date.now();
460
+ try {
461
+ await axios.put(`${DINGTALK_API}/v1.0/card/streaming`, body, {
462
+ headers: {
463
+ "x-acs-dingtalk-access-token": card.accessToken,
464
+ "Content-Type": "application/json",
465
+ },
466
+ });
467
+ const duration = Date.now() - start;
468
+ if (duration > 1000) {
469
+ log.logWarning(`DingTalk Card: streaming request took ${duration}ms (slow)`);
470
+ }
471
+ card.lastUpdated = Date.now() / 1000;
472
+ card.content = content;
473
+ if (finalize) {
474
+ card.finished = true;
475
+ }
476
+ return true;
477
+ }
478
+ catch (err) {
479
+ if (axios.isAxiosError(err) && err.response) {
480
+ log.logWarning(`DingTalk Card: streaming failed (${err.response.status})`, JSON.stringify(err.response.data));
481
+ }
482
+ else {
483
+ log.logWarning("DingTalk Card: streaming failed", err instanceof Error ? err.message : String(err));
484
+ }
485
+ return false;
486
+ }
487
+ }
488
+ // ==========================================================================
489
+ // Private - Access Token
490
+ // ==========================================================================
491
+ async getAccessToken() {
492
+ if (this.accessToken && Date.now() / 1000 < this.tokenExpiry) {
493
+ return this.accessToken;
494
+ }
495
+ try {
496
+ const resp = await axios.post(`${DINGTALK_API}/v1.0/oauth2/accessToken`, {
497
+ appKey: this.config.clientId,
498
+ appSecret: this.config.clientSecret,
499
+ }, {
500
+ headers: { "Content-Type": "application/json" },
501
+ });
502
+ const data = resp.data;
503
+ this.accessToken = data.accessToken || null;
504
+ this.tokenExpiry = Date.now() / 1000 + (data.expireIn || 7200) - 60;
505
+ return this.accessToken;
506
+ }
507
+ catch (err) {
508
+ if (axios.isAxiosError(err) && err.response) {
509
+ log.logWarning(`DingTalk: failed to get access token (${err.response.status})`, JSON.stringify(err.response.data));
510
+ }
511
+ else {
512
+ log.logWarning("DingTalk: failed to get access token", err instanceof Error ? err.message : String(err));
513
+ }
514
+ return null;
515
+ }
516
+ }
517
+ // ==========================================================================
518
+ // Private - Message handling
519
+ // ==========================================================================
520
+ extractContent(data) {
521
+ // 1. text 类型消息:从 text.content 提取
522
+ const textContent = (data.text?.content || "").trim();
523
+ if (textContent)
524
+ return textContent;
525
+ // 2. richText 类型消息:从 content.richText 列表提取文本片段
526
+ const raw = data;
527
+ const contentObj = raw.content;
528
+ if (contentObj?.richText) {
529
+ const parts = [];
530
+ for (const item of contentObj.richText) {
531
+ if (item.text)
532
+ parts.push(item.text);
533
+ }
534
+ const joined = parts.join("").trim();
535
+ if (joined)
536
+ return joined;
537
+ }
538
+ return "";
539
+ }
540
+ async onStreamMessage(data) {
541
+ const content = this.extractContent(data);
542
+ const senderId = data.senderStaffId || data.senderId || "";
543
+ const senderName = data.senderNick || "Unknown";
544
+ const conversationId = data.conversationId || "";
545
+ const conversationType = data.conversationType || "1";
546
+ if (!content) {
547
+ const msgtype = data.msgtype || "unknown";
548
+ log.logWarning(`DingTalk: empty message (type=${msgtype})`);
549
+ return;
550
+ }
551
+ if (this.config.allowFrom && this.config.allowFrom.length > 0) {
552
+ if (!this.config.allowFrom.includes(senderId)) {
553
+ log.logWarning(`DingTalk: ignoring message from unauthorized user ${senderName} (${senderId})`);
554
+ return;
555
+ }
556
+ }
557
+ // Determine channel ID
558
+ const channelId = conversationType === "2" ? `group_${conversationId}` : `dm_${senderId}`;
559
+ log.logInfo(`DingTalk ← ${senderName} (${senderId}) [${channelId}]: ${content.substring(0, 80)}`);
560
+ // Cache conversation metadata for card creation
561
+ this.setConversationMeta(channelId, {
562
+ conversationId,
563
+ conversationType,
564
+ senderId,
565
+ });
566
+ // Build event
567
+ const event = {
568
+ type: conversationType === "2" ? "group" : "dm",
569
+ channelId,
570
+ ts: Date.now().toString(),
571
+ user: senderId,
572
+ userName: senderName,
573
+ text: content,
574
+ conversationId,
575
+ conversationType,
576
+ };
577
+ // Check for stop command
578
+ if (content.toLowerCase().trim() === "stop") {
579
+ if (this.handler.isRunning(channelId)) {
580
+ this.handler.handleStop(channelId, this);
581
+ }
582
+ return;
583
+ }
584
+ // Check if busy
585
+ if (this.handler.isRunning(channelId)) {
586
+ const busyMsg = "正在处理中,请稍候。发送 `stop` 可取消当前任务。";
587
+ await this.sendPlain(channelId, busyMsg);
588
+ return;
589
+ }
590
+ // Enqueue for processing
591
+ this.getQueue(channelId).enqueue(async () => {
592
+ this.activeMessageProcessing = true;
593
+ try {
594
+ await this.handler.handleEvent(event, this);
595
+ }
596
+ finally {
597
+ this.activeMessageProcessing = false;
598
+ this.lastSocketAvailableTime = Date.now();
599
+ }
600
+ });
601
+ }
602
+ getQueue(channelId) {
603
+ let queue = this.queues.get(channelId);
604
+ if (!queue) {
605
+ queue = new ChannelQueue();
606
+ this.queues.set(channelId, queue);
607
+ }
608
+ return queue;
609
+ }
610
+ getConversationMeta(channelId) {
611
+ const cached = this.convMeta.get(channelId);
612
+ if (cached)
613
+ return cached;
614
+ const metaPath = this.getConversationMetaPath(channelId);
615
+ if (!metaPath || !existsSync(metaPath)) {
616
+ return null;
617
+ }
618
+ try {
619
+ const parsed = JSON.parse(readFileSync(metaPath, "utf-8"));
620
+ if (!parsed.conversationId || !parsed.conversationType || !parsed.senderId) {
621
+ return null;
622
+ }
623
+ const meta = {
624
+ conversationId: parsed.conversationId,
625
+ conversationType: parsed.conversationType,
626
+ senderId: parsed.senderId,
627
+ };
628
+ this.convMeta.set(channelId, meta);
629
+ return meta;
630
+ }
631
+ catch (err) {
632
+ log.logWarning(`Failed to load conversation metadata for ${channelId}`, err instanceof Error ? err.message : String(err));
633
+ return null;
634
+ }
635
+ }
636
+ setConversationMeta(channelId, meta) {
637
+ this.convMeta.set(channelId, meta);
638
+ const metaPath = this.getConversationMetaPath(channelId);
639
+ if (!metaPath)
640
+ return;
641
+ try {
642
+ mkdirSync(dirname(metaPath), { recursive: true });
643
+ writeFileSync(metaPath, JSON.stringify(meta, null, 2), "utf-8");
644
+ }
645
+ catch (err) {
646
+ log.logWarning(`Failed to persist conversation metadata for ${channelId}`, err instanceof Error ? err.message : String(err));
647
+ }
648
+ }
649
+ getConversationMetaPath(channelId) {
650
+ if (!this.config.stateDir)
651
+ return null;
652
+ return join(this.config.stateDir, channelId, ".channel-meta.json");
653
+ }
654
+ }
655
+ //# sourceMappingURL=dingtalk.js.map