@zhin.js/adapter-dingtalk 4.0.1 → 4.0.3

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 (56) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +58 -335
  3. package/adapters/dingtalk.ts +26 -0
  4. package/agent/tools/add_chat_members.ts +22 -0
  5. package/agent/tools/create_chat.ts +23 -0
  6. package/agent/tools/dept_info.ts +17 -0
  7. package/agent/tools/get_dept_users.ts +18 -0
  8. package/agent/tools/get_user.ts +17 -0
  9. package/agent/tools/list_departments.ts +18 -0
  10. package/agent/tools/send_work_notice.ts +20 -0
  11. package/agent/tools/update_chat.ts +27 -0
  12. package/lib/dingtalk-agent-deps.d.ts +26 -0
  13. package/lib/dingtalk-agent-deps.js +30 -0
  14. package/lib/endpoint.d.ts +45 -36
  15. package/lib/endpoint.js +199 -434
  16. package/lib/index.d.ts +5 -15
  17. package/lib/index.js +5 -219
  18. package/lib/platform-permit.d.ts +1 -2
  19. package/lib/platform-permit.js +4 -2
  20. package/lib/protocol.d.ts +121 -0
  21. package/lib/protocol.js +221 -0
  22. package/lib/webhook.d.ts +13 -0
  23. package/lib/webhook.js +48 -0
  24. package/package.json +51 -19
  25. package/plugin.ts +12 -0
  26. package/schema.json +23 -0
  27. package/src/dingtalk-agent-deps.ts +58 -0
  28. package/src/endpoint.ts +263 -479
  29. package/src/index.ts +46 -235
  30. package/src/platform-permit.ts +1 -2
  31. package/src/protocol.ts +338 -0
  32. package/src/webhook.ts +76 -0
  33. package/lib/adapter.d.ts +0 -19
  34. package/lib/adapter.d.ts.map +0 -1
  35. package/lib/adapter.js +0 -40
  36. package/lib/adapter.js.map +0 -1
  37. package/lib/endpoint.d.ts.map +0 -1
  38. package/lib/endpoint.js.map +0 -1
  39. package/lib/index.d.ts.map +0 -1
  40. package/lib/index.js.map +0 -1
  41. package/lib/platform-permit.d.ts.map +0 -1
  42. package/lib/platform-permit.js.map +0 -1
  43. package/lib/segment-mapper.d.ts +0 -2
  44. package/lib/segment-mapper.d.ts.map +0 -1
  45. package/lib/segment-mapper.js +0 -2
  46. package/lib/segment-mapper.js.map +0 -1
  47. package/lib/types.d.ts +0 -58
  48. package/lib/types.d.ts.map +0 -1
  49. package/lib/types.js +0 -5
  50. package/lib/types.js.map +0 -1
  51. package/plugin.yml +0 -3
  52. package/src/adapter.ts +0 -46
  53. package/src/segment-mapper.ts +0 -1
  54. package/src/types.ts +0 -56
  55. /package/{skills/dingtalk → agent}/PERMITS.md +0 -0
  56. /package/{skills/dingtalk/SKILL.md → agent/skills/dingtalk.md} +0 -0
