@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.
- package/CHANGELOG.md +31 -0
- package/README.md +247 -0
- package/dist/agent.d.ts +18 -0
- package/dist/agent.d.ts.map +1 -0
- package/dist/agent.js +938 -0
- package/dist/agent.js.map +1 -0
- package/dist/commands.d.ts +9 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/commands.js +45 -0
- package/dist/commands.js.map +1 -0
- package/dist/context.d.ts +139 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +432 -0
- package/dist/context.js.map +1 -0
- package/dist/delivery.d.ts +4 -0
- package/dist/delivery.d.ts.map +1 -0
- package/dist/delivery.js +221 -0
- package/dist/delivery.js.map +1 -0
- package/dist/dingtalk.d.ts +109 -0
- package/dist/dingtalk.d.ts.map +1 -0
- package/dist/dingtalk.js +655 -0
- package/dist/dingtalk.js.map +1 -0
- package/dist/events.d.ts +51 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +287 -0
- package/dist/events.js.map +1 -0
- package/dist/log.d.ts +33 -0
- package/dist/log.d.ts.map +1 -0
- package/dist/log.js +188 -0
- package/dist/log.js.map +1 -0
- package/dist/main.d.ts +3 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/main.js +298 -0
- package/dist/main.js.map +1 -0
- package/dist/paths.d.ts +8 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +10 -0
- package/dist/paths.js.map +1 -0
- package/dist/sandbox.d.ts +34 -0
- package/dist/sandbox.d.ts.map +1 -0
- package/dist/sandbox.js +180 -0
- package/dist/sandbox.js.map +1 -0
- package/dist/shell-escape.d.ts +6 -0
- package/dist/shell-escape.d.ts.map +1 -0
- package/dist/shell-escape.js +8 -0
- package/dist/shell-escape.js.map +1 -0
- package/dist/store.d.ts +41 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +110 -0
- package/dist/store.js.map +1 -0
- package/dist/tools/attach.d.ts +14 -0
- package/dist/tools/attach.d.ts.map +1 -0
- package/dist/tools/attach.js +35 -0
- package/dist/tools/attach.js.map +1 -0
- package/dist/tools/bash.d.ts +10 -0
- package/dist/tools/bash.d.ts.map +1 -0
- package/dist/tools/bash.js +78 -0
- package/dist/tools/bash.js.map +1 -0
- package/dist/tools/edit.d.ts +11 -0
- package/dist/tools/edit.d.ts.map +1 -0
- package/dist/tools/edit.js +129 -0
- package/dist/tools/edit.js.map +1 -0
- package/dist/tools/index.d.ts +5 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +15 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/read.d.ts +11 -0
- package/dist/tools/read.d.ts.map +1 -0
- package/dist/tools/read.js +132 -0
- package/dist/tools/read.js.map +1 -0
- package/dist/tools/truncate.d.ts +57 -0
- package/dist/tools/truncate.d.ts.map +1 -0
- package/dist/tools/truncate.js +184 -0
- package/dist/tools/truncate.js.map +1 -0
- package/dist/tools/write.d.ts +10 -0
- package/dist/tools/write.d.ts.map +1 -0
- package/dist/tools/write.js +31 -0
- package/dist/tools/write.js.map +1 -0
- package/package.json +54 -0
package/dist/dingtalk.js
ADDED
|
@@ -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
|