@xuanmiss-npm/dingtalk 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/src/config.ts ADDED
@@ -0,0 +1,185 @@
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
2
+
3
+ // ============================================================================
4
+ // 类型定义
5
+ // ============================================================================
6
+
7
+ export type DingtalkTokenSource = "config" | "env" | "none";
8
+
9
+ export type DingtalkAccountConfig = {
10
+ enabled?: boolean;
11
+ name?: string;
12
+ clientId?: string;
13
+ clientSecret?: string;
14
+ robotCode?: string;
15
+ allowFrom?: Array<string | number>;
16
+ dmPolicy?: "open" | "allowlist" | "pairing";
17
+ groups?: Record<
18
+ string,
19
+ {
20
+ requireMention?: boolean;
21
+ allowFrom?: string[];
22
+ }
23
+ >;
24
+ groupPolicy?: "open" | "allowlist";
25
+ };
26
+
27
+ export type ResolvedDingtalkAccount = {
28
+ accountId: string;
29
+ name?: string;
30
+ enabled: boolean;
31
+ clientId: string;
32
+ clientSecret: string;
33
+ robotCode?: string;
34
+ tokenSource: DingtalkTokenSource;
35
+ config: DingtalkAccountConfig;
36
+ };
37
+
38
+ export type DingtalkProbeResult = {
39
+ ok: boolean;
40
+ robot?: {
41
+ name?: string;
42
+ robotCode?: string;
43
+ };
44
+ error?: string;
45
+ };
46
+
47
+ export type DingtalkConfig = {
48
+ enabled?: boolean;
49
+ name?: string;
50
+ clientId?: string;
51
+ clientSecret?: string;
52
+ robotCode?: string;
53
+ dmPolicy?: "open" | "allowlist" | "pairing";
54
+ allowFrom?: Array<string | number>;
55
+ groupPolicy?: "open" | "allowlist";
56
+ groups?: Record<
57
+ string,
58
+ {
59
+ requireMention?: boolean;
60
+ allowFrom?: string[];
61
+ }
62
+ >;
63
+ accounts?: Record<
64
+ string,
65
+ {
66
+ enabled?: boolean;
67
+ name?: string;
68
+ clientId?: string;
69
+ clientSecret?: string;
70
+ robotCode?: string;
71
+ dmPolicy?: "open" | "allowlist" | "pairing";
72
+ allowFrom?: Array<string | number>;
73
+ }
74
+ >;
75
+ };
76
+
77
+ // ============================================================================
78
+ // 常量
79
+ // ============================================================================
80
+
81
+ export const DEFAULT_ACCOUNT_ID = "default";
82
+
83
+ // ============================================================================
84
+ // 配置解析函数
85
+ // ============================================================================
86
+
87
+ /**
88
+ * 解析钉钉账户配置
89
+ */
90
+ export function resolveDingtalkAccount(params: {
91
+ cfg: OpenClawConfig;
92
+ accountId?: string | null;
93
+ }): ResolvedDingtalkAccount {
94
+ const { cfg, accountId: rawAccountId } = params;
95
+ const accountId = rawAccountId?.trim() || DEFAULT_ACCOUNT_ID;
96
+ const section = (cfg.channels as Record<string, unknown> | undefined)?.dingtalk as
97
+ | DingtalkConfig
98
+ | undefined;
99
+
100
+ // 从环境变量读取
101
+ const envClientId = process.env.DINGTALK_CLIENT_ID?.trim() || "";
102
+ const envClientSecret = process.env.DINGTALK_CLIENT_SECRET?.trim() || "";
103
+ const envRobotCode = process.env.DINGTALK_ROBOT_CODE?.trim() || "";
104
+
105
+ if (accountId === DEFAULT_ACCOUNT_ID) {
106
+ // 默认账户:优先配置文件,fallback到环境变量
107
+ const clientId = section?.clientId?.trim() || envClientId;
108
+ const clientSecret = section?.clientSecret?.trim() || envClientSecret;
109
+ const robotCode = section?.robotCode?.trim() || envRobotCode || clientId;
110
+
111
+ return {
112
+ accountId,
113
+ name: section?.name,
114
+ enabled: section?.enabled !== false,
115
+ clientId,
116
+ clientSecret,
117
+ robotCode,
118
+ tokenSource: section?.clientId ? "config" : envClientId ? "env" : "none",
119
+ config: {
120
+ enabled: section?.enabled,
121
+ name: section?.name,
122
+ clientId: section?.clientId,
123
+ clientSecret: section?.clientSecret,
124
+ robotCode: section?.robotCode,
125
+ allowFrom: section?.allowFrom,
126
+ dmPolicy: section?.dmPolicy,
127
+ groups: section?.groups,
128
+ groupPolicy: section?.groupPolicy,
129
+ },
130
+ };
131
+ }
132
+
133
+ // 命名账户
134
+ const accountConfig = section?.accounts?.[accountId];
135
+ const clientId = accountConfig?.clientId?.trim() || "";
136
+ const clientSecret = accountConfig?.clientSecret?.trim() || "";
137
+ const robotCode = accountConfig?.robotCode?.trim() || clientId;
138
+
139
+ return {
140
+ accountId,
141
+ name: accountConfig?.name,
142
+ enabled: accountConfig?.enabled !== false,
143
+ clientId,
144
+ clientSecret,
145
+ robotCode,
146
+ tokenSource: clientId ? "config" : "none",
147
+ config: {
148
+ enabled: accountConfig?.enabled,
149
+ name: accountConfig?.name,
150
+ clientId: accountConfig?.clientId,
151
+ clientSecret: accountConfig?.clientSecret,
152
+ robotCode: accountConfig?.robotCode,
153
+ allowFrom: accountConfig?.allowFrom,
154
+ dmPolicy: accountConfig?.dmPolicy,
155
+ },
156
+ };
157
+ }
158
+
159
+ /**
160
+ * 列出所有钉钉账户ID
161
+ */
162
+ export function listDingtalkAccountIds(cfg: OpenClawConfig): string[] {
163
+ const section = (cfg.channels as Record<string, unknown> | undefined)?.dingtalk as
164
+ | DingtalkConfig
165
+ | undefined;
166
+ const accounts = section?.accounts || {};
167
+ const ids = Object.keys(accounts).filter((id) => id.trim());
168
+
169
+ // 如果有默认配置或环境变量,添加default
170
+ const hasDefaultConfig = !!(section?.clientId || process.env.DINGTALK_CLIENT_ID);
171
+
172
+ if (hasDefaultConfig && !ids.includes(DEFAULT_ACCOUNT_ID)) {
173
+ return [DEFAULT_ACCOUNT_ID, ...ids];
174
+ }
175
+
176
+ return ids.length > 0 ? ids : [DEFAULT_ACCOUNT_ID];
177
+ }
178
+
179
+ /**
180
+ * 解析默认钉钉账户ID
181
+ */
182
+ export function resolveDefaultDingtalkAccountId(cfg: OpenClawConfig): string {
183
+ const ids = listDingtalkAccountIds(cfg);
184
+ return ids[0] || DEFAULT_ACCOUNT_ID;
185
+ }
package/src/monitor.ts ADDED
@@ -0,0 +1,318 @@
1
+ import type { OpenClawConfig, RuntimeEnv } from "openclaw/plugin-sdk";
2
+ import { recordInboundSession } from "openclaw/plugin-sdk";
3
+ import type { DingtalkProbeResult } from "./config.js";
4
+ import { DWClient, TOPIC_ROBOT, type DWClientDownStream } from "dingtalk-stream";
5
+ import { getDingtalkRuntime } from "./runtime.js";
6
+ import { sendMessageDingtalk } from "./send.js";
7
+ import { registerDingtalkClient, unregisterDingtalkClient } from "./client-registry.js";
8
+
9
+
10
+ // ============================================================================
11
+ // 类型定义
12
+ // ============================================================================
13
+
14
+ export type MonitorDingtalkParams = {
15
+ clientId: string;
16
+ clientSecret: string;
17
+ robotCode?: string;
18
+ accountId: string;
19
+ config: OpenClawConfig;
20
+ runtime: RuntimeEnv;
21
+ abortSignal: AbortSignal;
22
+ onMessage?: (message: DingtalkInboundMessage) => Promise<void>;
23
+ };
24
+
25
+ export type DingtalkInboundMessage = {
26
+ conversationId: string;
27
+ conversationType: "1" | "2"; // 1=单聊, 2=群聊
28
+ senderId: string;
29
+ senderNick: string;
30
+ senderCorpId?: string;
31
+ senderStaffId?: string;
32
+ content: string;
33
+ msgId: string;
34
+ msgtype: string;
35
+ createAt: number;
36
+ sessionWebhook?: string;
37
+ sessionWebhookExpiredTime?: number;
38
+ atUsers?: Array<{ dingtalkId: string; staffId?: string }>;
39
+ isAdmin?: boolean;
40
+ chatbotUserId?: string;
41
+ };
42
+
43
+ export type RobotMessage = {
44
+ conversationId?: string;
45
+ conversationType?: string;
46
+ senderId?: string;
47
+ senderNick?: string;
48
+ senderCorpId?: string;
49
+ senderStaffId?: string;
50
+ chatbotUserId?: string;
51
+ msgId?: string;
52
+ msgtype?: string;
53
+ createAt?: number;
54
+ text?: { content?: string };
55
+ sessionWebhook?: string;
56
+ sessionWebhookExpiredTime?: number;
57
+ atUsers?: Array<{ dingtalkId?: string; staffId?: string }>;
58
+ isAdmin?: boolean;
59
+ robotCode?: string;
60
+ };
61
+
62
+ // ============================================================================
63
+ // 探测函数
64
+ // ============================================================================
65
+
66
+ /**
67
+ * 探测钉钉机器人账户状态
68
+ * 通过获取AccessToken验证凭证是否有效
69
+ */
70
+ export async function probeDingtalk(
71
+ clientId: string,
72
+ clientSecret: string,
73
+ timeoutMs: number,
74
+ ): Promise<DingtalkProbeResult> {
75
+ if (!clientId || !clientSecret) {
76
+ return { ok: false, error: "Missing clientId or clientSecret" };
77
+ }
78
+
79
+ try {
80
+ const controller = new AbortController();
81
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
82
+
83
+ const response = await fetch(`https://api.dingtalk.com/v1.0/oauth2/accessToken`, {
84
+ method: "POST",
85
+ headers: { "Content-Type": "application/json" },
86
+ body: JSON.stringify({
87
+ appKey: clientId,
88
+ appSecret: clientSecret,
89
+ }),
90
+ signal: controller.signal,
91
+ });
92
+
93
+ clearTimeout(timeout);
94
+
95
+ if (!response.ok) {
96
+ const error = await response.text();
97
+ return { ok: false, error: `HTTP ${response.status}: ${error}` };
98
+ }
99
+
100
+ const data = (await response.json()) as {
101
+ accessToken?: string;
102
+ expireIn?: number;
103
+ };
104
+
105
+ return {
106
+ ok: Boolean(data.accessToken),
107
+ robot: { robotCode: clientId },
108
+ };
109
+ } catch (err) {
110
+ if (err instanceof Error && err.name === "AbortError") {
111
+ return { ok: false, error: "Timeout" };
112
+ }
113
+ return { ok: false, error: String(err) };
114
+ }
115
+ }
116
+
117
+ // ============================================================================
118
+ // Stream监听器 (使用官方 dingtalk-stream SDK)
119
+ // ============================================================================
120
+
121
+ /**
122
+ * 启动钉钉Stream监听器
123
+ */
124
+ export async function monitorDingtalkProvider(params: MonitorDingtalkParams): Promise<void> {
125
+ const { clientId, clientSecret, robotCode, accountId, config, runtime, abortSignal, onMessage } =
126
+ params;
127
+
128
+ const logger = getDingtalkRuntime().logging.getChildLogger({ subsystem: "dingtalk/monitor", accountId });
129
+
130
+ logger.info(`Starting DingTalk Stream provider (RobotCode: ${robotCode || clientId})`);
131
+
132
+ // 创建钉钉Stream客户端
133
+ const client = new DWClient({
134
+ clientId,
135
+ clientSecret,
136
+ debug: false,
137
+ });
138
+
139
+ // 注册到全局注册表以便发送端复用
140
+ registerDingtalkClient(accountId, client);
141
+
142
+ // 注册机器人消息回调
143
+ client.registerCallbackListener(TOPIC_ROBOT, async (res: DWClientDownStream) => {
144
+ try {
145
+ const message = JSON.parse(res.data) as RobotMessage;
146
+ const { text, senderStaffId, sessionWebhook, conversationId, senderNick, msgId } = message;
147
+
148
+ const messageContent = text?.content?.trim() || "";
149
+ if (!messageContent) return;
150
+
151
+ // 构建入站消息
152
+ const inboundMessage: DingtalkInboundMessage = {
153
+ conversationId: conversationId || "",
154
+ conversationType: message.conversationType === "2" ? "2" : "1",
155
+ senderId: message.senderId || senderStaffId || "",
156
+ senderNick: senderNick || "",
157
+ senderCorpId: message.senderCorpId,
158
+ senderStaffId: senderStaffId,
159
+ content: messageContent,
160
+ msgId: msgId || "",
161
+ msgtype: message.msgtype || "text",
162
+ createAt: message.createAt || Date.now(),
163
+ sessionWebhook,
164
+ sessionWebhookExpiredTime: message.sessionWebhookExpiredTime,
165
+ atUsers: message.atUsers?.map((u) => ({
166
+ dingtalkId: u.dingtalkId || "",
167
+ staffId: u.staffId,
168
+ })),
169
+ isAdmin: message.isAdmin,
170
+ chatbotUserId: message.chatbotUserId,
171
+ };
172
+
173
+ // 给回包,表示消息已收到 (DingTalk 要求 5s 内回复)
174
+ if (res.headers?.messageId) {
175
+ client.socketCallBackResponse(res.headers.messageId, "ok");
176
+ }
177
+
178
+ // 调用消息处理回调
179
+ if (onMessage) {
180
+ onMessage(inboundMessage).catch(err => {
181
+ logger.error(`Message handler error: ${String(err)}`);
182
+ });
183
+ }
184
+ } catch (err) {
185
+ logger.error(`Error processing message: ${String(err)}`);
186
+ }
187
+ });
188
+
189
+ // 连接到钉钉Stream服务
190
+ client.connect();
191
+
192
+ // 等待abort信号
193
+ return new Promise((resolve) => {
194
+ abortSignal.addEventListener("abort", () => {
195
+ logger.info("Stream provider stopping...");
196
+ unregisterDingtalkClient(accountId);
197
+ // 注意:dingtalk-stream SDK 可能没有提供断开连接的方法
198
+ resolve();
199
+ });
200
+ });
201
+ }
202
+
203
+ // ============================================================================
204
+ // 集成OpenClaw消息处理
205
+ // ============================================================================
206
+
207
+ /**
208
+ * 创建与OpenClaw集成的消息处理器
209
+ * 将钉钉消息转发给OpenClaw Gateway处理
210
+ */
211
+ export function createOpenClawMessageHandler(params: {
212
+ accountId: string;
213
+ config: OpenClawConfig;
214
+ runtime: RuntimeEnv;
215
+ }) {
216
+ const { accountId, config: cfg } = params;
217
+ const runtime = getDingtalkRuntime();
218
+ const logger = runtime.logging.getChildLogger({ subsystem: "dingtalk/handler", accountId });
219
+
220
+ return async (message: DingtalkInboundMessage): Promise<void> => {
221
+ const isGroup = message.conversationType === "2";
222
+ const senderId = message.senderStaffId || message.senderId;
223
+
224
+ // 构建会话标识
225
+ // 单聊使用用户ID,群聊使用会话ID
226
+ const to = isGroup ? `channel:${message.conversationId}` : `user:${senderId}`;
227
+ const from = `dingtalk:${senderId}`;
228
+
229
+ // 格式化 Inbound Envelope
230
+ const storePath = runtime.channel.session.resolveStorePath(cfg.session?.store, {
231
+ agentId: undefined, // 使用默认 agent 或由路由决定
232
+ });
233
+
234
+ // 这里的路由逻辑通常由 Gateway 处理,我们需要提供足够的上下文
235
+ const sessionKey = `dingtalk:${accountId}:${isGroup ? `group:${message.conversationId}` : `direct:${senderId}`}`;
236
+
237
+ const previousTimestamp = runtime.channel.session.readSessionUpdatedAt({
238
+ storePath,
239
+ sessionKey,
240
+ });
241
+
242
+ const envelopeOptions = runtime.channel.reply.resolveEnvelopeFormatOptions(cfg);
243
+ const combinedBody = runtime.channel.reply.formatInboundEnvelope({
244
+ channel: "DingTalk",
245
+ from: message.senderNick || senderId,
246
+ timestamp: message.createAt,
247
+ body: message.content,
248
+ chatType: isGroup ? "channel" : "direct",
249
+ senderLabel: message.senderNick || senderId,
250
+ previousTimestamp,
251
+ envelope: envelopeOptions,
252
+ });
253
+
254
+ // 组装 Context
255
+ const ctxPayload = runtime.channel.reply.finalizeInboundContext({
256
+ Body: combinedBody,
257
+ RawBody: message.content,
258
+ From: from,
259
+ To: to,
260
+ SessionKey: sessionKey,
261
+ AccountId: accountId,
262
+ ChatType: isGroup ? "channel" : "direct",
263
+ ConversationLabel: isGroup ? `DingTalk Group ${message.conversationId}` : `DingTalk User ${message.senderNick}`,
264
+ SenderName: message.senderNick,
265
+ SenderId: senderId,
266
+ Provider: "dingtalk",
267
+ Surface: "dingtalk",
268
+ MessageSid: message.msgId,
269
+ Timestamp: message.createAt,
270
+ WasMentioned: !isGroup || message.atUsers?.some(u => u.dingtalkId === message.chatbotUserId || u.staffId === message.chatbotUserId),
271
+ OriginatingChannel: "dingtalk",
272
+ OriginatingTo: to,
273
+ });
274
+
275
+ // 记录会话
276
+ await recordInboundSession({
277
+ storePath,
278
+ sessionKey,
279
+ ctx: ctxPayload,
280
+ onRecordError: (err) => {
281
+ logger.error(`Failed to record session: ${String(err)}`);
282
+ },
283
+ });
284
+
285
+ // 创建回复分发器
286
+ const { dispatcher, replyOptions, markDispatchIdle } = runtime.channel.reply.createReplyDispatcherWithTyping({
287
+ deliver: async (payload) => {
288
+ const text = payload.text || "";
289
+ if (!text && !payload.mediaUrl && !(payload.mediaUrls?.length)) return;
290
+
291
+ // 优先使用 sessionWebhook (适用于流模式即时回复)
292
+ const deliverTarget = message.sessionWebhook || to;
293
+
294
+ // 自动把原发送者加入艾特列表 (如果是群聊回复)
295
+ const atUsers = isGroup ? [senderId] : undefined;
296
+
297
+ await sendMessageDingtalk(deliverTarget, text, {
298
+ cfg,
299
+ accountId,
300
+ atUsers,
301
+ });
302
+ },
303
+ onError: (err) => {
304
+ runtime.logging.getChildLogger({ subsystem: "dingtalk/dispatch" }).error(`Reply failed: ${String(err)}`);
305
+ },
306
+ });
307
+
308
+ // 分发消息
309
+ await runtime.channel.reply.dispatchReplyFromConfig({
310
+ ctx: ctxPayload,
311
+ cfg,
312
+ dispatcher,
313
+ replyOptions,
314
+ });
315
+
316
+ markDispatchIdle();
317
+ };
318
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,14 @@
1
+ import type { PluginRuntime } from "openclaw/plugin-sdk";
2
+
3
+ let runtime: PluginRuntime | null = null;
4
+
5
+ export function setDingtalkRuntime(next: PluginRuntime) {
6
+ runtime = next;
7
+ }
8
+
9
+ export function getDingtalkRuntime(): PluginRuntime {
10
+ if (!runtime) {
11
+ throw new Error("DingTalk runtime not initialized");
12
+ }
13
+ return runtime;
14
+ }