package/lib/index.d.ts CHANGED
@@ -1,15 +1,5 @@
1
- import { DingTalkAdapter } from "./adapter.js";
2
- declare module "zhin.js" {
3
- namespace Plugin {
4
- interface Contexts {
5
- router: import("@zhin.js/host-router").Router;
6
- }
7
- }
8
- interface Adapters {
9
- dingtalk: DingTalkAdapter;
10
- }
11
- }
12
- export * from "./types.js";
13
- export { DingTalkEndpoint } from "./endpoint.js";
14
- export { DingTalkAdapter } from "./adapter.js";
15
- //# sourceMappingURL=index.d.ts.map
1
+ export { formatInboundContent, formatOutboundBody, generateMessageId, headerValue, normalizeWebhookPath, readTextBody, resolveChatType, resolveDingTalkConfig, resolveSender, resolveTarget, verifySignature, type AccessToken, type DingTalkAdapterConfig, type DingTalkApiResponse, type DingTalkEvent, type DingTalkMessage, type DingTalkSendBody, type DingTalkWireSegment, type ResolvedDingTalkConfig, } from './protocol.js';
2
+ export { DingTalkEndpoint, type DingTalkEndpointOptions, type DingTalkFetch, } from './endpoint.js';
3
+ export { registerDingTalkWebhookRoutes, handleDingTalkWebhookRequest, type DingTalkWebhookHandler, } from './webhook.js';
4
+ export { getDingtalkAgentDeps, registerDingtalkAgentEndpoint, setDingtalkAgentDeps, type DingtalkAgentDeps, type DingtalkAgentEndpoint, } from './dingtalk-agent-deps.js';
5
+ export { checkDingtalkPlatformPermit, dingtalkGroupPermitResolver, normalizeDingtalkSenderForPermit, platformPermit, registerDingtalkPlatformPermitChecker, } from './platform-permit.js';
package/lib/index.js CHANGED
@@ -1,219 +1,5 @@
1
- /**
2
- * 钉钉适配器入口:类型扩展、导出、注册
3
- */
4
- import { usePlugin, createSceneManagementTools } from "zhin.js";
5
- import { DingTalkAdapter } from "./adapter.js";
6
- import { dingtalkGroupPermitResolver, platformPermit, registerDingtalkPlatformPermitChecker, } from "./platform-permit.js";
7
- export * from "./types.js";
8
- export { DingTalkEndpoint } from "./endpoint.js";
9
- export { DingTalkAdapter } from "./adapter.js";
10
- const plugin = usePlugin();
11
- const { provide, useContext } = plugin;
12
- useContext("router", (router) => {
13
- provide({
14
- name: "dingtalk",
15
- description: "DingTalk Endpoint Adapter",
16
- mounted: async (p) => {
17
- const adapter = new DingTalkAdapter(p, router);
18
- await adapter.start();
19
- return adapter;
20
- },
21
- dispose: async (adapter) => {
22
- await adapter.stop();
23
- },
24
- });
25
- });
26
- useContext('tool', 'dingtalk', (toolService, dingtalk) => {
27
- const disposers = [];
28
- disposers.push(registerDingtalkPlatformPermitChecker());
29
- const sceneTools = createSceneManagementTools(dingtalk, 'dingtalk', { permitResolver: dingtalkGroupPermitResolver, registerChecker: false });
30
- disposers.push(...sceneTools.map(t => toolService.addTool(t, plugin.name)));
31
- disposers.push(toolService.addTool({
32
- name: 'dingtalk_get_user',
33
- description: '获取钉钉用户信息',
34
- parameters: {
35
- type: 'object',
36
- properties: {
37
- endpoint_id: { type: 'string', description: 'Endpoint 名称', contextKey: 'endpointId' },
38
- user_id: { type: 'string', description: '用户 ID' },
39
- },
40
- required: ['endpoint_id', 'user_id'],
41
- },
42
- platforms: ['dingtalk'],
43
- tags: ['dingtalk'],
44
- execute: async (args) => {
45
- const endpoint = dingtalk.endpoints.get(args.endpoint_id);
46
- if (!endpoint)
47
- throw new Error(`Endpoint ${args.endpoint_id} 不存在`);
48
- return await endpoint.getUserInfo(args.user_id);
49
- },
50
- }, plugin.name));
51
- disposers.push(toolService.addTool({
52
- name: 'dingtalk_get_dept_users',
53
- description: '获取钉钉部门用户列表',
54
- parameters: {
55
- type: 'object',
56
- properties: {
57
- endpoint_id: { type: 'string', description: 'Endpoint 名称', contextKey: 'endpointId' },
58
- dept_id: { type: 'string', description: '部门 ID' },
59
- },
60
- required: ['endpoint_id', 'dept_id'],
61
- },
62
- platforms: ['dingtalk'],
63
- tags: ['dingtalk'],
64
- execute: async (args) => {
65
- const endpoint = dingtalk.endpoints.get(args.endpoint_id);
66
- if (!endpoint)
67
- throw new Error(`Endpoint ${args.endpoint_id} 不存在`);
68
- const users = await endpoint.getDepartmentUsers(args.dept_id);
69
- return { users, count: users.length };
70
- },
71
- }, plugin.name));
72
- disposers.push(toolService.addTool({
73
- name: 'dingtalk_list_departments',
74
- description: '获取钉钉部门列表',
75
- parameters: {
76
- type: 'object',
77
- properties: {
78
- endpoint_id: { type: 'string', description: 'Endpoint 名称', contextKey: 'endpointId' },
79
- dept_id: { type: 'string', description: '父部门 ID,默认 1(根部门)' },
80
- },
81
- required: ['endpoint_id'],
82
- },
83
- platforms: ['dingtalk'],
84
- tags: ['dingtalk'],
85
- execute: async (args) => {
86
- const endpoint = dingtalk.endpoints.get(args.endpoint_id);
87
- if (!endpoint)
88
- throw new Error(`Endpoint ${args.endpoint_id} 不存在`);
89
- const departments = await endpoint.getDepartmentList(args.dept_id || '1');
90
- return { departments, count: departments.length };
91
- },
92
- }, plugin.name));
93
- disposers.push(toolService.addTool({
94
- name: 'dingtalk_send_work_notice',
95
- description: '向指定用户发送钉钉工作通知',
96
- parameters: {
97
- type: 'object',
98
- properties: {
99
- endpoint_id: { type: 'string', description: 'Endpoint 名称', contextKey: 'endpointId' },
100
- user_ids: { type: 'string', description: '用户 ID 列表,逗号分隔' },
101
- content: { type: 'string', description: '通知内容' },
102
- },
103
- required: ['endpoint_id', 'user_ids', 'content'],
104
- },
105
- platforms: ['dingtalk'],
106
- tags: ['dingtalk'],
107
- execute: async (args) => {
108
- const endpoint = dingtalk.endpoints.get(args.endpoint_id);
109
- if (!endpoint)
110
- throw new Error(`Endpoint ${args.endpoint_id} 不存在`);
111
- const msgContent = { msgtype: 'text', text: { content: args.content } };
112
- const success = await endpoint.sendWorkNotice(args.user_ids.split(','), msgContent);
113
- return { success, message: success ? '工作通知已发送' : '发送失败' };
114
- },
115
- }, plugin.name));
116
- disposers.push(toolService.addTool({
117
- name: 'dingtalk_create_chat',
118
- description: '创建钉钉群聊',
119
- parameters: {
120
- type: 'object',
121
- properties: {
122
- endpoint_id: { type: 'string', description: 'Endpoint 名称', contextKey: 'endpointId' },
123
- name: { type: 'string', description: '群名' },
124
- owner: { type: 'string', description: '群主用户 ID' },
125
- members: { type: 'string', description: '成员用户 ID 列表,逗号分隔' },
126
- },
127
- required: ['endpoint_id', 'name', 'owner', 'members'],
128
- },
129
- platforms: ['dingtalk'],
130
- tags: ['dingtalk'],
131
- permissions: [platformPermit('chat_owner')],
132
- execute: async (args) => {
133
- const endpoint = dingtalk.endpoints.get(args.endpoint_id);
134
- if (!endpoint)
135
- throw new Error(`Endpoint ${args.endpoint_id} 不存在`);
136
- const chatId = await endpoint.createChat(args.name, args.owner, args.members.split(','));
137
- return { success: !!chatId, chat_id: chatId, message: chatId ? `群聊创建成功: ${chatId}` : '创建失败' };
138
- },
139
- }, plugin.name));
140
- disposers.push(toolService.addTool({
141
- name: 'dingtalk_add_chat_members',
142
- description: '向钉钉群聊添加成员',
143
- parameters: {
144
- type: 'object',
145
- properties: {
146
- endpoint_id: { type: 'string', description: 'Endpoint 名称', contextKey: 'endpointId' },
147
- chat_id: { type: 'string', description: '群聊 ID' },
148
- user_ids: { type: 'string', description: '要添加的用户 ID 列表,逗号分隔' },
149
- },
150
- required: ['endpoint_id', 'chat_id', 'user_ids'],
151
- },
152
- platforms: ['dingtalk'],
153
- tags: ['dingtalk'],
154
- permissions: [platformPermit('chat_admin')],
155
- execute: async (args) => {
156
- const endpoint = dingtalk.endpoints.get(args.endpoint_id);
157
- if (!endpoint)
158
- throw new Error(`Endpoint ${args.endpoint_id} 不存在`);
159
- const success = await endpoint.updateChat(args.chat_id, { add_useridlist: args.user_ids.split(',') });
160
- return { success, message: success ? '成员添加成功' : '添加失败' };
161
- },
162
- }, plugin.name));
163
- disposers.push(toolService.addTool({
164
- name: 'dingtalk_dept_info',
165
- description: '获取钉钉部门详细信息',
166
- parameters: {
167
- type: 'object',
168
- properties: {
169
- endpoint_id: { type: 'string', description: 'Endpoint 名称', contextKey: 'endpointId' },
170
- dept_id: { type: 'string', description: '部门 ID' },
171
- },
172
- required: ['endpoint_id', 'dept_id'],
173
- },
174
- platforms: ['dingtalk'],
175
- tags: ['dingtalk'],
176
- execute: async (args) => {
177
- const endpoint = dingtalk.endpoints.get(args.endpoint_id);
178
- if (!endpoint)
179
- throw new Error(`Endpoint ${args.endpoint_id} 不存在`);
180
- return await endpoint.getDepartmentInfo(Number(args.dept_id));
181
- },
182
- }, plugin.name));
183
- disposers.push(toolService.addTool({
184
- name: 'dingtalk_update_chat',
185
- description: '更新钉钉群聊设置(改名、换群主、增减成员)',
186
- parameters: {
187
- type: 'object',
188
- properties: {
189
- endpoint_id: { type: 'string', description: 'Endpoint 名称', contextKey: 'endpointId' },
190
- chat_id: { type: 'string', description: '群聊 ID' },
191
- name: { type: 'string', description: '新群名(可选)' },
192
- owner: { type: 'string', description: '新群主 userId(可选)' },
193
- add_members: { type: 'string', description: '要添加的成员 userId,逗号分隔(可选)' },
194
- remove_members: { type: 'string', description: '要移除的成员 userId,逗号分隔(可选)' },
195
- },
196
- required: ['endpoint_id', 'chat_id'],
197
- },
198
- platforms: ['dingtalk'],
199
- tags: ['dingtalk'],
200
- execute: async (args) => {
201
- const endpoint = dingtalk.endpoints.get(args.endpoint_id);
202
- if (!endpoint)
203
- throw new Error(`Endpoint ${args.endpoint_id} 不存在`);
204
- const options = {};
205
- if (args.name)
206
- options.name = args.name;
207
- if (args.owner)
208
- options.owner = args.owner;
209
- if (args.add_members)
210
- options.add_useridlist = args.add_members.split(',').map((s) => s.trim());
211
- if (args.remove_members)
212
- options.del_useridlist = args.remove_members.split(',').map((s) => s.trim());
213
- await endpoint.updateChat(args.chat_id, options);
214
- return { success: true, message: '群聊设置已更新' };
215
- },
216
- }, plugin.name));
217
- return () => disposers.forEach(d => d());
218
- });
219
- //# sourceMappingURL=index.js.map
1
+ export { formatInboundContent, formatOutboundBody, generateMessageId, headerValue, normalizeWebhookPath, readTextBody, resolveChatType, resolveDingTalkConfig, resolveSender, resolveTarget, verifySignature, } from './protocol.js';
2
+ export { DingTalkEndpoint, } from './endpoint.js';
3
+ export { registerDingTalkWebhookRoutes, handleDingTalkWebhookRequest, } from './webhook.js';
4
+ export { getDingtalkAgentDeps, registerDingtalkAgentEndpoint, setDingtalkAgentDeps, } from './dingtalk-agent-deps.js';
5
+ export { checkDingtalkPlatformPermit, dingtalkGroupPermitResolver, normalizeDingtalkSenderForPermit, platformPermit, registerDingtalkPlatformPermitChecker, } from './platform-permit.js';
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * 钉钉 DingTalk platform permit
3
3
  */
