@zhin.js/adapter-wecom 0.0.1 → 1.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.
Files changed (66) hide show
  1. package/CHANGELOG.md +624 -0
  2. package/README.md +80 -216
  3. package/adapters/wecom.js +34 -0
  4. package/adapters/wecom.ts +39 -0
  5. package/{skills/wecom → agent}/PERMITS.md +1 -1
  6. package/agent/tools/get_dept_users.ts +16 -0
  7. package/agent/tools/get_user.ts +15 -0
  8. package/agent/tools/list_departments.ts +16 -0
  9. package/agent/tools/send_text.ts +17 -0
  10. package/commands/endpoint/add/[id].js +3 -0
  11. package/commands/endpoint/add/[id].ts +3 -0
  12. package/commands/endpoint/list.js +3 -0
  13. package/commands/endpoint/list.ts +3 -0
  14. package/commands/endpoint/remove/[id].js +3 -0
  15. package/commands/endpoint/remove/[id].ts +3 -0
  16. package/lib/client.d.ts +12 -0
  17. package/lib/client.js +2 -0
  18. package/lib/endpoint.d.ts +55 -37
  19. package/lib/endpoint.js +247 -516
  20. package/lib/index.d.ts +6 -15
  21. package/lib/index.js +6 -115
  22. package/lib/media-upload.d.ts +23 -0
  23. package/lib/media-upload.js +58 -0
  24. package/lib/platform-permit.d.ts +2 -4
  25. package/lib/platform-permit.js +5 -11
  26. package/lib/protocol.d.ts +102 -0
  27. package/lib/protocol.js +268 -0
  28. package/lib/side-event-dispatch.d.ts +4 -0
  29. package/lib/side-event-dispatch.js +63 -0
  30. package/lib/webhook.d.ts +14 -0
  31. package/lib/webhook.js +86 -0
  32. package/lib/wecom-endpoint-commands.d.ts +1 -0
  33. package/lib/wecom-endpoint-commands.js +19 -0
  34. package/lib/wecom-runtime-state.d.ts +1 -0
  35. package/lib/wecom-runtime-state.js +6 -0
  36. package/package.json +64 -16
  37. package/plugin.js +19 -0
  38. package/schema.json +96 -0
  39. package/src/client.ts +16 -0
  40. package/src/endpoint.ts +311 -559
  41. package/src/index.ts +52 -131
  42. package/src/media-upload.ts +79 -0
  43. package/src/platform-permit.ts +6 -11
  44. package/src/protocol.ts +379 -0
  45. package/src/side-event-dispatch.ts +70 -0
  46. package/src/webhook.ts +130 -0
  47. package/src/wecom-endpoint-commands.ts +20 -0
  48. package/src/wecom-runtime-state.ts +7 -0
  49. package/lib/adapter.d.ts +0 -15
  50. package/lib/adapter.d.ts.map +0 -1
  51. package/lib/adapter.js +0 -20
  52. package/lib/adapter.js.map +0 -1
  53. package/lib/endpoint.d.ts.map +0 -1
  54. package/lib/endpoint.js.map +0 -1
  55. package/lib/index.d.ts.map +0 -1
  56. package/lib/index.js.map +0 -1
  57. package/lib/platform-permit.d.ts.map +0 -1
  58. package/lib/platform-permit.js.map +0 -1
  59. package/lib/types.d.ts +0 -48
  60. package/lib/types.d.ts.map +0 -1
  61. package/lib/types.js +0 -5
  62. package/lib/types.js.map +0 -1
  63. package/plugin.yml +0 -3
  64. package/src/adapter.ts +0 -26
  65. package/src/types.ts +0 -51
  66. /package/{skills/wecom/SKILL.md → agent/skills/wecom.md} +0 -0
