opencode-dingtalk 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -0
- package/dist/__tests__/connection-manager.test.js +107 -0
- package/dist/__tests__/message-queue.test.js +298 -0
- package/dist/config.js +181 -0
- package/dist/connection-manager.js +103 -0
- package/dist/dingtalk/bot.js +2129 -0
- package/dist/dingtalk/dingtalk-api.js +126 -0
- package/dist/dingtalk/dingtalk-client.js +390 -0
- package/dist/dingtalk/types.js +8 -0
- package/dist/events.js +423 -0
- package/dist/index.js +164 -0
- package/dist/index.test.js +6 -0
- package/dist/instance.js +49 -0
- package/dist/lock.js +57 -0
- package/dist/logger.js +43 -0
- package/dist/message-queue.js +604 -0
- package/dist/registry.js +77 -0
- package/dist/standalone.js +110 -0
- package/dist/state.js +550 -0
- package/dist/types/dingtalk-stub.js +3 -0
- package/dist/utils.js +171 -0
- package/package.json +56 -0
package/dist/events.js
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
import { sendToAllActive, sendToUser, sendToSession, streamCardToAllActive, sendBySessionWebhook, } from "./dingtalk/bot.js";
|
|
2
|
+
import { logger } from "./logger.js";
|
|
3
|
+
const LEVEL_RANK = {
|
|
4
|
+
minimal: 0,
|
|
5
|
+
normal: 1,
|
|
6
|
+
verbose: 2,
|
|
7
|
+
};
|
|
8
|
+
function shouldNotify(state, required) {
|
|
9
|
+
const current = state.botConfig?.notifyLevel || "normal";
|
|
10
|
+
return LEVEL_RANK[current] >= LEVEL_RANK[required];
|
|
11
|
+
}
|
|
12
|
+
// ============ Helpers ============
|
|
13
|
+
function formatTodosLine(todos) {
|
|
14
|
+
const total = todos.length;
|
|
15
|
+
const done = todos.filter((t) => t.status === "completed").length;
|
|
16
|
+
return `${done}/${total}`;
|
|
17
|
+
}
|
|
18
|
+
// ============ Event Handler Factory ============
|
|
19
|
+
/**
|
|
20
|
+
* Creates a per-instance event handler.
|
|
21
|
+
*
|
|
22
|
+
* Timers / tracking sets that were formerly module-level globals are now
|
|
23
|
+
* local variables captured in the returned closure, ensuring isolation
|
|
24
|
+
* between multiple BotInstance objects.
|
|
25
|
+
*/
|
|
26
|
+
export function createEventHandler(state) {
|
|
27
|
+
// --- Per-instance closure state ---
|
|
28
|
+
const userMessageKeys = new Set();
|
|
29
|
+
const USER_MSG_KEYS_MAX = 200;
|
|
30
|
+
// --- 序列号计数器,用于确保消息排序的稳定性(当 lastUpdate 相同时) ---
|
|
31
|
+
let messageSequenceCounter = 0;
|
|
32
|
+
function trackUserMessage(sessionID, messageID) {
|
|
33
|
+
userMessageKeys.add(`${sessionID}:${messageID}`);
|
|
34
|
+
if (userMessageKeys.size > USER_MSG_KEYS_MAX) {
|
|
35
|
+
const first = userMessageKeys.values().next().value;
|
|
36
|
+
if (first)
|
|
37
|
+
userMessageKeys.delete(first);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function isUserMessage(sessionID, messageID) {
|
|
41
|
+
return userMessageKeys.has(`${sessionID}:${messageID}`);
|
|
42
|
+
}
|
|
43
|
+
// --- Todo debounce ---
|
|
44
|
+
const TODO_DEBOUNCE_MS = 3000;
|
|
45
|
+
let todoDebounceTimer = null;
|
|
46
|
+
let todoDebouncePayload = null;
|
|
47
|
+
let todoDebounceSessionId = null;
|
|
48
|
+
function scheduleTodoNotify(line, sessionId) {
|
|
49
|
+
todoDebouncePayload = line;
|
|
50
|
+
if (sessionId)
|
|
51
|
+
todoDebounceSessionId = sessionId;
|
|
52
|
+
if (todoDebounceTimer)
|
|
53
|
+
return;
|
|
54
|
+
todoDebounceTimer = setTimeout(async () => {
|
|
55
|
+
todoDebounceTimer = null;
|
|
56
|
+
const payload = todoDebouncePayload;
|
|
57
|
+
const sid = todoDebounceSessionId;
|
|
58
|
+
todoDebouncePayload = null;
|
|
59
|
+
todoDebounceSessionId = null;
|
|
60
|
+
if (payload && sid) {
|
|
61
|
+
try {
|
|
62
|
+
await sendToSession(state, payload, sid);
|
|
63
|
+
}
|
|
64
|
+
catch { }
|
|
65
|
+
}
|
|
66
|
+
}, TODO_DEBOUNCE_MS);
|
|
67
|
+
}
|
|
68
|
+
function cancelTodoDebounce() {
|
|
69
|
+
if (todoDebounceTimer) {
|
|
70
|
+
clearTimeout(todoDebounceTimer);
|
|
71
|
+
todoDebounceTimer = null;
|
|
72
|
+
todoDebouncePayload = null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// --- Card streaming debounce ---
|
|
76
|
+
const CARD_STREAM_INTERVAL_MS = 500;
|
|
77
|
+
let cardStreamTimer = null;
|
|
78
|
+
let cardStreamSessionId = null;
|
|
79
|
+
async function flushCardStream(sessionId, finished) {
|
|
80
|
+
if (state.botConfig?.messageType !== "card")
|
|
81
|
+
return 0;
|
|
82
|
+
let content = "";
|
|
83
|
+
for (const pending of state.pendingResponses.values()) {
|
|
84
|
+
if (pending.sessionId === sessionId && pending.textBuffer) {
|
|
85
|
+
if (content)
|
|
86
|
+
content += "\n\n";
|
|
87
|
+
content += pending.textBuffer;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (!content)
|
|
91
|
+
return 0;
|
|
92
|
+
return streamCardToAllActive(state, content, finished, console);
|
|
93
|
+
}
|
|
94
|
+
function scheduleCardStream(sessionId) {
|
|
95
|
+
if (cardStreamTimer && cardStreamSessionId === sessionId)
|
|
96
|
+
return;
|
|
97
|
+
cancelCardStreamTimer();
|
|
98
|
+
cardStreamSessionId = sessionId;
|
|
99
|
+
cardStreamTimer = setTimeout(async () => {
|
|
100
|
+
cardStreamTimer = null;
|
|
101
|
+
try {
|
|
102
|
+
await flushCardStream(sessionId, false);
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
logger.error(`[opencode-dingtalk] Card stream flush error: ${err.message}`);
|
|
106
|
+
}
|
|
107
|
+
}, CARD_STREAM_INTERVAL_MS);
|
|
108
|
+
}
|
|
109
|
+
function cancelCardStreamTimer() {
|
|
110
|
+
if (cardStreamTimer) {
|
|
111
|
+
clearTimeout(cardStreamTimer);
|
|
112
|
+
cardStreamTimer = null;
|
|
113
|
+
cardStreamSessionId = null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// ============ The Handler ============
|
|
117
|
+
return async (event) => {
|
|
118
|
+
// --- session.created ---
|
|
119
|
+
if (event.type === "session.created") {
|
|
120
|
+
const e = event;
|
|
121
|
+
if (!state.activeSessionId && e.properties?.info?.id) {
|
|
122
|
+
state.activeSessionId = e.properties.info.id;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// --- session.status ---
|
|
126
|
+
if (event.type === "session.status" &&
|
|
127
|
+
state.botConfig &&
|
|
128
|
+
state.activeUsers.size > 0) {
|
|
129
|
+
const e = event;
|
|
130
|
+
const status = e.properties.status;
|
|
131
|
+
state.sessionStatus = {
|
|
132
|
+
sessionID: e.properties.sessionID,
|
|
133
|
+
status: status.type === "retry" ? "retry" : status.type,
|
|
134
|
+
retry: status.type === "retry"
|
|
135
|
+
? {
|
|
136
|
+
attempt: status.attempt,
|
|
137
|
+
message: status.message,
|
|
138
|
+
next: status.next,
|
|
139
|
+
}
|
|
140
|
+
: undefined,
|
|
141
|
+
updatedAt: Date.now(),
|
|
142
|
+
};
|
|
143
|
+
if (status.type === "retry") {
|
|
144
|
+
try {
|
|
145
|
+
await sendToAllActive(state, `[重试] 尝试=${status.attempt} 下次=${Math.round(status.next / 1000)}s\n${status.message}`);
|
|
146
|
+
}
|
|
147
|
+
catch { }
|
|
148
|
+
}
|
|
149
|
+
if (status.type === "idle" && shouldNotify(state, "verbose")) {
|
|
150
|
+
try {
|
|
151
|
+
await sendToAllActive(state, `[Status] session 空闲 (${e.properties.sessionID.slice(0, 8)}...)`);
|
|
152
|
+
}
|
|
153
|
+
catch { }
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// --- todo.updated ---
|
|
157
|
+
if (event.type === "todo.updated" && state.botConfig) {
|
|
158
|
+
const e = event;
|
|
159
|
+
state.sessionTodos.set(e.properties.sessionID, e.properties.todos);
|
|
160
|
+
if (shouldNotify(state, "normal")) {
|
|
161
|
+
const todos = e.properties.todos;
|
|
162
|
+
if (todos.length > 0) {
|
|
163
|
+
const inProgress = todos.find((t) => t.status === "in_progress");
|
|
164
|
+
const line = `[Todo] ${formatTodosLine(todos)}${inProgress ? ` | in_progress: ${inProgress.content}` : ""}`;
|
|
165
|
+
scheduleTodoNotify(line, e.properties.sessionID);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// --- permission.asked ---
|
|
170
|
+
if (event.type === "permission.asked" && state.botConfig) {
|
|
171
|
+
const e = event;
|
|
172
|
+
state.pendingPermissions.set(e.properties.id, {
|
|
173
|
+
id: e.properties.id,
|
|
174
|
+
sessionID: e.properties.sessionID,
|
|
175
|
+
title: e.properties.title,
|
|
176
|
+
type: e.properties.type,
|
|
177
|
+
pattern: e.properties.pattern,
|
|
178
|
+
time: e.properties.time,
|
|
179
|
+
});
|
|
180
|
+
try {
|
|
181
|
+
const id = e.properties.id;
|
|
182
|
+
await sendToSession(state, `[Permission] 请审批权限请求\n` +
|
|
183
|
+
`id=${e.properties.id}\n` +
|
|
184
|
+
`审批命令: /approve ${id} once|always|reject`, e.properties.sessionID);
|
|
185
|
+
}
|
|
186
|
+
catch { }
|
|
187
|
+
}
|
|
188
|
+
// --- question.asked ---
|
|
189
|
+
if (event.type === "question.asked" && state.botConfig) {
|
|
190
|
+
const e = event;
|
|
191
|
+
state.pendingQuestions.set(e.properties.id, {
|
|
192
|
+
id: e.properties.id,
|
|
193
|
+
sessionID: e.properties.sessionID,
|
|
194
|
+
questions: e.properties.questions,
|
|
195
|
+
});
|
|
196
|
+
try {
|
|
197
|
+
const id = e.properties.id;
|
|
198
|
+
const firstQ = e.properties.questions[0];
|
|
199
|
+
const optionsText = firstQ.options
|
|
200
|
+
.map((opt, idx) => ` ${idx + 1}. ${opt.label}${opt.description ? ` - ${opt.description}` : ""}`)
|
|
201
|
+
.join("\n");
|
|
202
|
+
let message = `[Question] Agent 正在询问问题\n` +
|
|
203
|
+
`问题: ${firstQ.question}\n` +
|
|
204
|
+
`选项:\n${optionsText}\n\n`;
|
|
205
|
+
if (e.properties.questions.length === 1) {
|
|
206
|
+
if (firstQ.multiple) {
|
|
207
|
+
message += `回答命令: /answer ${id} 1,2,3 (可多选,用逗号分隔)`;
|
|
208
|
+
}
|
|
209
|
+
else if (firstQ.custom !== false) {
|
|
210
|
+
message += `回答命令: /answer ${id} <选项序号或自定义文本>`;
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
message += `回答命令: /answer ${id} <选项序号>`;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
message += `共 ${e.properties.questions.length} 个问题,回答命令: /answer ${id} <答案1> <答案2> ...`;
|
|
218
|
+
}
|
|
219
|
+
await sendToSession(state, message, e.properties.sessionID);
|
|
220
|
+
}
|
|
221
|
+
catch { }
|
|
222
|
+
}
|
|
223
|
+
// --- session.error ---
|
|
224
|
+
if (event.type === "session.error" && state.botConfig) {
|
|
225
|
+
const e = event;
|
|
226
|
+
const msg = e.properties.error?.data?.message ||
|
|
227
|
+
e.properties.error?.message ||
|
|
228
|
+
e.properties.error?.name ||
|
|
229
|
+
"Unknown error";
|
|
230
|
+
if (e.properties.sessionID) {
|
|
231
|
+
try {
|
|
232
|
+
await sendToSession(state, `[Error] ${msg}`, e.properties.sessionID);
|
|
233
|
+
}
|
|
234
|
+
catch { }
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
// --- message.updated ---
|
|
238
|
+
if (event.type === "message.updated") {
|
|
239
|
+
const e = event;
|
|
240
|
+
const info = e.properties.info;
|
|
241
|
+
if (info?.role === "user" && info.id && info.sessionID) {
|
|
242
|
+
trackUserMessage(info.sessionID, info.id);
|
|
243
|
+
const agent = info.agent;
|
|
244
|
+
if (agent && info.sessionID) {
|
|
245
|
+
state.setSessionAgent(info.sessionID, agent);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (state.botConfig && state.activeUsers.size > 0) {
|
|
249
|
+
if (info?.role === "assistant" && info.error) {
|
|
250
|
+
const msg = info.error.data?.message ||
|
|
251
|
+
info.error.message ||
|
|
252
|
+
info.error.name ||
|
|
253
|
+
"Unknown error";
|
|
254
|
+
try {
|
|
255
|
+
await sendToAllActive(state, `[Assistant Error] ${msg}`);
|
|
256
|
+
}
|
|
257
|
+
catch { }
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
// --- message.part.updated ---
|
|
262
|
+
if (event.type === "message.part.updated" &&
|
|
263
|
+
state.botConfig &&
|
|
264
|
+
state.activeUsers.size > 0) {
|
|
265
|
+
const e = event;
|
|
266
|
+
const part = e.properties?.part;
|
|
267
|
+
if (part?.type === "text") {
|
|
268
|
+
const textPart = part;
|
|
269
|
+
if (!isUserMessage(textPart.sessionID, textPart.messageID)) {
|
|
270
|
+
const key = `${textPart.sessionID}:${textPart.messageID}`;
|
|
271
|
+
if (!state.pendingResponses.has(key)) {
|
|
272
|
+
state.pendingResponses.set(key, {
|
|
273
|
+
sessionId: textPart.sessionID,
|
|
274
|
+
textBuffer: "",
|
|
275
|
+
lastUpdate: Date.now(),
|
|
276
|
+
sequence: ++messageSequenceCounter,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
const pending = state.pendingResponses.get(key);
|
|
280
|
+
if (e.properties.delta) {
|
|
281
|
+
pending.textBuffer += e.properties.delta;
|
|
282
|
+
}
|
|
283
|
+
else {
|
|
284
|
+
pending.textBuffer = textPart.text;
|
|
285
|
+
}
|
|
286
|
+
pending.lastUpdate = Date.now();
|
|
287
|
+
if (state.botConfig?.messageType === "card") {
|
|
288
|
+
scheduleCardStream(textPart.sessionID);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (part?.type === "tool") {
|
|
293
|
+
const toolPart = part;
|
|
294
|
+
const stateType = toolPart.state?.type || toolPart.state?.status;
|
|
295
|
+
if (stateType === "error" && toolPart.state?.error) {
|
|
296
|
+
try {
|
|
297
|
+
await sendToSession(state, `[Tool Error: ${toolPart.tool}]\n${toolPart.state.error}`, toolPart.sessionID);
|
|
298
|
+
}
|
|
299
|
+
catch { }
|
|
300
|
+
}
|
|
301
|
+
if (shouldNotify(state, "verbose") &&
|
|
302
|
+
(stateType === "completed" || stateType === "done") &&
|
|
303
|
+
toolPart.state.output) {
|
|
304
|
+
const output = toolPart.state.output;
|
|
305
|
+
if (output.length > 100) {
|
|
306
|
+
const summary = output.length > 1000 ? output.slice(0, 1000) + "..." : output;
|
|
307
|
+
try {
|
|
308
|
+
await sendToSession(state, `[Tool: ${toolPart.tool}]\n${summary}`, toolPart.sessionID);
|
|
309
|
+
}
|
|
310
|
+
catch { }
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
// --- session.idle ---
|
|
316
|
+
if (event.type === "session.idle" &&
|
|
317
|
+
state.botConfig &&
|
|
318
|
+
state.activeUsers.size > 0) {
|
|
319
|
+
const e = event;
|
|
320
|
+
const sessionId = e.properties?.sessionID;
|
|
321
|
+
// 验证 sessionId 是否有效
|
|
322
|
+
if (!sessionId || typeof sessionId !== "string") {
|
|
323
|
+
logger.warn(`[opencode-dingtalk] session.idle event with invalid sessionId: ${sessionId}, ignoring`);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
cancelCardStreamTimer();
|
|
327
|
+
cancelTodoDebounce();
|
|
328
|
+
// 修复:按 lastUpdate 时间排序,确保消息按实际产生顺序发送
|
|
329
|
+
// 原因:pendingResponses Map 遍历顺序不确定,可能导致消息乱序
|
|
330
|
+
// 添加 sequence 作为辅助排序键,确保当 lastUpdate 相同时消息顺序稳定
|
|
331
|
+
const sortedPendings = Array.from(state.pendingResponses.entries())
|
|
332
|
+
.filter(([_, pending]) => pending.sessionId === sessionId && pending.textBuffer.length > 0)
|
|
333
|
+
.sort((a, b) => {
|
|
334
|
+
// 先按 lastUpdate 升序排序
|
|
335
|
+
const timeDiff = a[1].lastUpdate - b[1].lastUpdate;
|
|
336
|
+
if (timeDiff !== 0)
|
|
337
|
+
return timeDiff;
|
|
338
|
+
// 当 lastUpdate 相同时,按 sequence 升序排序(消息产生顺序)
|
|
339
|
+
return a[1].sequence - b[1].sequence;
|
|
340
|
+
});
|
|
341
|
+
for (const [key, pending] of sortedPendings) {
|
|
342
|
+
if (!state.processedMessages.has(key)) {
|
|
343
|
+
state.processedMessages.add(key);
|
|
344
|
+
const text = pending.textBuffer;
|
|
345
|
+
if (text.length > 0) {
|
|
346
|
+
try {
|
|
347
|
+
const message = text.length > 6000
|
|
348
|
+
? text.slice(0, 6000) + "\n\n[Truncated...]"
|
|
349
|
+
: text;
|
|
350
|
+
let sentViaCard = false;
|
|
351
|
+
if (state.botConfig?.messageType === "card") {
|
|
352
|
+
const count = await flushCardStream(sessionId, true);
|
|
353
|
+
sentViaCard = count > 0;
|
|
354
|
+
}
|
|
355
|
+
if (!sentViaCard) {
|
|
356
|
+
// 检查是否是单聊(sessionId 在 sessionToUserMap 中)
|
|
357
|
+
const userId = state.getUserIdBySession(sessionId);
|
|
358
|
+
if (userId) {
|
|
359
|
+
// 单聊响应
|
|
360
|
+
const user = state.activeUsers.get(userId);
|
|
361
|
+
if (user?.sessionWebhook) {
|
|
362
|
+
logger.log(`[opencode-dingtalk][Single] Sending to user ${userId} via sessionWebhook`);
|
|
363
|
+
await sendBySessionWebhook(user.sessionWebhook, message, console);
|
|
364
|
+
logger.log(`[opencode-dingtalk][Single] Sent response to user ${userId}`);
|
|
365
|
+
}
|
|
366
|
+
else {
|
|
367
|
+
logger.log(`[opencode-dingtalk][Single] No sessionWebhook for user ${userId}, sending proactive message`);
|
|
368
|
+
await sendToUser(state, userId, message);
|
|
369
|
+
logger.log(`[opencode-dingtalk][Single] Sent proactive message to user ${userId}`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
else {
|
|
373
|
+
// 检查是否是群聊(sessionId 在 groupSessions 中)
|
|
374
|
+
let sentToGroup = false;
|
|
375
|
+
for (const [groupKey, groupSession] of state.groupSessions) {
|
|
376
|
+
if (groupSession.activeSessionId === sessionId &&
|
|
377
|
+
groupSession.sessionWebhook) {
|
|
378
|
+
logger.log(`[opencode-dingtalk][Group] Sending to group ${groupSession.conversationId}`);
|
|
379
|
+
await sendBySessionWebhook(groupSession.sessionWebhook, message, console);
|
|
380
|
+
sentToGroup = true;
|
|
381
|
+
logger.log(`[opencode-dingtalk][Group] Sent response to group ${groupSession.conversationId}`);
|
|
382
|
+
break;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
if (!sentToGroup) {
|
|
386
|
+
// sessionId 既不在 sessionToUserMap 也不在 groupSessions 中
|
|
387
|
+
logger.error(`[opencode-dingtalk][Error] Cannot route response for sessionId=${sessionId}: not found in sessionToUserMap or groupSessions`);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
// 发送后延迟 100ms,确保钉钉服务器按顺序处理消息
|
|
391
|
+
// 修复:消息顺序错乱问题 - 异步 HTTP 发送无顺序保证
|
|
392
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
catch (err) {
|
|
396
|
+
logger.error(`[opencode-dingtalk] Failed to send response: ${err.message}`, err);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
state.pendingResponses.delete(key);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
// 不再清理 session 到 user 的映射,单聊和群聊一样长期保留
|
|
403
|
+
// 这样权限申请、待办事项、异常等系统消息才能正确路由到对应的目标
|
|
404
|
+
state.usersWithPendingRequests.clear();
|
|
405
|
+
if (state.processedMessages.size > 100) {
|
|
406
|
+
const arr = Array.from(state.processedMessages);
|
|
407
|
+
arr.slice(0, 50).forEach((k) => state.processedMessages.delete(k));
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
// --- command.executed ---
|
|
411
|
+
if (event.type === "command.executed" &&
|
|
412
|
+
state.botConfig &&
|
|
413
|
+
state.activeUsers.size > 0) {
|
|
414
|
+
if (shouldNotify(state, "verbose")) {
|
|
415
|
+
const e = event;
|
|
416
|
+
try {
|
|
417
|
+
await sendToAllActive(state, `[Command] ${e.properties.name} ${e.properties.arguments}`);
|
|
418
|
+
}
|
|
419
|
+
catch { }
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// Silence all console output by default so plugin logs don't appear in the
|
|
2
|
+
// opencode UI. Remove or comment out these lines to enable debug logging.
|
|
3
|
+
console.log =
|
|
4
|
+
console.info =
|
|
5
|
+
console.warn =
|
|
6
|
+
console.error =
|
|
7
|
+
console.debug =
|
|
8
|
+
() => { };
|
|
9
|
+
import { tool } from "@opencode-ai/plugin/tool";
|
|
10
|
+
import { z } from "zod/v4";
|
|
11
|
+
import { resolveConfig, resolveInstances, } from "./config.js";
|
|
12
|
+
import { getOrCreateInstance, shutdownAll } from "./registry.js";
|
|
13
|
+
import { logger } from "./logger.js";
|
|
14
|
+
// ============ Helpers ============
|
|
15
|
+
function findConfigByName(name) {
|
|
16
|
+
const all = resolveInstances();
|
|
17
|
+
const lower = name.toLowerCase();
|
|
18
|
+
return all.find((c) => c.name?.toLowerCase() === lower || c.clientId?.toLowerCase() === lower);
|
|
19
|
+
}
|
|
20
|
+
function buildBotConfig(cfg) {
|
|
21
|
+
return {
|
|
22
|
+
clientId: cfg.clientId,
|
|
23
|
+
clientSecret: cfg.clientSecret,
|
|
24
|
+
robotCode: cfg.robotCode,
|
|
25
|
+
messageType: cfg.messageType,
|
|
26
|
+
cardTemplateId: cfg.cardTemplateId,
|
|
27
|
+
notifyLevel: cfg.notifyLevel,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
// ============ Plugin Definition ============
|
|
31
|
+
export const OpenCodeDingtalkPlugin = async ({ client, serverUrl }) => {
|
|
32
|
+
// 添加详细调试日志
|
|
33
|
+
logger.log("[opencode-dingtalk][DEBUG] === Plugin starting ===");
|
|
34
|
+
logger.log("[opencode-dingtalk][DEBUG] client:", client ? "exists" : "null");
|
|
35
|
+
logger.log("[opencode-dingtalk][DEBUG] serverUrl:", serverUrl?.toString() || "undefined");
|
|
36
|
+
const config = resolveConfig();
|
|
37
|
+
logger.log("[opencode-dingtalk][DEBUG] resolveConfig() returned:", JSON.stringify({
|
|
38
|
+
clientId: config.clientId ? "***" : undefined,
|
|
39
|
+
clientSecret: config.clientSecret ? "***" : undefined,
|
|
40
|
+
robotCode: config.robotCode,
|
|
41
|
+
autoStart: config.autoStart,
|
|
42
|
+
messageType: config.messageType,
|
|
43
|
+
opencodeUrl: config.opencodeUrl,
|
|
44
|
+
}));
|
|
45
|
+
const instanceId = config.clientId || "default";
|
|
46
|
+
logger.log("[opencode-dingtalk][DEBUG] instanceId:", instanceId);
|
|
47
|
+
const hasCreds = !!(config.clientId && config.clientSecret);
|
|
48
|
+
logger.log("[opencode-dingtalk][DEBUG] hasCreds:", hasCreds);
|
|
49
|
+
const instance = getOrCreateInstance({
|
|
50
|
+
instanceId,
|
|
51
|
+
client,
|
|
52
|
+
serverUrl: serverUrl?.toString(),
|
|
53
|
+
...(hasCreds ? { botConfig: buildBotConfig(config) } : {}),
|
|
54
|
+
});
|
|
55
|
+
logger.log("[opencode-dingtalk][DEBUG] After getOrCreateInstance:");
|
|
56
|
+
logger.log("[opencode-dingtalk][DEBUG] - options passed:", JSON.stringify({
|
|
57
|
+
instanceId,
|
|
58
|
+
hasClient: !!client,
|
|
59
|
+
hasServerUrl: !!serverUrl,
|
|
60
|
+
hasBotConfig: hasCreds,
|
|
61
|
+
}));
|
|
62
|
+
logger.log("[opencode-dingtalk][DEBUG] - instance.isRunning:", instance.isRunning);
|
|
63
|
+
logger.log("[opencode-dingtalk][DEBUG] - instance.state.botConfig:", instance.state.botConfig ? "exists" : "null");
|
|
64
|
+
if (instance.state.botConfig) {
|
|
65
|
+
logger.log("[opencode-dingtalk][DEBUG] - botConfig.clientId:", instance.state.botConfig.clientId);
|
|
66
|
+
logger.log("[opencode-dingtalk][DEBUG] - botConfig.robotCode:", instance.state.botConfig.robotCode);
|
|
67
|
+
}
|
|
68
|
+
// 检查实际的连接状态
|
|
69
|
+
if (instance.state.connectionManager) {
|
|
70
|
+
const connState = instance.state.connectionManager.getState();
|
|
71
|
+
const metrics = instance.state.connectionManager.getMetrics();
|
|
72
|
+
logger.log("[opencode-dingtalk][DEBUG] ConnectionManager state:", connState);
|
|
73
|
+
logger.log("[opencode-dingtalk][DEBUG] ConnectionManager metrics:", JSON.stringify({
|
|
74
|
+
connectedAt: metrics.connectedAt,
|
|
75
|
+
totalConnectedMs: metrics.totalConnectedMs,
|
|
76
|
+
reconnectAttempts: metrics.reconnectAttempts,
|
|
77
|
+
lastError: metrics.lastError,
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
logger.log("[opencode-dingtalk][DEBUG] No ConnectionManager initialized yet");
|
|
82
|
+
}
|
|
83
|
+
// Auto-start bot if configured
|
|
84
|
+
logger.log("[opencode-dingtalk][DEBUG] Checking auto-start conditions:", {
|
|
85
|
+
configAutoStart: config.autoStart,
|
|
86
|
+
hasCreds: hasCreds,
|
|
87
|
+
isRunning: instance.isRunning,
|
|
88
|
+
shouldStart: config.autoStart && hasCreds && !instance.isRunning,
|
|
89
|
+
});
|
|
90
|
+
if (config.autoStart && hasCreds && !instance.isRunning) {
|
|
91
|
+
logger.log("[opencode-dingtalk][DEBUG] === Starting bot ===");
|
|
92
|
+
try {
|
|
93
|
+
logger.log("[opencode-dingtalk][DEBUG] Calling instance.startWithConfig()...");
|
|
94
|
+
await instance.startWithConfig(buildBotConfig(config));
|
|
95
|
+
logger.log("[opencode-dingtalk][DEBUG] startWithConfig() completed");
|
|
96
|
+
logger.log(`[opencode-dingtalk] Auto-started bot (mode: ${config.messageType})`);
|
|
97
|
+
if (instance.state.activeUsers.size > 0) {
|
|
98
|
+
await instance
|
|
99
|
+
.sendToAllActive("机器人已连接,可以开始使用了")
|
|
100
|
+
.catch(() => { });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
105
|
+
logger.log("[opencode-dingtalk][DEBUG] Error during start:", msg);
|
|
106
|
+
if (msg.includes("another process")) {
|
|
107
|
+
logger.log("[opencode-dingtalk] Bot already running in another process, skipping auto-start");
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
logger.error("[opencode-dingtalk] Auto-start failed:", err);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
logger.log("[opencode-dingtalk][DEBUG] === NOT starting bot, conditions not met ===");
|
|
116
|
+
if (!config.autoStart)
|
|
117
|
+
logger.log("[opencode-dingtalk][DEBUG] Reason: autoStart is false");
|
|
118
|
+
if (!hasCreds)
|
|
119
|
+
logger.log("[opencode-dingtalk][DEBUG] Reason: missing credentials");
|
|
120
|
+
if (instance.isRunning)
|
|
121
|
+
logger.log("[opencode-dingtalk][DEBUG] Reason: already running");
|
|
122
|
+
}
|
|
123
|
+
logger.log("[opencode-dingtalk][DEBUG] === Plugin initialization complete, returning handlers ===");
|
|
124
|
+
return {
|
|
125
|
+
event: async ({ event }) => {
|
|
126
|
+
const evt = event;
|
|
127
|
+
await instance.handleEvent(evt);
|
|
128
|
+
},
|
|
129
|
+
tool: {
|
|
130
|
+
"opencode-dingtalk-send": tool({
|
|
131
|
+
description: "向指定用户发送消息",
|
|
132
|
+
args: {
|
|
133
|
+
userId: z.string().describe("用户工号"),
|
|
134
|
+
message: z.string().describe("要发送的消息内容"),
|
|
135
|
+
},
|
|
136
|
+
async execute({ userId, message }) {
|
|
137
|
+
try {
|
|
138
|
+
const success = await instance.sendToUser(userId, message);
|
|
139
|
+
if (success) {
|
|
140
|
+
return `消息已成功发送给用户 ${userId}`;
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
return `发送失败,请检查用户工号是否正确或机器人是否正常运行`;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
return `Error: ${error instanceof Error ? error.message : "Unknown error"}`;
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
}),
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
};
|
|
154
|
+
export default OpenCodeDingtalkPlugin;
|
|
155
|
+
// Ensure all instances are stopped when the host process exits
|
|
156
|
+
async function shutdown() {
|
|
157
|
+
try {
|
|
158
|
+
await shutdownAll();
|
|
159
|
+
}
|
|
160
|
+
catch { }
|
|
161
|
+
process.exit(0);
|
|
162
|
+
}
|
|
163
|
+
process.on("SIGINT", shutdown);
|
|
164
|
+
process.on("SIGTERM", shutdown);
|
package/dist/instance.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { InstanceState } from "./state.js";
|
|
2
|
+
import { startBot, stopBot, sendToAllActive, sendToUser } from "./dingtalk/bot.js";
|
|
3
|
+
import { createEventHandler } from "./events.js";
|
|
4
|
+
export class BotInstance {
|
|
5
|
+
state;
|
|
6
|
+
eventHandler;
|
|
7
|
+
constructor(options) {
|
|
8
|
+
this.state = new InstanceState(options.instanceId);
|
|
9
|
+
if (options.client)
|
|
10
|
+
this.state.client = options.client;
|
|
11
|
+
if (options.serverUrl)
|
|
12
|
+
this.state.serverUrl = options.serverUrl;
|
|
13
|
+
if (options.botConfig)
|
|
14
|
+
this.state.botConfig = options.botConfig;
|
|
15
|
+
this.eventHandler = createEventHandler(this.state);
|
|
16
|
+
}
|
|
17
|
+
get instanceId() {
|
|
18
|
+
return this.state.instanceId;
|
|
19
|
+
}
|
|
20
|
+
get isRunning() {
|
|
21
|
+
return this.state.botConfig !== null && this.state.connectionManager !== null;
|
|
22
|
+
}
|
|
23
|
+
/** Start the DingTalk Stream connection using state.botConfig. */
|
|
24
|
+
async start() {
|
|
25
|
+
if (!this.state.botConfig)
|
|
26
|
+
throw new Error(`[${this.instanceId}] Bot not configured`);
|
|
27
|
+
await startBot(this.state, this.state.botConfig);
|
|
28
|
+
}
|
|
29
|
+
/** Start with an explicit config (sets state.botConfig first). */
|
|
30
|
+
async startWithConfig(config) {
|
|
31
|
+
await startBot(this.state, config);
|
|
32
|
+
}
|
|
33
|
+
/** Disconnect from DingTalk and clean up. */
|
|
34
|
+
async stop() {
|
|
35
|
+
await stopBot(this.state);
|
|
36
|
+
}
|
|
37
|
+
/** Dispatch an OpenCode event to this instance's handler. */
|
|
38
|
+
async handleEvent(event) {
|
|
39
|
+
await this.eventHandler(event);
|
|
40
|
+
}
|
|
41
|
+
/** Send a message to all active users of this instance. */
|
|
42
|
+
async sendToAllActive(message) {
|
|
43
|
+
return sendToAllActive(this.state, message);
|
|
44
|
+
}
|
|
45
|
+
/** Send a message to a specific user. */
|
|
46
|
+
async sendToUser(userId, message) {
|
|
47
|
+
return sendToUser(this.state, userId, message);
|
|
48
|
+
}
|
|
49
|
+
}
|