4
- import type { Message } from 'zhin.js';
4
+ import { type Message } from '@zhin.js/core';
5
5
  export declare function platformPermit(perm: string): string;
6
6
  export declare function dingtalkGroupPermitResolver(logicalPerm: string): string;
7
7
  export declare function normalizeDingtalkSenderForPermit(input: {
@@ -13,4 +13,3 @@ export declare function normalizeDingtalkSenderForPermit(input: {
13
13
  };
14
14
  export declare function checkDingtalkPlatformPermit(perm: string, message: Message<any>): boolean;
15
15
  export declare function registerDingtalkPlatformPermitChecker(): () => void;
16
- //# sourceMappingURL=platform-permit.d.ts.map
@@ -1,4 +1,7 @@
1
- import { registerPlatformPermitChecker } from 'zhin.js';
1
+ /**
2
+ * 钉钉 DingTalk platform permit
3
+ */
4
+ import { registerPlatformPermitChecker } from '@zhin.js/core';
2
5
  const ADAPTER = 'dingtalk';
3
6
  export function platformPermit(perm) {
4
7
  return `platform(${ADAPTER},${perm})`;
@@ -36,4 +39,3 @@ export function checkDingtalkPlatformPermit(perm, message) {
36
39
  export function registerDingtalkPlatformPermitChecker() {
37
40
  return registerPlatformPermitChecker(ADAPTER, checkDingtalkPlatformPermit);
38
41
  }
39
- //# sourceMappingURL=platform-permit.js.map
@@ -0,0 +1,121 @@
1
+ /**
2
+ * DingTalk protocol helpers — no legacy Adapter/Endpoint / segment-mapper.
3
+ * Canonicalization is owned by gateway/core before endpoint.send.
4
+ */
5
+ import type { IncomingMessage } from 'node:http';
6
+ /** Plugin Runtime owner config (`plugins.<instanceKey>` / schema.json). */
7
+ export interface DingTalkAdapterConfig {
8
+ readonly name?: string;
9
+ readonly appKey?: string;
10
+ readonly appSecret?: string;
11
+ readonly webhookPath?: string;
12
+ readonly robotCode?: string;
13
+ readonly apiBaseUrl?: string;
14
+ /** Transitional: legacy root `endpoints[]` with `context: dingtalk`. */
15
+ readonly endpoints?: ReadonlyArray<Partial<ResolvedDingTalkConfig> & {
16
+ readonly context?: string;
17
+ }>;
18
+ }
19
+ export interface ResolvedDingTalkConfig {
20
+ readonly context: 'dingtalk';
21
+ readonly name: string;
22
+ readonly appKey: string;
23
+ readonly appSecret: string;
24
+ readonly webhookPath: string;
25
+ readonly robotCode?: string;
26
+ readonly apiBaseUrl: string;
27
+ }
28
+ export interface DingTalkMessage {
29
+ readonly msgtype?: string;
30
+ readonly text?: {
31
+ readonly content?: string;
32
+ };
33
+ readonly msgId?: string;
34
+ readonly createAt?: number;
35
+ readonly conversationType?: string;
36
+ readonly conversationId?: string;
37
+ readonly senderId?: string;
38
+ readonly senderNick?: string;
39
+ readonly senderCorpId?: string;
40
+ readonly sessionWebhook?: string;
41
+ readonly chatbotCorpId?: string;
42
+ readonly chatbotUserId?: string;
43
+ readonly isAdmin?: boolean;
44
+ readonly senderStaffId?: string;
45
+ readonly atUsers?: ReadonlyArray<{
46
+ readonly dingtalkId?: string;
47
+ readonly staffId?: string;
48
+ }>;
49
+ readonly content?: Record<string, unknown>;
50
+ }
51
+ export interface DingTalkEvent extends DingTalkMessage {
52
+ readonly [key: string]: unknown;
53
+ }
54
+ /**
55
+ * 钉钉回调消息 @ 机器人判定:机器人被 @ 时回调带 `isInAtList: true`;
56
+ * 部分回调形态的 `atUserIds` / `atUsers[].dingtalkId` 会包含机器人 robotCode(来自配置)。
57
+ * 两者都不满足则不标注。
58
+ */
59
+ export declare function isDingtalkBotMentioned(event: DingTalkMessage, robotCode?: string): boolean;
60
+ export interface AccessToken {
61
+ token: string;
62
+ expires_in: number;
63
+ timestamp: number;
64
+ }
65
+ export interface DingTalkApiResponse {
66
+ readonly errcode: number;
67
+ readonly errmsg?: string;
68
+ readonly access_token?: string;
69
+ readonly expires_in?: number;
70
+ readonly msgId?: string;
71
+ readonly chatid?: string;
72
+ readonly result?: unknown;
73
+ readonly chat_info?: unknown;
74
+ readonly [key: string]: unknown;
75
+ }
76
+ export interface DingTalkWireSegment {
77
+ readonly type: string;
78
+ readonly data?: Record<string, unknown>;
79
+ }
80
+ export interface DingTalkSendBody {
81
+ readonly msgtype: string;
82
+ readonly text?: {
83
+ readonly content: string;
84
+ };
85
+ readonly picture?: {
86
+ readonly picURL: string;
87
+ };
88
+ readonly markdown?: {
89
+ readonly title: string;
90
+ readonly text: string;
91
+ };
92
+ readonly link?: {
93
+ readonly title: string;
94
+ readonly text: string;
95
+ readonly messageUrl?: string;
96
+ readonly picUrl?: string;
97
+ };
98
+ readonly at?: {
99
+ readonly atUserIds: string[];
100
+ readonly isAtAll: boolean;
101
+ };
102
+ readonly robotCode?: string;
103
+ }
104
+ export declare function resolveDingTalkConfig(config?: DingTalkAdapterConfig): ResolvedDingTalkConfig;
105
+ export declare function normalizeWebhookPath(path: string): string;
106
+ export declare function resolveChatType(conversationType?: string): 'group' | 'private';
107
+ export declare function resolveTarget(msg: DingTalkMessage): string;
108
+ export declare function resolveSender(msg: DingTalkMessage): string;
109
+ export declare function generateMessageId(msg: DingTalkMessage): string;
110
+ /** Build inbound text for MessageGateway.receive. */
111
+ export declare function formatInboundContent(msg: DingTalkMessage): string;
112
+ export declare function verifySignature(appSecret: string, timestamp: string, sign: string): boolean;
113
+ /**
114
+ * Wire-encode an already-rendered outbound payload into DingTalk robot body.
115
+ * Segment canonicalization is intentionally not done here.
116
+ */
117
+ export declare function formatOutboundBody(payload: unknown): DingTalkSendBody;
118
+ export declare function headerValue(headers: IncomingMessage['headers'], name: string): string;
119
+ export declare function readTextBody(request: IncomingMessage, options?: {
120
+ readonly limit?: number;
121
+ }): Promise<string>;
@@ -0,0 +1,221 @@
1
+ /**
2
+ * DingTalk protocol helpers — no legacy Adapter/Endpoint / segment-mapper.
3
+ * Canonicalization is owned by gateway/core before endpoint.send.
4
+ */
5
+ import { createHmac, timingSafeEqual } from 'node:crypto';
6
+ /**
7
+ * 钉钉回调消息 @ 机器人判定:机器人被 @ 时回调带 `isInAtList: true`;
8
+ * 部分回调形态的 `atUserIds` / `atUsers[].dingtalkId` 会包含机器人 robotCode(来自配置)。
9
+ * 两者都不满足则不标注。
10
+ */
11
+ export function isDingtalkBotMentioned(event, robotCode) {
12
+ const extra = event;
13
+ if (extra.isInAtList === true)
14
+ return true;
15
+ if (!robotCode)
16
+ return false;
17
+ if (Array.isArray(extra.atUserIds) && extra.atUserIds.some((id) => String(id) === robotCode)) {
18
+ return true;
19
+ }
20
+ return (event.atUsers ?? []).some((user) => user.dingtalkId === robotCode);
21
+ }
22
+ export function resolveDingTalkConfig(config = {}) {
23
+ const entry = config.endpoints?.find((item) => item.context === 'dingtalk');
24
+ const appKey = config.appKey ?? entry?.appKey ?? process.env.DINGTALK_APP_KEY;
25
+ const appSecret = config.appSecret ?? entry?.appSecret ?? process.env.DINGTALK_APP_SECRET;
26
+ if (!appKey || !appSecret) {
27
+ throw new TypeError('DingTalk adapter requires appKey + appSecret (plugins.<key> or endpoints with context: dingtalk)');
28
+ }
29
+ const name = (typeof config.name === 'string' && config.name)
30
+ || (typeof entry?.name === 'string' && entry.name)
31
+ || process.env.DINGTALK_BOT_NAME
32
+ || 'dingtalk-bot';
33
+ const webhookPath = normalizeWebhookPath(config.webhookPath ?? entry?.webhookPath ?? '/dingtalk/webhook');
34
+ const apiBaseUrl = (config.apiBaseUrl ?? entry?.apiBaseUrl ?? 'https://oapi.dingtalk.com').replace(/\/$/, '');
35
+ const robotCode = config.robotCode ?? entry?.robotCode;
36
+ return {
37
+ context: 'dingtalk',
38
+ name,
39
+ appKey,
40
+ appSecret,
41
+ webhookPath,
42
+ ...(robotCode ? { robotCode } : {}),
43
+ apiBaseUrl,
44
+ };
45
+ }
46
+ export function normalizeWebhookPath(path) {
47
+ const trimmed = path.trim() || '/dingtalk/webhook';
48
+ return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
49
+ }
50
+ export function resolveChatType(conversationType) {
51
+ return conversationType === '2' ? 'group' : 'private';
52
+ }
53
+ export function resolveTarget(msg) {
54
+ return msg.conversationId || msg.senderId || 'unknown';
55
+ }
56
+ export function resolveSender(msg) {
57
+ return msg.senderId || msg.senderStaffId || 'unknown';
58
+ }
59
+ export function generateMessageId(msg) {
60
+ return msg.msgId || `${msg.createAt ?? Date.now()}`;
61
+ }
62
+ /** Build inbound text for MessageGateway.receive. */
63
+ export function formatInboundContent(msg) {
64
+ if (!msg.msgtype)
65
+ return '';
66
+ switch (msg.msgtype) {
67
+ case 'text':
68
+ return msg.text?.content || '';
69
+ case 'picture':
70
+ return '[image]';
71
+ case 'file': {
72
+ const name = typeof msg.content?.fileName === 'string' ? msg.content.fileName : '';
73
+ return name ? `[file: ${name}]` : '[file]';
74
+ }
75
+ case 'audio':
76
+ return '[audio]';
77
+ case 'video':
78
+ return '[video]';
79
+ case 'richText': {
80
+ const rich = msg.content?.richText;
81
+ if (Array.isArray(rich)) {
82
+ return rich
83
+ .map((item) => (item && typeof item === 'object' && 'text' in item
84
+ ? String(item.text || '')
85
+ : ''))
86
+ .join('');
87
+ }
88
+ return '[richText]';
89
+ }
90
+ case 'markdown':
91
+ return typeof msg.content?.text === 'string' ? msg.content.text : '[markdown]';
92
+ default:
93
+ return `[${msg.msgtype}]`;
94
+ }
95
+ }
96
+ export function verifySignature(appSecret, timestamp, sign) {
97
+ try {
98
+ const stringToSign = `${timestamp}\n${appSecret}`;
99
+ const hmac = createHmac('sha256', appSecret);
100
+ hmac.update(stringToSign);
101
+ const calculated = hmac.digest('base64');
102
+ const a = Buffer.from(calculated);
103
+ const b = Buffer.from(sign);
104
+ if (a.length !== b.length)
105
+ return false;
106
+ return timingSafeEqual(a, b);
107
+ }
108
+ catch {
109
+ return false;
110
+ }
111
+ }
112
+ /**
113
+ * Wire-encode an already-rendered outbound payload into DingTalk robot body.
114
+ * Segment canonicalization is intentionally not done here.
115
+ */
116
+ export function formatOutboundBody(payload) {
117
+ if (typeof payload === 'string') {
118
+ return { msgtype: 'text', text: { content: payload } };
119
+ }
120
+ const items = Array.isArray(payload)
121
+ ? payload
122
+ : payload && typeof payload === 'object' && 'type' in payload
123
+ ? [payload]
124
+ : [];
125
+ if (items.length === 0) {
126
+ const text = payload == null
127
+ ? ''
128
+ : typeof payload === 'object'
129
+ ? JSON.stringify(payload)
130
+ : String(payload);
131
+ return { msgtype: 'text', text: { content: text } };
132
+ }
133
+ const textParts = [];
134
+ const atUserIds = [];
135
+ let media = null;
136
+ for (const item of items) {
137
+ if (typeof item === 'string') {
138
+ textParts.push(item);
139
+ continue;
140
+ }
141
+ const data = item.data ?? {};
142
+ switch (item.type) {
143
+ case 'text':
144
+ textParts.push(String(data.content ?? data.text ?? ''));
145
+ break;
146
+ case 'at': {
147
+ const userId = data.id ?? data.userId;
148
+ if (userId) {
149
+ atUserIds.push(String(userId));
150
+ textParts.push(`@${String(data.name || userId)} `);
151
+ }
152
+ break;
153
+ }
154
+ case 'image':
155
+ if (!media) {
156
+ media = {
157
+ msgtype: 'picture',
158
+ picture: { picURL: String(data.url ?? data.file ?? '') },
159
+ };
160
+ }
161
+ break;
162
+ case 'markdown':
163
+ if (!media) {
164
+ media = {
165
+ msgtype: 'markdown',
166
+ markdown: {
167
+ title: String(data.title || '消息'),
168
+ text: String(data.content ?? data.text ?? ''),
169
+ },
170
+ };
171
+ }
172
+ break;
173
+ case 'link':
174
+ if (!media) {
175
+ media = {
176
+ msgtype: 'link',
177
+ link: {
178
+ title: String(data.title || '链接'),
179
+ text: String(data.text ?? data.content ?? ''),
180
+ messageUrl: typeof data.url === 'string' ? data.url : undefined,
181
+ picUrl: typeof data.picUrl === 'string' ? data.picUrl : undefined,
182
+ },
183
+ };
184
+ }
185
+ break;
186
+ default:
187
+ textParts.push(`[${item.type}]`);
188
+ }
189
+ }
190
+ if (media)
191
+ return media;
192
+ const result = {
193
+ msgtype: 'text',
194
+ text: { content: textParts.join('') },
195
+ };
196
+ if (atUserIds.length > 0) {
197
+ return { ...result, at: { atUserIds, isAtAll: false } };
198
+ }
199
+ return result;
200
+ }
201
+ export function headerValue(headers, name) {
202
+ const value = headers[name] ?? headers[name.toLowerCase()];
203
+ if (Array.isArray(value))
204
+ return value[0] ?? '';
205
+ return value ?? '';
206
+ }
207
+ export async function readTextBody(request, options = {}) {
208
+ const limit = options.limit ?? 1_048_576;
209
+ const chunks = [];
210
+ let size = 0;
211
+ for await (const chunk of request) {
212
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
213
+ size += buffer.length;
214
+ if (size > limit) {
215
+ request.destroy();
216
+ throw new Error(`Request body exceeds ${limit} bytes`);
217
+ }
218
+ chunks.push(buffer);
219
+ }
220
+ return Buffer.concat(chunks).toString('utf8');
221
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * DingTalk webhook HTTP: signature → parse → admit.
3
+ */
4
+ import type { IncomingMessage, ServerResponse } from 'node:http';
5
+ import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
6
+ import { type DingTalkEvent, type ResolvedDingTalkConfig } from './protocol.js';
7
+ export interface DingTalkWebhookHandler {
8
+ readonly config: ResolvedDingTalkConfig;
9
+ readonly isOpen: boolean;
10
+ admit(event: DingTalkEvent): void;
11
+ }
12
+ export declare function registerDingTalkWebhookRoutes(http: HttpHost, handler: DingTalkWebhookHandler): HttpRouteRegistration[];
13
+ export declare function handleDingTalkWebhookRequest(request: IncomingMessage, response: ServerResponse, handler: DingTalkWebhookHandler): Promise<void>;
package/lib/webhook.js ADDED
@@ -0,0 +1,48 @@
1
+ import { formatCompact, getLogger } from '@zhin.js/logger';
2
+ import { headerValue, readTextBody, verifySignature, } from './protocol.js';
3
+ const logger = getLogger('dingtalk');
4
+ export function registerDingTalkWebhookRoutes(http, handler) {
5
+ const path = handler.config.webhookPath;
6
+ return [
7
+ http.route('POST', path, async (request, response) => {
8
+ await handleDingTalkWebhookRequest(request, response, handler);
9
+ }, { summary: 'DingTalk robot webhook', tags: ['dingtalk'] }),
10
+ ];
11
+ }
12
+ export async function handleDingTalkWebhookRequest(request, response, handler) {
13
+ try {
14
+ // DingTalk outgoing callbacks put timestamp/sign on the URL query;
15
+ // headers are accepted as a fallback for legacy senders.
16
+ const query = new URL(request.url ?? '/', 'http://localhost').searchParams;
17
+ const timestamp = query.get('timestamp') || headerValue(request.headers, 'timestamp');
18
+ const sign = query.get('sign') || headerValue(request.headers, 'sign');
19
+ if (timestamp && sign) {
20
+ if (!verifySignature(handler.config.appSecret, timestamp, sign)) {
21
+ logger.warn(formatCompact({ op: 'webhook', ok: false, error: 'invalid signature' }));
22
+ response.writeHead(403, { 'Content-Type': 'application/json' });
23
+ response.end(JSON.stringify({ code: -1, msg: 'Forbidden' }));
24
+ return;
25
+ }
26
+ }
27
+ const rawBody = await readTextBody(request);
28
+ let event;
29
+ try {
30
+ event = JSON.parse(rawBody);
31
+ }
32
+ catch {
33
+ response.writeHead(200, { 'Content-Type': 'application/json' });
34
+ response.end(JSON.stringify({ code: 0, msg: 'success' }));
35
+ return;
36
+ }
37
+ if (event.msgtype && handler.isOpen) {
38
+ handler.admit(event);
39
+ }
40
+ response.writeHead(200, { 'Content-Type': 'application/json' });
41
+ response.end(JSON.stringify({ code: 0, msg: 'success' }));
42
+ }
43
+ catch (error) {
44
+ logger.error('Webhook error:', error);
45
+ response.writeHead(500, { 'Content-Type': 'application/json' });
46
+ response.end(JSON.stringify({ code: -1, msg: 'Internal Server Error' }));
47
+ }
48
+ }