package/src/index.ts CHANGED
@@ -1,134 +1,55 @@
1
- /**
2
- * 企业微信适配器入口:类型扩展、导出、注册
3
- */
4
- import { usePlugin, type Plugin, type ToolFeature } from 'zhin.js';
5
- import type { Router } from '@zhin.js/host-router/router';
6
- import { WecomAdapter } from './adapter.js';
7
- import {
8
- registerWecomPlatformPermitChecker,
1
+ export {
2
+ buildSendRequestBody,
3
+ decryptMessage,
4
+ extractEncryptFromXml,
5
+ formatInboundContent,
6
+ formatOutboundBody,
7
+ getAesKey,
8
+ normalizeEchostrParam,
9
+ normalizeWebhookPath,
10
+ parseXmlMessage,
11
+ queryParam,
12
+ readTextBody,
13
+ resolveChatType,
14
+ resolveWecomConfig,
15
+ verifySignature,
16
+ type AccessToken,
17
+ type ResolvedWecomConfig,
18
+ type WecomAdapterConfig,
19
+ type WecomApiResponse,
20
+ type WecomMessage,
21
+ type WecomSendBody,
22
+ type WecomWireSegment,
23
+ } from './protocol.js';
24
+
25
+ export { wecomClient, type WecomClientEventMap } from './client.js';
26
+
27
+ export {
28
+ checkWecomPlatformPermit,
29
+ normalizeWecomSenderForPermit,
9
30
  platformPermit,
31
+ wecomGroupPermitResolver,
10
32
  } from './platform-permit.js';
11
33
 
12
- declare module 'zhin.js' {
13
- namespace Plugin {
14
- interface Contexts {
15
- router: import('@zhin.js/host-router').Router;
16
- }
17
- }
18
- interface Adapters {
19
- wecom: WecomAdapter;
20
- }
21
- }
22
-
23
- export * from './types.js';
24
- export { WecomEndpoint } from './endpoint.js';
25
- export { WecomAdapter } from './adapter.js';
26
-
27
- const plugin = usePlugin();
28
- const { provide, useContext } = plugin;
29
-
30
- useContext('router', (router: Router) => {
31
- provide({
32
- name: 'wecom',
33
- description: 'WeCom (企业微信) Endpoint Adapter',
34
- mounted: async (p: Plugin) => {
35
- const adapter = new WecomAdapter(p, router);
36
- await adapter.start();
37
- return adapter;
38
- },
39
- dispose: async (adapter: WecomAdapter) => {
40
- await adapter.stop();
41
- },
42
- });
43
- });
44
-
45
- useContext('tool', 'wecom', (toolService: ToolFeature, wecom: WecomAdapter) => {
46
- const disposers: (() => void)[] = [];
47
- disposers.push(registerWecomPlatformPermitChecker());
48
-
49
- disposers.push(toolService.addTool({
50
- name: 'wecom_get_user',
51
- description: '获取企业微信用户信息',
52
- parameters: {
53
- type: 'object',
54
- properties: {
55
- endpoint_id: { type: 'string', description: 'Endpoint 名称', contextKey: 'endpointId' },
56
- user_id: { type: 'string', description: '用户 ID' },
57
- },
58
- required: ['endpoint_id', 'user_id'],
59
- },
60
- platforms: ['wecom'],
61
- tags: ['wecom'],
62
- execute: async (args: Record<string, any>) => {
63
- const endpoint = wecom.endpoints.get(args.endpoint_id);
64
- if (!endpoint) throw new Error(`Endpoint ${args.endpoint_id} 不存在`);
65
- return await endpoint.getUserInfo(args.user_id);
66
- },
67
- }, plugin.name));
68
-
69
- disposers.push(toolService.addTool({
70
- name: 'wecom_get_dept_users',
71
- description: '获取企业微信部门用户列表',
72
- parameters: {
73
- type: 'object',
74
- properties: {
75
- endpoint_id: { type: 'string', description: 'Endpoint 名称', contextKey: 'endpointId' },
76
- dept_id: { type: 'string', description: '部门 ID' },
77
- },
78
- required: ['endpoint_id', 'dept_id'],
79
- },
80
- platforms: ['wecom'],
81
- tags: ['wecom'],
82
- execute: async (args: Record<string, any>) => {
83
- const endpoint = wecom.endpoints.get(args.endpoint_id);
84
- if (!endpoint) throw new Error(`Endpoint ${args.endpoint_id} 不存在`);
85
- const users = await endpoint.getDepartmentUsers(Number(args.dept_id));
86
- return { users, count: users.length };
87
- },
88
- }, plugin.name));
89
-
90
- disposers.push(toolService.addTool({
91
- name: 'wecom_list_departments',
92
- description: '获取企业微信部门列表',
93
- parameters: {
94
- type: 'object',
95
- properties: {
96
- endpoint_id: { type: 'string', description: 'Endpoint 名称', contextKey: 'endpointId' },
97
- dept_id: { type: 'string', description: '父部门 ID,默认 1(跟部门)' },
98
- },
99
- required: ['endpoint_id'],
100
- },
101
- platforms: ['wecom'],
102
- tags: ['wecom'],
103
- execute: async (args: Record<string, any>) => {
104
- const endpoint = wecom.endpoints.get(args.endpoint_id);
105
- if (!endpoint) throw new Error(`Endpoint ${args.endpoint_id} 不存在`);
106
- const departments = await endpoint.getDepartmentList(Number(args.dept_id) || 1);
107
- return { departments, count: departments.length };
108
- },
109
- }, plugin.name));
110
-
111
- disposers.push(toolService.addTool({
112
- name: 'wecom_send_text',
113
- description: '向指定企业微信用户发送文本消息',
114
- parameters: {
115
- type: 'object',
116
- properties: {
117
- endpoint_id: { type: 'string', description: 'Endpoint 名称', contextKey: 'endpointId' },
118
- user_id: { type: 'string', description: '用户 ID' },
119
- content: { type: 'string', description: '消息内容' },
120
- },
121
- required: ['endpoint_id', 'user_id', 'content'],
122
- },
123
- platforms: ['wecom'],
124
- tags: ['wecom'],
125
- execute: async (args: Record<string, any>) => {
126
- const endpoint = wecom.endpoints.get(args.endpoint_id);
127
- if (!endpoint) throw new Error(`Endpoint ${args.endpoint_id} 不存在`);
128
- const success = await endpoint.sendTextMessage(args.user_id, args.content);
129
- return { success, message: success ? '消息已发送' : '发送失败' };
130
- },
131
- }, plugin.name));
132
-
133
- return () => disposers.forEach(d => d());
134
- });
34
+ export {
35
+ WecomClient,
36
+ WecomEndpoint,
37
+ type WecomClientApi,
38
+ type WecomEndpointOptions,
39
+ type WecomFetch,
40
+ } from './endpoint.js';
41
+
42
+ export {
43
+ buildMediaUploadForm,
44
+ readOutboundImageMedia,
45
+ resolveMediaBinary,
46
+ type MediaBinary,
47
+ type WecomMediaUploadResult,
48
+ } from './media-upload.js';
49
+
50
+ export {
51
+ registerWecomWebhookRoutes,
52
+ handleWecomVerificationRequest,
53
+ handleWecomWebhookRequest,
54
+ type WecomWebhookHandler,
55
+ } from './webhook.js';
@@ -0,0 +1,79 @@
1
+ /**
2
+ * WeCom 临时素材上传(message/send 的 image 段需要 media_id)。
3
+ * 接口:POST {apiBaseUrl}/cgi-bin/media/upload?access_token=…&type=image
4
+ * 与 message/send 同属应用消息接口域(同一 access_token),不引入额外授权域。
5
+ */
6
+ import { readFile } from 'node:fs/promises';
7
+ import { basename } from 'node:path';
8
+ import { isMediaRef, type MediaRef } from '@zhin.js/core';
9
+
10
+ export interface MediaBinary {
11
+ readonly data: Buffer;
12
+ readonly mimeType: string;
13
+ readonly fileName: string;
14
+ }
15
+
16
+ export interface WecomMediaUploadResult {
17
+ readonly errcode?: number;
18
+ readonly errmsg?: string;
19
+ readonly type?: string;
20
+ readonly media_id?: string;
21
+ readonly created_at?: string;
22
+ }
23
+
24
+ const MIME_EXT: Record<string, string> = {
25
+ 'image/jpeg': 'jpg',
26
+ 'image/png': 'png',
27
+ 'image/gif': 'gif',
28
+ 'image/webp': 'webp',
29
+ 'image/bmp': 'bmp',
30
+ };
31
+
32
+ /** 从 canonical MediaRef 解析二进制:base64 解码 / 本地读盘 / URL 下载。 */
33
+ export async function resolveMediaBinary(
34
+ media: MediaRef,
35
+ download: (url: string) => Promise<Buffer> = defaultDownload,
36
+ ): Promise<MediaBinary> {
37
+ const mimeType = media.mime_type ?? 'image/png';
38
+ const ext = MIME_EXT[mimeType] ?? 'png';
39
+ if (media.kind === 'base64') {
40
+ const value = media.value.startsWith('base64://')
41
+ ? media.value.slice('base64://'.length)
42
+ : media.value;
43
+ return { data: Buffer.from(value, 'base64'), mimeType, fileName: `image.${ext}` };
44
+ }
45
+ if (media.kind === 'path') {
46
+ const path = media.value.startsWith('file://') ? media.value.slice('file://'.length) : media.value;
47
+ return { data: await readFile(path), mimeType, fileName: basename(path) };
48
+ }
49
+ if (media.kind === 'url') {
50
+ return { data: await download(media.value), mimeType, fileName: `image.${ext}` };
51
+ }
52
+ // kind=file 为平台不透明引用(media_id),无二进制可解,调用方应直用 value
53
+ throw new Error(`cannot resolve binary from media kind: ${media.kind}`);
54
+ }
55
+
56
+ async function defaultDownload(url: string): Promise<Buffer> {
57
+ const response = await fetch(url, { signal: AbortSignal.timeout(30_000) });
58
+ if (!response.ok) throw new Error(`download failed: HTTP ${response.status}`);
59
+ return Buffer.from(await response.arrayBuffer());
60
+ }
61
+
62
+ /** 临时素材上传的 multipart body(字段名固定为 `media`)。 */
63
+ export function buildMediaUploadForm(binary: MediaBinary): FormData {
64
+ // Buffer 的 ArrayBufferLike 不满足 BlobPart(SharedArrayBuffer 分支),拷贝为 Uint8Array<ArrayBuffer>
65
+ const bytes = new Uint8Array(binary.data.byteLength);
66
+ bytes.set(binary.data);
67
+ const form = new FormData();
68
+ form.append('media', new Blob([bytes], { type: binary.mimeType }), binary.fileName);
69
+ return form;
70
+ }
71
+
72
+ /**
73
+ * 出站 image 段的媒体引用:只读 canonical `data.media`(MediaRef)。
74
+ * 中央 normalizeOutboundPayload 已保证到达 endpoint 的载荷为 canonical;
75
+ * 无 MediaRef 时返回 undefined,由调用方 warn + 丢弃。
76
+ */
77
+ export function readOutboundImageMedia(data: Record<string, unknown>): MediaRef | undefined {
78
+ return isMediaRef(data.media) ? data.media : undefined;
79
+ }
@@ -1,8 +1,7 @@
1
1
  /**
2
2
  * 企业微信 WeCom platform permit
3
3
  */
4
- import type { Message } from 'zhin.js';
5
- import { registerPlatformPermitChecker } from 'zhin.js';
4
+ import type { PermissionSubject } from '@zhin.js/permission';
6
5
 
7
6
  const ADAPTER = 'wecom';
8
7
 
@@ -11,8 +10,8 @@ export function platformPermit(perm: string): string {
11
10
  }
12
11
 
13
12
  const FACTORY_PERM_MAP: Record<string, string> = {
14
- group_admin: 'chat_admin',
15
- group_owner: 'chat_owner',
13
+ scene_admin: 'chat_admin',
14
+ scene_owner: 'chat_owner',
16
15
  };
17
16
 
18
17
  export function wecomGroupPermitResolver(logicalPerm: string): string {
@@ -32,10 +31,9 @@ export function normalizeWecomSenderForPermit(input: {
32
31
  return { role: 'member', permissions: [] };
33
32
  }
34
33
 
35
- export function checkWecomPlatformPermit(perm: string, message: Message<any>): boolean {
36
- const sender = message.$sender as { role?: string; permissions?: string[] };
37
- const permissions = sender.permissions ?? [];
38
- const role = sender.role;
34
+ export function checkWecomPlatformPermit(perm: string, subject: PermissionSubject): boolean {
35
+ const role = subject.sender?.role?.[0];
36
+ const permissions = subject.sender?.permissions ?? [];
39
37
  const has = (t: string) => permissions.includes(t);
40
38
 
41
39
  switch (perm) {
@@ -48,6 +46,3 @@ export function checkWecomPlatformPermit(perm: string, message: Message<any>): b
48
46
  }
49
47
  }
50
48
 
51
- export function registerWecomPlatformPermitChecker(): () => void {
52
- return registerPlatformPermitChecker(ADAPTER, checkWecomPlatformPermit);
53
- }
@@ -0,0 +1,379 @@
1
+ /**
2
+ * WeCom (企业微信) protocol helpers — no legacy Adapter/Endpoint / segment-mapper.
3
+ * Canonicalization is owned by gateway/core before endpoint.send.
4
+ */
5
+
6
+ import { createHash, createDecipheriv, timingSafeEqual } from 'node:crypto';
7
+ import type { IncomingMessage } from 'node:http';
8
+ import type { ConversationRef } from '@zhin.js/im-contract';
9
+
10
+ /** Plugin Runtime owner config (`plugins.<instanceKey>` / schema.json). */
11
+ export interface WecomAdapterConfig {
12
+ readonly id?: string;
13
+ readonly corpId?: string;
14
+ readonly agentSecret?: string;
15
+ readonly token?: string;
16
+ readonly encodingAESKey?: string;
17
+ readonly webhookPath?: string;
18
+ readonly apiBaseUrl?: string;
19
+ /** Transitional: legacy root `endpoints[]` with `context: wecom`. */
20
+ readonly endpoints?: ReadonlyArray<Partial<ResolvedWecomConfig> & {
21
+ readonly context?: string;
22
+ }>;
23
+ }
24
+
25
+ export interface ResolvedWecomConfig {
26
+ readonly context: 'wecom';
27
+ readonly id: string;
28
+ readonly corpId: string;
29
+ readonly agentSecret: string;
30
+ readonly token: string;
31
+ readonly encodingAESKey: string;
32
+ readonly webhookPath: string;
33
+ readonly apiBaseUrl: string;
34
+ }
35
+
36
+ export interface WecomMessage {
37
+ readonly ToUserName: string;
38
+ readonly FromUserName: string;
39
+ readonly CreateTime: number;
40
+ readonly MsgType: 'text' | 'image' | 'voice' | 'video' | 'shortvideo' | 'location' | 'link' | 'event' | string;
41
+ readonly Content?: string;
42
+ readonly MsgId?: string;
43
+ readonly PicUrl?: string;
44
+ readonly MediaId?: string;
45
+ readonly ThumbMediaId?: string;
46
+ readonly Format?: string;
47
+ readonly Recognition?: string;
48
+ readonly Location_X?: string;
49
+ readonly Location_Y?: string;
50
+ readonly Scale?: string;
51
+ readonly Label?: string;
52
+ readonly Title?: string;
53
+ readonly Description?: string;
54
+ readonly Url?: string;
55
+ readonly Event?: string;
56
+ readonly EventKey?: string;
57
+ readonly AgentID?: string;
58
+ }
59
+
60
+ export interface AccessToken {
61
+ access_token: string;
62
+ expires_in: number;
63
+ timestamp: number;
64
+ }
65
+
66
+ export interface WecomApiResponse {
67
+ readonly errcode: number;
68
+ readonly errmsg?: string;
69
+ readonly access_token?: string;
70
+ readonly expires_in?: number;
71
+ readonly msgid?: string;
72
+ readonly userlist?: unknown[];
73
+ readonly department?: unknown[];
74
+ readonly [key: string]: unknown;
75
+ }
76
+
77
+ export interface WecomWireSegment {
78
+ readonly type: string;
79
+ readonly data?: Record<string, unknown>;
80
+ }
81
+
82
+ export interface WecomSendBody {
83
+ readonly msgtype: string;
84
+ readonly data: Record<string, unknown>;
85
+ }
86
+
87
+ export function resolveWecomConfig(config: WecomAdapterConfig = {}): ResolvedWecomConfig {
88
+ const entry = config.endpoints?.find((item) => item.context === 'wecom');
89
+ const corpId = config.corpId ?? entry?.corpId ?? process.env.WECOM_CORP_ID;
90
+ const agentSecret = config.agentSecret ?? entry?.agentSecret ?? process.env.WECOM_AGENT_SECRET;
91
+ const token = config.token ?? entry?.token ?? process.env.WECOM_TOKEN;
92
+ const encodingAESKey = config.encodingAESKey
93
+ ?? entry?.encodingAESKey
94
+ ?? process.env.WECOM_AES_KEY;
95
+ if (!corpId || !agentSecret || !token || !encodingAESKey) {
96
+ throw new TypeError(
97
+ 'WeCom adapter requires corpId + agentSecret + token + encodingAESKey (plugins.<key> or endpoints with context: wecom)',
98
+ );
99
+ }
100
+ const id = (typeof config.id === 'string' && config.id)
101
+ || (typeof entry?.id === 'string' && entry.id)
102
+ || process.env.WECOM_BOT_NAME
103
+ || 'wecom-bot';
104
+ return {
105
+ context: 'wecom',
106
+ id,
107
+ corpId,
108
+ agentSecret,
109
+ token,
110
+ encodingAESKey,
111
+ webhookPath: normalizeWebhookPath(
112
+ config.webhookPath ?? entry?.webhookPath ?? '/wecom/callback',
113
+ ),
114
+ apiBaseUrl: config.apiBaseUrl
115
+ ?? entry?.apiBaseUrl
116
+ ?? 'https://qyapi.weixin.qq.com',
117
+ };
118
+ }
119
+
120
+ export function normalizeWebhookPath(path: string): string {
121
+ const trimmed = path.trim() || '/wecom/callback';
122
+ return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
123
+ }
124
+
125
+ export function queryParam(value: string | null | undefined): string {
126
+ return value ?? '';
127
+ }
128
+
129
+ /** URL 查询里的 Base64 可能把 `+` 解码成空格 */
130
+ export function normalizeEchostrParam(echostr: string): string {
131
+ return echostr.replace(/ /g, '+');
132
+ }
133
+
134
+ export function getAesKey(encodingAESKey: string): Buffer {
135
+ const aesKey = Buffer.from(`${encodingAESKey}=`, 'base64');
136
+ if (aesKey.length !== 32) {
137
+ throw new Error(`encodingAESKey must produce a 32-byte key, got ${aesKey.length} bytes`);
138
+ }
139
+ return aesKey;
140
+ }
141
+
142
+ export function verifySignature(
143
+ token: string,
144
+ timestamp: string,
145
+ nonce: string,
146
+ encrypt: string,
147
+ signature: string,
148
+ ): boolean {
149
+ try {
150
+ const hash = createHash('sha1')
151
+ .update([token, timestamp, nonce, encrypt].sort().join(''))
152
+ .digest('hex');
153
+ const a = Buffer.from(hash);
154
+ const b = Buffer.from(signature);
155
+ if (a.length !== b.length) return false;
156
+ return timingSafeEqual(a, b);
157
+ } catch {
158
+ return false;
159
+ }
160
+ }
161
+
162
+ export function decryptMessage(
163
+ encrypted: string,
164
+ encodingAESKey: string,
165
+ corpId: string,
166
+ ): string | null {
167
+ try {
168
+ const aesKey = getAesKey(encodingAESKey);
169
+ const buf = Buffer.from(encrypted, 'base64');
170
+ const iv = aesKey.subarray(0, 16);
171
+ const decipher = createDecipheriv('aes-256-cbc', aesKey, iv);
172
+ decipher.setAutoPadding(false);
173
+ const decrypted = Buffer.concat([decipher.update(buf), decipher.final()]);
174
+ const pad = decrypted[decrypted.length - 1]!;
175
+ const content = decrypted.subarray(0, decrypted.length - pad);
176
+ const msgLen = content.readUInt32BE(16);
177
+ const msg = content.subarray(20, 20 + msgLen).toString('utf8');
178
+ const extractedCorpId = content.subarray(20 + msgLen).toString('utf8');
179
+ if (extractedCorpId !== corpId) return null;
180
+ return msg;
181
+ } catch {
182
+ return null;
183
+ }
184
+ }
185
+
186
+ export function extractEncryptFromXml(xml: string): string | null {
187
+ const match = xml.match(/<Encrypt><!\[CDATA\[([^[\]]+)\]\]><\/Encrypt>/);
188
+ return match?.[1] ?? null;
189
+ }
190
+
191
+ export function parseXmlMessage(xml: string): WecomMessage | null {
192
+ try {
193
+ const get = (tag: string): string | undefined => {
194
+ const m = xml.match(new RegExp(`<${tag}><!\\[CDATA\\[([^\\[\\]]*)\\]\\]><\\/${tag}>`))
195
+ || xml.match(new RegExp(`<${tag}>([^<]*)<\\/${tag}>`));
196
+ return m ? m[1] : undefined;
197
+ };
198
+ const msgType = get('MsgType');
199
+ if (!msgType) return null;
200
+
201
+ const msg: WecomMessage = {
202
+ ToUserName: get('ToUserName') || '',
203
+ FromUserName: get('FromUserName') || '',
204
+ CreateTime: Number(get('CreateTime') || Date.now()),
205
+ MsgType: msgType,
206
+ MsgId: get('MsgId'),
207
+ AgentID: get('AgentID'),
208
+ Content: get('Content'),
209
+ PicUrl: get('PicUrl'),
210
+ MediaId: get('MediaId'),
211
+ ThumbMediaId: get('ThumbMediaId'),
212
+ Format: get('Format'),
213
+ Recognition: get('Recognition'),
214
+ Location_X: get('Location_X'),
215
+ Location_Y: get('Location_Y'),
216
+ Scale: get('Scale'),
217
+ Label: get('Label'),
218
+ Title: get('Title'),
219
+ Description: get('Description'),
220
+ Url: get('Url'),
221
+ Event: get('Event'),
222
+ EventKey: get('EventKey'),
223
+ };
224
+ return msg;
225
+ } catch {
226
+ return null;
227
+ }
228
+ }
229
+
230
+ /** Build inbound text for OutboundMessageService.receive. */
231
+ export function formatInboundContent(msg: WecomMessage): string {
232
+ switch (msg.MsgType) {
233
+ case 'text':
234
+ return msg.Content || '(空消息)';
235
+ case 'image':
236
+ return msg.PicUrl ? `[image: ${msg.PicUrl}]` : '[image]';
237
+ case 'voice':
238
+ return msg.Recognition
239
+ ? msg.Recognition
240
+ : `[voice${msg.Format ? `: ${msg.Format}` : ''}]`;
241
+ case 'video':
242
+ case 'shortvideo':
243
+ return '[video]';
244
+ case 'location':
245
+ return `[位置] ${msg.Label || ''} (${msg.Location_X}, ${msg.Location_Y})`.trim();
246
+ case 'link':
247
+ return `[link: ${msg.Title ?? ''}${msg.Url ? ` ${msg.Url}` : ''}]`;
248
+ case 'event':
249
+ return `[事件] ${msg.Event || ''} ${msg.EventKey || ''}`.trim();
250
+ default:
251
+ return `[不支持的消息类型: ${msg.MsgType}]`;
252
+ }
253
+ }
254
+
255
+ export function resolveChatType(fromUserName: string): 'group' | 'private' {
256
+ return fromUserName.endsWith('@chatroom') ? 'group' : 'private';
257
+ }
258
+
259
+ /**
260
+ * 入站归一化 → ConversationRef:WeCom 无 guild/channel 容器概念,
261
+ * `@chatroom` 群会话 → kind 'group',其余(应用消息/单聊)→ kind 'private'。
262
+ */
263
+ export function wecomInboundConversation(endpointKey: string, msg: WecomMessage): ConversationRef {
264
+ return {
265
+ endpoint: { id: endpointKey, adapter: endpointKey.split('\0')[0] ?? endpointKey },
266
+ kind: resolveChatType(msg.FromUserName),
267
+ id: msg.FromUserName,
268
+ };
269
+ }
270
+
271
+ /**
272
+ * Wire-encode an already-rendered outbound payload into WeCom message/send body parts.
273
+ */
274
+ export function formatOutboundBody(payload: unknown): WecomSendBody {
275
+ if (typeof payload === 'string') {
276
+ return { msgtype: 'text', data: { content: payload } };
277
+ }
278
+ if (!Array.isArray(payload)) {
279
+ return { msgtype: 'text', data: { content: String(payload ?? '') } };
280
+ }
281
+
282
+ const textParts: string[] = [];
283
+ let hasMedia = false;
284
+ let mediaType = '';
285
+ let mediaData: Record<string, unknown> | null = null;
286
+
287
+ for (const item of payload) {
288
+ if (typeof item === 'string') {
289
+ textParts.push(item);
290
+ continue;
291
+ }
292
+ if (!item || typeof item !== 'object') continue;
293
+ const seg = item as WecomWireSegment;
294
+ const data = seg.data ?? {};
295
+ switch (seg.type) {
296
+ case 'text':
297
+ textParts.push(String(data.content ?? data.text ?? ''));
298
+ break;
299
+ case 'at': {
300
+ const userId = data.id ?? data.userId;
301
+ if (userId) textParts.push(`<@${userId}>`);
302
+ break;
303
+ }
304
+ case 'image':
305
+ if (!hasMedia) {
306
+ hasMedia = true;
307
+ mediaType = 'image';
308
+ // media_id 由 endpoint 的 #materializeOutboundMedia 物化写入(内部 wire)。
309
+ mediaData = { media_id: data.media_id };
310
+ }
311
+ break;
312
+ case 'markdown':
313
+ if (!hasMedia) {
314
+ hasMedia = true;
315
+ mediaType = 'markdown';
316
+ mediaData = { content: data.content || data.text };
317
+ }
318
+ break;
319
+ case 'link':
320
+ if (!hasMedia) {
321
+ hasMedia = true;
322
+ mediaType = 'news';
323
+ mediaData = {
324
+ articles: [{
325
+ title: data.title || '链接',
326
+ description: data.text || data.content || '',
327
+ url: data.url,
328
+ picurl: data.picUrl,
329
+ }],
330
+ };
331
+ }
332
+ break;
333
+ default:
334
+ break;
335
+ }
336
+ }
337
+
338
+ if (hasMedia && mediaData) {
339
+ return { msgtype: mediaType, data: mediaData };
340
+ }
341
+ return { msgtype: 'text', data: { content: textParts.join('') } };
342
+ }
343
+
344
+ export function buildSendRequestBody(
345
+ targetId: string,
346
+ content: WecomSendBody,
347
+ agentId: string | number,
348
+ ): Record<string, unknown> {
349
+ const body: Record<string, unknown> = {
350
+ msgtype: content.msgtype,
351
+ agentid: agentId,
352
+ [content.msgtype]: content.data,
353
+ };
354
+ if (targetId.endsWith('@chatroom')) {
355
+ body.chatid = targetId;
356
+ } else {
357
+ body.touser = targetId;
358
+ }
359
+ return body;
360
+ }
361
+
362
+ export async function readTextBody(
363
+ request: IncomingMessage,
364
+ options: { readonly limit?: number } = {},
365
+ ): Promise<string> {
366
+ const limit = options.limit ?? 1_048_576;
367
+ const chunks: Buffer[] = [];
368
+ let size = 0;
369
+ for await (const chunk of request) {
370
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
371
+ size += buffer.length;
372
+ if (size > limit) {
373
+ request.destroy();
374
+ throw new Error(`Request body exceeds ${limit} bytes`);
375
+ }
376
+ chunks.push(buffer);
377
+ }
378
+ return Buffer.concat(chunks).toString('utf8');
379
+ }