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.
@@ -0,0 +1,126 @@
1
+ // DingTalk API 封装 - 使用 undici/fetch 实现
2
+ // 替代 dingtalk-stream 和 axios
3
+ const DINGTALK_API = "https://api.dingtalk.com";
4
+ // ============ 工具函数 ============
5
+ /**
6
+ * 带重试的 API 请求
7
+ */
8
+ async function fetchWithRetry(url, options, maxRetries = 3) {
9
+ let lastError = null;
10
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
11
+ try {
12
+ const response = await fetch(url, {
13
+ ...options,
14
+ headers: {
15
+ "Content-Type": "application/json",
16
+ ...options.headers,
17
+ },
18
+ });
19
+ const data = await response.json();
20
+ // 检查钉钉 API 错误码
21
+ if (data.errcode && data.errcode !== 0) {
22
+ const statusCode = response.status;
23
+ const isRetryable = data.errcode === 401 || // 未授权
24
+ data.errcode === 429 || // 请求过多
25
+ (data.errcode && data.errcode >= 500); // 服务端错误
26
+ if (isRetryable && attempt < maxRetries) {
27
+ const delayMs = 100 * Math.pow(2, attempt - 1);
28
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
29
+ continue;
30
+ }
31
+ throw new Error(`DingTalk API error: ${data.errmsg || data.errcode}`);
32
+ }
33
+ return data;
34
+ }
35
+ catch (err) {
36
+ lastError = err instanceof Error ? err : new Error(String(err));
37
+ // 网络错误时重试
38
+ if (attempt < maxRetries) {
39
+ const delayMs = 100 * Math.pow(2, attempt - 1);
40
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
41
+ }
42
+ }
43
+ }
44
+ throw lastError || new Error("Fetch exhausted without returning");
45
+ }
46
+ // ============ 公开 API ============
47
+ /**
48
+ * 获取钉钉 access_token
49
+ */
50
+ export async function getAccessToken(clientId, clientSecret) {
51
+ const url = `${DINGTALK_API}/v1.0/oauth2/accessToken`;
52
+ const body = {
53
+ appKey: clientId,
54
+ appSecret: clientSecret,
55
+ };
56
+ const result = await fetchWithRetry(url, {
57
+ method: "POST",
58
+ body: JSON.stringify(body),
59
+ });
60
+ return result.accessToken;
61
+ }
62
+ /**
63
+ * 发送文本消息
64
+ */
65
+ export async function sendTextMessage(webhook, content) {
66
+ const payload = {
67
+ msgtype: "text",
68
+ text: { content },
69
+ };
70
+ await fetchWithRetry(webhook, {
71
+ method: "POST",
72
+ body: JSON.stringify(payload),
73
+ });
74
+ }
75
+ /**
76
+ * 发送 Markdown 消息
77
+ */
78
+ export async function sendMarkdownMessage(webhook, title, text) {
79
+ const payload = {
80
+ msgtype: "markdown",
81
+ markdown: { title, text },
82
+ };
83
+ await fetchWithRetry(webhook, {
84
+ method: "POST",
85
+ body: JSON.stringify(payload),
86
+ });
87
+ }
88
+ /**
89
+ * 发送卡片消息
90
+ */
91
+ export async function sendCardMessage(webhook, cardTemplateId, cardData) {
92
+ const payload = {
93
+ msgtype: "interactive_card",
94
+ interactive_card: {
95
+ card_template_id: cardTemplateId,
96
+ card_data: cardData,
97
+ },
98
+ };
99
+ await fetchWithRetry(webhook, {
100
+ method: "POST",
101
+ body: JSON.stringify(payload),
102
+ });
103
+ }
104
+ /**
105
+ * 发送卡片交互回调响应
106
+ */
107
+ export async function sendCardCallback(accessToken, callbackId, responseData) {
108
+ const url = `${DINGTALK_API}/v1.0/card/streaming?access_token=${accessToken}`;
109
+ const streamBody = {
110
+ callbackId,
111
+ responseData,
112
+ };
113
+ await fetchWithRetry(url, {
114
+ method: "PUT",
115
+ body: JSON.stringify(streamBody),
116
+ });
117
+ }
118
+ /**
119
+ * 发送消息到群聊 webhook
120
+ */
121
+ export async function sendToConversation(webhook, message) {
122
+ await fetchWithRetry(webhook, {
123
+ method: "POST",
124
+ body: JSON.stringify(message),
125
+ });
126
+ }
@@ -0,0 +1,390 @@
1
+ /**
2
+ * DingTalk Stream Client - 基于 undici 的实现
3
+ *
4
+ * 移除了 dingtalk-stream SDK(CJS包问题),使用 undici 重写
5
+ * 保留了与原 DWClient 相同的接口
6
+ */
7
+ import EventEmitter from "events";
8
+ import { request, WebSocket as UndiciWebSocket } from "undici";
9
+ import { EventAck } from "./types.js";
10
+ import { logger } from "../logger.js";
11
+ const GATEWAY_URL = "https://api.dingtalk.com/v1.0/gateway/connections/open";
12
+ const GET_TOKEN_URL = "https://oapi.dingtalk.com/gettoken";
13
+ export { GATEWAY_URL, GET_TOKEN_URL };
14
+ export { TOPIC_ROBOT, EventAck } from "./types.js";
15
+ const defaultConfig = {
16
+ clientId: "",
17
+ clientSecret: "",
18
+ keepAlive: false,
19
+ debug: false,
20
+ ua: "",
21
+ endpoint: "",
22
+ access_token: "",
23
+ autoReconnect: true,
24
+ subscriptions: [{ type: "EVENT", topic: "*" }],
25
+ };
26
+ export class DingTalkClient extends EventEmitter {
27
+ debug = false;
28
+ connected = false;
29
+ registered = false;
30
+ reconnecting = false;
31
+ userDisconnect = false;
32
+ reconnectBaseInterval = 1000;
33
+ reconnectMaxInterval = 60000;
34
+ reconnectAttempts = 0;
35
+ heartbeat_interval = 8000;
36
+ heartbeatIntervalId;
37
+ reconnectTimerId;
38
+ isConnecting = false;
39
+ sslopts = { rejectUnauthorized: true };
40
+ config;
41
+ socket;
42
+ dw_url;
43
+ isAlive = false;
44
+ onEventReceived = () => ({
45
+ status: EventAck.SUCCESS,
46
+ });
47
+ constructor(opts) {
48
+ super();
49
+ this.config = {
50
+ ...defaultConfig,
51
+ ...opts,
52
+ };
53
+ if (!this.config.clientId || !this.config.clientSecret) {
54
+ throw new Error("clientId or clientSecret is null");
55
+ }
56
+ if (this.config.debug !== undefined) {
57
+ this.debug = this.config.debug;
58
+ }
59
+ }
60
+ getConfig() {
61
+ return { ...this.config };
62
+ }
63
+ printDebug(msg) {
64
+ logger.debug("[opencode-dingtalk]", msg);
65
+ }
66
+ registerAllEventListener(onEventReceived) {
67
+ this.onEventReceived = onEventReceived;
68
+ return this;
69
+ }
70
+ registerCallbackListener(eventId, callback) {
71
+ if (!eventId || !callback) {
72
+ throw new Error("registerCallbackListener: eventId and callback must be defined");
73
+ }
74
+ if (!this.config.subscriptions.find((x) => x.topic === eventId && x.type === "CALLBACK")) {
75
+ this.config.subscriptions.push({
76
+ type: "CALLBACK",
77
+ topic: eventId,
78
+ });
79
+ }
80
+ this.on(eventId, callback);
81
+ return this;
82
+ }
83
+ async getAccessToken() {
84
+ const url = `${GET_TOKEN_URL}?appkey=${this.config.clientId}&appsecret=${this.config.clientSecret}`;
85
+ const response = await request(url, {
86
+ method: "GET",
87
+ headers: { Accept: "application/json" },
88
+ });
89
+ if (response.statusCode === 200) {
90
+ const data = (await response.body.json());
91
+ if (data.access_token) {
92
+ this.config.access_token = data.access_token;
93
+ return data.access_token;
94
+ }
95
+ }
96
+ throw new Error("getAccessToken: get access_token failed");
97
+ }
98
+ async getEndpoint() {
99
+ this.printDebug("get connect endpoint by config");
100
+ this.printDebug(this.config);
101
+ logger.log("[opencode-dingtalk] Calling DingTalk gateway:", GATEWAY_URL);
102
+ logger.log("[opencode-dingtalk] Request body:", JSON.stringify({
103
+ clientId: this.config.clientId,
104
+ clientSecret: this.config.clientSecret,
105
+ ua: this.config.ua,
106
+ subscriptions: this.config.subscriptions,
107
+ }, null, 2));
108
+ const response = await request(GATEWAY_URL, {
109
+ method: "POST",
110
+ headers: {
111
+ Accept: "application/json",
112
+ "Content-Type": "application/json",
113
+ "User-Agent": "opencode-dingtalk/0.2.0",
114
+ },
115
+ body: JSON.stringify({
116
+ clientId: this.config.clientId,
117
+ clientSecret: this.config.clientSecret,
118
+ ua: this.config.ua,
119
+ subscriptions: this.config.subscriptions,
120
+ }),
121
+ });
122
+ const data = (await response.body.json());
123
+ logger.log("[opencode-dingtalk] Gateway response status:", response.statusCode);
124
+ logger.log("[opencode-dingtalk] Gateway response data:", JSON.stringify(data, null, 2));
125
+ this.printDebug("res.data " + JSON.stringify(data));
126
+ if (data.endpoint && data.ticket) {
127
+ this.config.endpoint = data.endpoint;
128
+ this.dw_url = `${data.endpoint}?ticket=${data.ticket}`;
129
+ logger.log("[opencode-dingtalk] WebSocket URL:", this.dw_url);
130
+ return this;
131
+ }
132
+ else {
133
+ throw new Error("build: get endpoint failed");
134
+ }
135
+ }
136
+ cleanup() {
137
+ if (this.heartbeatIntervalId) {
138
+ clearInterval(this.heartbeatIntervalId);
139
+ this.heartbeatIntervalId = undefined;
140
+ }
141
+ if (this.socket) {
142
+ this.socket.close();
143
+ this.socket = undefined;
144
+ }
145
+ }
146
+ scheduleReconnect() {
147
+ if (!this.config.autoReconnect ||
148
+ this.userDisconnect ||
149
+ this.isConnecting) {
150
+ return;
151
+ }
152
+ const delay = Math.min(this.reconnectBaseInterval * Math.pow(2, this.reconnectAttempts) +
153
+ Math.random() * 1000, this.reconnectMaxInterval);
154
+ this.reconnecting = true;
155
+ this.printDebug("Reconnecting in " +
156
+ (delay / 1000).toFixed(1) +
157
+ " seconds... (attempt " +
158
+ (this.reconnectAttempts + 1) +
159
+ ")");
160
+ if (this.reconnectTimerId) {
161
+ clearTimeout(this.reconnectTimerId);
162
+ }
163
+ this.reconnectTimerId = setTimeout(() => {
164
+ this.reconnectTimerId = undefined;
165
+ this.connect();
166
+ }, delay);
167
+ }
168
+ async _connect() {
169
+ return new Promise((resolve, reject) => {
170
+ if (!this.dw_url) {
171
+ reject(new Error("dw_url is not defined"));
172
+ return;
173
+ }
174
+ this.printDebug("Connecting to dingtalk websocket @ " + this.dw_url);
175
+ try {
176
+ this.socket = new UndiciWebSocket(this.dw_url);
177
+ }
178
+ catch (err) {
179
+ this.printDebug("WebSocket constructor error");
180
+ logger.warn("[opencode-dingtalk] ERROR", err);
181
+ reject(err);
182
+ return;
183
+ }
184
+ let settled = false;
185
+ this.socket.addEventListener("open", () => {
186
+ this.connected = true;
187
+ this.reconnectAttempts = 0;
188
+ logger.log("[opencode-dingtalk] connect success");
189
+ if (this.config.keepAlive) {
190
+ this.isAlive = true;
191
+ this.heartbeatIntervalId = setInterval(() => {
192
+ if (this.isAlive === false) {
193
+ logger.error("[opencode-dingtalk] TERMINATE SOCKET: Ping Pong does not transfer heartbeat within heartbeat interval");
194
+ this.socket?.close();
195
+ return;
196
+ }
197
+ this.isAlive = false;
198
+ }, this.heartbeat_interval);
199
+ }
200
+ settled = true;
201
+ resolve();
202
+ });
203
+ this.socket.addEventListener("pong", () => {
204
+ this.heartbeat();
205
+ });
206
+ this.socket.addEventListener("message", (event) => {
207
+ const messageStr = typeof event.data === "string" ? event.data : event.data.toString();
208
+ this.printDebug("[DEBUG] Raw message received: " + messageStr.slice(0, 200));
209
+ this.onDownStream(messageStr);
210
+ });
211
+ this.socket.addEventListener("close", () => {
212
+ this.printDebug("Socket closed");
213
+ this.connected = false;
214
+ this.registered = false;
215
+ if (this.heartbeatIntervalId) {
216
+ clearInterval(this.heartbeatIntervalId);
217
+ this.heartbeatIntervalId = undefined;
218
+ }
219
+ if (settled) {
220
+ this.scheduleReconnect();
221
+ }
222
+ });
223
+ this.socket.addEventListener("error", (err) => {
224
+ this.printDebug("SOCKET ERROR");
225
+ logger.warn("[opencode-dingtalk] ERROR", err);
226
+ this.socket?.close();
227
+ if (!settled) {
228
+ settled = true;
229
+ reject(err);
230
+ }
231
+ });
232
+ });
233
+ }
234
+ async connect() {
235
+ logger.log("[opencode-dingtalk] connect() called");
236
+ if (this.isConnecting) {
237
+ this.printDebug("connect() already in progress, skipping");
238
+ return;
239
+ }
240
+ this.userDisconnect = false;
241
+ this.isConnecting = true;
242
+ try {
243
+ logger.log("[opencode-dingtalk] calling cleanup()");
244
+ this.cleanup();
245
+ logger.log("[opencode-dingtalk] calling getEndpoint()");
246
+ await this.getEndpoint();
247
+ logger.log("[opencode-dingtalk] getEndpoint() completed, dw_url:", this.dw_url);
248
+ if (this.userDisconnect)
249
+ return;
250
+ logger.log("[opencode-dingtalk] calling _connect()");
251
+ await this._connect();
252
+ logger.log("[opencode-dingtalk] _connect() completed");
253
+ }
254
+ catch (err) {
255
+ logger.error("[opencode-dingtalk] Connect failed:", err);
256
+ this.printDebug("Connect failed: " + (err instanceof Error ? err.message : String(err)));
257
+ if (!this.userDisconnect) {
258
+ this.reconnectAttempts++;
259
+ this.isConnecting = false;
260
+ this.scheduleReconnect();
261
+ }
262
+ return;
263
+ }
264
+ finally {
265
+ this.isConnecting = false;
266
+ }
267
+ }
268
+ disconnect() {
269
+ logger.log("[opencode-dingtalk] Disconnecting.");
270
+ this.userDisconnect = true;
271
+ if (this.reconnectTimerId) {
272
+ clearTimeout(this.reconnectTimerId);
273
+ this.reconnectTimerId = undefined;
274
+ }
275
+ this.reconnecting = false;
276
+ this.reconnectAttempts = 0;
277
+ this.cleanup();
278
+ this.connected = false;
279
+ this.registered = false;
280
+ }
281
+ heartbeat() {
282
+ this.isAlive = true;
283
+ this.printDebug("CLIENT-SIDE HEARTBEAT");
284
+ }
285
+ onDownStream(data) {
286
+ this.printDebug("[DEBUG] Received message from dingtalk websocket server");
287
+ this.printDebug("[DEBUG] Message length: " + data.length);
288
+ try {
289
+ const msg = JSON.parse(data);
290
+ this.printDebug("[DEBUG] Parsed message: " + JSON.stringify(msg));
291
+ this.printDebug("[DEBUG] Message type: " + msg.type);
292
+ switch (msg.type) {
293
+ case "SYSTEM":
294
+ this.onSystem(msg);
295
+ break;
296
+ case "EVENT":
297
+ this.onEvent(msg);
298
+ break;
299
+ case "CALLBACK":
300
+ this.onCallback(msg);
301
+ break;
302
+ default:
303
+ this.printDebug("[DEBUG] Unknown message type: " + msg.type);
304
+ }
305
+ }
306
+ catch (err) {
307
+ this.printDebug("Failed to parse message: " + err);
308
+ }
309
+ }
310
+ onSystem(downstream) {
311
+ this.printDebug("[DEBUG] System message received, topic: " + downstream.headers.topic);
312
+ switch (downstream.headers.topic) {
313
+ case "CONNECTED": {
314
+ this.printDebug("CONNECTED");
315
+ this.emit("connected", downstream);
316
+ break;
317
+ }
318
+ case "REGISTERED": {
319
+ this.printDebug("REGISTERED");
320
+ this.registered = true;
321
+ this.reconnecting = false;
322
+ this.emit("registered", downstream);
323
+ break;
324
+ }
325
+ case "disconnect": {
326
+ this.connected = false;
327
+ this.registered = false;
328
+ this.emit("disconnected", downstream);
329
+ break;
330
+ }
331
+ case "KEEPALIVE": {
332
+ this.heartbeat();
333
+ break;
334
+ }
335
+ case "ping": {
336
+ this.printDebug("PING");
337
+ this.socket?.send(JSON.stringify({
338
+ code: 200,
339
+ headers: downstream.headers,
340
+ message: "OK",
341
+ data: downstream.data,
342
+ }));
343
+ break;
344
+ }
345
+ }
346
+ }
347
+ onEvent(message) {
348
+ this.printDebug("received event, message=" + JSON.stringify(message));
349
+ const ackData = this.onEventReceived(message);
350
+ this.socket?.send(JSON.stringify({
351
+ code: 200,
352
+ headers: {
353
+ contentType: "application/json",
354
+ messageId: message.headers.messageId,
355
+ },
356
+ message: "OK",
357
+ data: JSON.stringify(ackData),
358
+ }));
359
+ }
360
+ onCallback(message) {
361
+ this.printDebug("[DEBUG] Callback message received, topic: " + message.headers.topic);
362
+ this.printDebug("[DEBUG] Full callback message: " + JSON.stringify(message));
363
+ this.emit(message.headers.topic, message);
364
+ }
365
+ send(messageId, value) {
366
+ if (!messageId) {
367
+ throw new Error("send: messageId must be defined");
368
+ }
369
+ const msg = {
370
+ code: 200,
371
+ headers: {
372
+ contentType: "application/json",
373
+ messageId: messageId,
374
+ },
375
+ message: "OK",
376
+ data: JSON.stringify(value),
377
+ };
378
+ this.socket?.send(JSON.stringify(msg));
379
+ }
380
+ socketCallBackResponse(messageId, result) {
381
+ this.send(messageId, { response: result });
382
+ }
383
+ removeListener(event, handler) {
384
+ return super.removeListener(event, handler);
385
+ }
386
+ removeAllListeners() {
387
+ super.removeAllListeners();
388
+ return this;
389
+ }
390
+ }
@@ -0,0 +1,8 @@
1
+ export var EventAck;
2
+ (function (EventAck) {
3
+ EventAck["SUCCESS"] = "SUCCESS";
4
+ EventAck["LATER"] = "LATER";
5
+ })(EventAck || (EventAck = {}));
6
+ export const TOPIC_ROBOT = '/v1.0/im/bot/messages/get';
7
+ export const TOPIC_CARD = '/v1.0/card/instances/callback';
8
+ export const TOPIC_AI_GRAPH_API = '/v1.0/graph/api/invoke';