@zhin.js/adapter-wecom 2.0.2 → 3.0.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 (53) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +80 -226
  3. package/adapters/wecom.ts +27 -0
  4. package/agent/tools/get_dept_users.ts +2 -2
  5. package/agent/tools/get_user.ts +2 -2
  6. package/agent/tools/list_departments.ts +2 -2
  7. package/agent/tools/send_text.ts +2 -2
  8. package/lib/endpoint.d.ts +46 -0
  9. package/lib/endpoint.js +205 -0
  10. package/lib/index.d.ts +5 -0
  11. package/lib/index.js +5 -0
  12. package/lib/platform-permit.d.ts +15 -0
  13. package/lib/{src/platform-permit.js → platform-permit.js} +1 -2
  14. package/lib/protocol.d.ts +96 -0
  15. package/lib/protocol.js +252 -0
  16. package/lib/webhook.d.ts +14 -0
  17. package/lib/webhook.js +86 -0
  18. package/lib/wecom-agent-deps.d.ts +17 -0
  19. package/lib/wecom-agent-deps.js +30 -0
  20. package/package.json +45 -20
  21. package/plugin.ts +12 -0
  22. package/schema.json +60 -0
  23. package/src/endpoint.ts +196 -584
  24. package/src/index.ts +48 -53
  25. package/src/platform-permit.ts +1 -1
  26. package/src/protocol.ts +362 -0
  27. package/src/webhook.ts +130 -0
  28. package/src/wecom-agent-deps.ts +37 -9
  29. package/lib/agent/tools/get_dept_users.js +0 -18
  30. package/lib/agent/tools/get_dept_users.js.map +0 -1
  31. package/lib/agent/tools/get_user.js +0 -17
  32. package/lib/agent/tools/get_user.js.map +0 -1
  33. package/lib/agent/tools/list_departments.js +0 -18
  34. package/lib/agent/tools/list_departments.js.map +0 -1
  35. package/lib/agent/tools/send_text.js +0 -19
  36. package/lib/agent/tools/send_text.js.map +0 -1
  37. package/lib/src/adapter.js +0 -22
  38. package/lib/src/adapter.js.map +0 -1
  39. package/lib/src/endpoint.js +0 -602
  40. package/lib/src/endpoint.js.map +0 -1
  41. package/lib/src/index.js +0 -41
  42. package/lib/src/index.js.map +0 -1
  43. package/lib/src/platform-permit.js.map +0 -1
  44. package/lib/src/segment-mapper.js +0 -2
  45. package/lib/src/segment-mapper.js.map +0 -1
  46. package/lib/src/types.js +0 -5
  47. package/lib/src/types.js.map +0 -1
  48. package/lib/src/wecom-agent-deps.js +0 -10
  49. package/lib/src/wecom-agent-deps.js.map +0 -1
  50. package/plugin.yml +0 -3
  51. package/src/adapter.ts +0 -28
  52. package/src/segment-mapper.ts +0 -1
  53. package/src/types.ts +0 -51
@@ -0,0 +1,205 @@
1
+ import { formatCompact, getLogger } from '@zhin.js/logger';
2
+ import { registerWecomAgentEndpoint } from './wecom-agent-deps.js';
3
+ import { buildSendRequestBody, formatInboundContent, formatOutboundBody, resolveChatType, } from './protocol.js';
4
+ import { registerWecomWebhookRoutes } from './webhook.js';
5
+ const logger = getLogger('wecom');
6
+ export class WecomEndpoint {
7
+ #options;
8
+ #fetch;
9
+ #routeReleases = [];
10
+ #accessToken = { access_token: '', expires_in: 0, timestamp: 0 };
11
+ #refreshPromise = null;
12
+ #open = false;
13
+ #started = false;
14
+ #unregisterAgent;
15
+ constructor(options) {
16
+ this.#options = options;
17
+ this.#fetch = options.fetch ?? globalThis.fetch;
18
+ }
19
+ /** Used by webhook handler. */
20
+ get isOpen() {
21
+ return this.#open;
22
+ }
23
+ get config() {
24
+ return this.#options.config;
25
+ }
26
+ async start() {
27
+ if (this.#started)
28
+ return;
29
+ this.#started = true;
30
+ try {
31
+ await this.#refreshAccessToken();
32
+ this.#unregisterAgent = registerWecomAgentEndpoint(this.#options.config.name, this);
33
+ this.#routeReleases.push(...registerWecomWebhookRoutes(this.#options.http, this));
34
+ logger.debug(formatCompact({
35
+ endpoint: this.#options.config.name,
36
+ op: 'webhook',
37
+ path: this.#options.config.webhookPath,
38
+ }));
39
+ }
40
+ catch (error) {
41
+ await this.stop();
42
+ logger.error('Failed to connect WeCom endpoint:', error);
43
+ throw error;
44
+ }
45
+ }
46
+ open() {
47
+ this.#open = true;
48
+ }
49
+ close() {
50
+ this.#open = false;
51
+ }
52
+ async stop() {
53
+ this.#open = false;
54
+ for (const release of this.#routeReleases.splice(0))
55
+ release();
56
+ this.#unregisterAgent?.();
57
+ this.#unregisterAgent = undefined;
58
+ this.#started = false;
59
+ logger.debug(formatCompact({ op: 'disconnect', endpoint: this.#options.config.name }));
60
+ }
61
+ async send({ target, payload }) {
62
+ const content = formatOutboundBody(payload);
63
+ const body = buildSendRequestBody(target, content,
64
+ // Legacy field: historically written into agentid (preserved for cutover).
65
+ this.#options.config.agentSecret);
66
+ const data = await this.#request('/cgi-bin/message/send', {
67
+ method: 'POST',
68
+ body,
69
+ });
70
+ if (data.errcode !== 0) {
71
+ throw new Error(`Failed to send message: ${data.errmsg} (${data.errcode})`);
72
+ }
73
+ logger.debug(formatCompact({ op: 'send', endpoint: this.#options.config.name, to: target }));
74
+ return data.msgid || `${Date.now()}`;
75
+ }
76
+ /** Test / internal: admit a parsed message when open (non-webhook path). */
77
+ admit(msg) {
78
+ if (!this.#open)
79
+ return;
80
+ const chatType = resolveChatType(msg.FromUserName);
81
+ void this.#options.gateway.receive({
82
+ adapter: this.#options.id,
83
+ target: msg.FromUserName,
84
+ content: formatInboundContent(msg),
85
+ sender: msg.FromUserName,
86
+ id: msg.MsgId || `${msg.CreateTime}`,
87
+ metadata: Object.freeze({
88
+ msgType: msg.MsgType,
89
+ event: msg.Event,
90
+ chatType,
91
+ endpoint: this.#options.config.name,
92
+ toUserName: msg.ToUserName,
93
+ agentId: msg.AgentID,
94
+ }),
95
+ }).catch((err) => {
96
+ logger.warn(formatCompact({
97
+ op: 'wecom_gateway_receive_failed',
98
+ target: msg.FromUserName,
99
+ error: err instanceof Error ? err.message : String(err),
100
+ }));
101
+ });
102
+ }
103
+ async getUserInfo(userId) {
104
+ try {
105
+ const data = await this.#request('/cgi-bin/user/get', {
106
+ params: { userid: userId },
107
+ });
108
+ if (data.errcode === 0)
109
+ return data;
110
+ throw new Error(`Failed to get user info: ${data.errmsg}`);
111
+ }
112
+ catch (error) {
113
+ logger.error('Failed to get user info:', error);
114
+ return null;
115
+ }
116
+ }
117
+ async getDepartmentUsers(deptId) {
118
+ try {
119
+ const data = await this.#request('/cgi-bin/user/simplelist', {
120
+ params: { department_id: deptId },
121
+ });
122
+ if (data.errcode === 0)
123
+ return data.userlist || [];
124
+ throw new Error(`Failed to get department users: ${data.errmsg}`);
125
+ }
126
+ catch (error) {
127
+ logger.error('Failed to get department users:', error);
128
+ return [];
129
+ }
130
+ }
131
+ async getDepartmentList(deptId = 1) {
132
+ try {
133
+ const data = await this.#request('/cgi-bin/department/list', {
134
+ params: { id: deptId },
135
+ });
136
+ if (data.errcode === 0)
137
+ return data.department || [];
138
+ throw new Error(`Failed to get department list: ${data.errmsg}`);
139
+ }
140
+ catch (error) {
141
+ logger.error('Failed to get department list:', error);
142
+ return [];
143
+ }
144
+ }
145
+ async sendTextMessage(userId, content) {
146
+ try {
147
+ await this.send({ target: userId, payload: content });
148
+ return true;
149
+ }
150
+ catch (error) {
151
+ logger.error('Failed to send text message:', error);
152
+ return false;
153
+ }
154
+ }
155
+ async #request(path, options = {}) {
156
+ await this.#ensureAccessToken();
157
+ const { method = 'GET', params = {}, body } = options;
158
+ const urlParams = new URLSearchParams({
159
+ ...Object.fromEntries(Object.entries(params).map(([key, value]) => [key, String(value)])),
160
+ access_token: this.#accessToken.access_token,
161
+ });
162
+ const url = `${this.#options.config.apiBaseUrl}${path}?${urlParams.toString()}`;
163
+ const response = await this.#fetch(url, {
164
+ method,
165
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
166
+ body: body && method === 'POST' ? JSON.stringify(body) : undefined,
167
+ });
168
+ if (!response.ok) {
169
+ const text = await response.text().catch(() => '');
170
+ throw new Error(`WeCom API error ${response.status}: ${text}`);
171
+ }
172
+ return await response.json();
173
+ }
174
+ async #ensureAccessToken() {
175
+ const now = Date.now();
176
+ if (this.#accessToken.access_token
177
+ && now < this.#accessToken.timestamp + (this.#accessToken.expires_in - 300) * 1000) {
178
+ return;
179
+ }
180
+ if (this.#refreshPromise) {
181
+ await this.#refreshPromise;
182
+ return;
183
+ }
184
+ this.#refreshPromise = this.#refreshAccessToken()
185
+ .then(() => this.#accessToken.access_token)
186
+ .finally(() => { this.#refreshPromise = null; });
187
+ await this.#refreshPromise;
188
+ }
189
+ async #refreshAccessToken() {
190
+ const { corpId, agentSecret, apiBaseUrl } = this.#options.config;
191
+ const url = `${apiBaseUrl}/cgi-bin/gettoken?corpid=${corpId}&corpsecret=${agentSecret}`;
192
+ const response = await this.#fetch(url);
193
+ const data = await response.json();
194
+ if (data.errcode === 0 && data.access_token) {
195
+ this.#accessToken = {
196
+ access_token: data.access_token,
197
+ expires_in: data.expires_in ?? 7200,
198
+ timestamp: Date.now(),
199
+ };
200
+ logger.debug('Access token refreshed successfully');
201
+ return;
202
+ }
203
+ throw new Error(`Failed to get access token: ${data.errmsg} (${data.errcode})`);
204
+ }
205
+ }
package/lib/index.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ export { buildSendRequestBody, decryptMessage, extractEncryptFromXml, formatInboundContent, formatOutboundBody, getAesKey, normalizeEchostrParam, normalizeWebhookPath, parseXmlMessage, queryParam, readTextBody, resolveChatType, resolveWecomConfig, verifySignature, type AccessToken, type ResolvedWecomConfig, type WecomAdapterConfig, type WecomApiResponse, type WecomMessage, type WecomSendBody, type WecomWireSegment, } from './protocol.js';
2
+ export { getWecomAgentDeps, registerWecomAgentEndpoint, setWecomAgentDeps, type WecomAgentDeps, type WecomAgentEndpoint, } from './wecom-agent-deps.js';
3
+ export { checkWecomPlatformPermit, normalizeWecomSenderForPermit, platformPermit, registerWecomPlatformPermitChecker, wecomGroupPermitResolver, } from './platform-permit.js';
4
+ export { WecomEndpoint, type WecomEndpointOptions, type WecomFetch, } from './endpoint.js';
5
+ export { registerWecomWebhookRoutes, handleWecomVerificationRequest, handleWecomWebhookRequest, type WecomWebhookHandler, } from './webhook.js';
package/lib/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { buildSendRequestBody, decryptMessage, extractEncryptFromXml, formatInboundContent, formatOutboundBody, getAesKey, normalizeEchostrParam, normalizeWebhookPath, parseXmlMessage, queryParam, readTextBody, resolveChatType, resolveWecomConfig, verifySignature, } from './protocol.js';
2
+ export { getWecomAgentDeps, registerWecomAgentEndpoint, setWecomAgentDeps, } from './wecom-agent-deps.js';
3
+ export { checkWecomPlatformPermit, normalizeWecomSenderForPermit, platformPermit, registerWecomPlatformPermitChecker, wecomGroupPermitResolver, } from './platform-permit.js';
4
+ export { WecomEndpoint, } from './endpoint.js';
5
+ export { registerWecomWebhookRoutes, handleWecomVerificationRequest, handleWecomWebhookRequest, } from './webhook.js';
@@ -0,0 +1,15 @@
1
+ /**
2
+ * 企业微信 WeCom platform permit
3
+ */
4
+ import { type Message } from '@zhin.js/core';
5
+ export declare function platformPermit(perm: string): string;
6
+ export declare function wecomGroupPermitResolver(logicalPerm: string): string;
7
+ export declare function normalizeWecomSenderForPermit(input: {
8
+ isOwner?: boolean;
9
+ isAdmin?: boolean;
10
+ }): {
11
+ role?: string;
12
+ permissions?: string[];
13
+ };
14
+ export declare function checkWecomPlatformPermit(perm: string, message: Message<any>): boolean;
15
+ export declare function registerWecomPlatformPermitChecker(): () => void;
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * 企业微信 WeCom platform permit
3
3
  */
4
- import { registerPlatformPermitChecker } from 'zhin.js';
4
+ import { registerPlatformPermitChecker } from '@zhin.js/core';
5
5
  const ADAPTER = 'wecom';
6
6
  export function platformPermit(perm) {
7
7
  return `platform(${ADAPTER},${perm})`;
@@ -39,4 +39,3 @@ export function checkWecomPlatformPermit(perm, message) {
39
39
  export function registerWecomPlatformPermitChecker() {
40
40
  return registerPlatformPermitChecker(ADAPTER, checkWecomPlatformPermit);
41
41
  }
42
- //# sourceMappingURL=platform-permit.js.map
@@ -0,0 +1,96 @@
1
+ /**
2
+ * WeCom (企业微信) 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 WecomAdapterConfig {
8
+ readonly name?: string;
9
+ readonly corpId?: string;
10
+ readonly agentSecret?: string;
11
+ readonly token?: string;
12
+ readonly encodingAESKey?: string;
13
+ readonly webhookPath?: string;
14
+ readonly apiBaseUrl?: string;
15
+ /** Transitional: legacy root `endpoints[]` with `context: wecom`. */
16
+ readonly endpoints?: ReadonlyArray<Partial<ResolvedWecomConfig> & {
17
+ readonly context?: string;
18
+ }>;
19
+ }
20
+ export interface ResolvedWecomConfig {
21
+ readonly context: 'wecom';
22
+ readonly name: string;
23
+ readonly corpId: string;
24
+ readonly agentSecret: string;
25
+ readonly token: string;
26
+ readonly encodingAESKey: string;
27
+ readonly webhookPath: string;
28
+ readonly apiBaseUrl: string;
29
+ }
30
+ export interface WecomMessage {
31
+ readonly ToUserName: string;
32
+ readonly FromUserName: string;
33
+ readonly CreateTime: number;
34
+ readonly MsgType: 'text' | 'image' | 'voice' | 'video' | 'shortvideo' | 'location' | 'link' | 'event' | string;
35
+ readonly Content?: string;
36
+ readonly MsgId?: string;
37
+ readonly PicUrl?: string;
38
+ readonly MediaId?: string;
39
+ readonly ThumbMediaId?: string;
40
+ readonly Format?: string;
41
+ readonly Recognition?: string;
42
+ readonly Location_X?: string;
43
+ readonly Location_Y?: string;
44
+ readonly Scale?: string;
45
+ readonly Label?: string;
46
+ readonly Title?: string;
47
+ readonly Description?: string;
48
+ readonly Url?: string;
49
+ readonly Event?: string;
50
+ readonly EventKey?: string;
51
+ readonly AgentID?: string;
52
+ }
53
+ export interface AccessToken {
54
+ access_token: string;
55
+ expires_in: number;
56
+ timestamp: number;
57
+ }
58
+ export interface WecomApiResponse {
59
+ readonly errcode: number;
60
+ readonly errmsg?: string;
61
+ readonly access_token?: string;
62
+ readonly expires_in?: number;
63
+ readonly msgid?: string;
64
+ readonly userlist?: unknown[];
65
+ readonly department?: unknown[];
66
+ readonly [key: string]: unknown;
67
+ }
68
+ export interface WecomWireSegment {
69
+ readonly type: string;
70
+ readonly data?: Record<string, unknown>;
71
+ }
72
+ export interface WecomSendBody {
73
+ readonly msgtype: string;
74
+ readonly data: Record<string, unknown>;
75
+ }
76
+ export declare function resolveWecomConfig(config?: WecomAdapterConfig): ResolvedWecomConfig;
77
+ export declare function normalizeWebhookPath(path: string): string;
78
+ export declare function queryParam(value: string | null | undefined): string;
79
+ /** URL 查询里的 Base64 可能把 `+` 解码成空格 */
80
+ export declare function normalizeEchostrParam(echostr: string): string;
81
+ export declare function getAesKey(encodingAESKey: string): Buffer;
82
+ export declare function verifySignature(token: string, timestamp: string, nonce: string, encrypt: string, signature: string): boolean;
83
+ export declare function decryptMessage(encrypted: string, encodingAESKey: string, corpId: string): string | null;
84
+ export declare function extractEncryptFromXml(xml: string): string | null;
85
+ export declare function parseXmlMessage(xml: string): WecomMessage | null;
86
+ /** Build inbound text for MessageGateway.receive. */
87
+ export declare function formatInboundContent(msg: WecomMessage): string;
88
+ export declare function resolveChatType(fromUserName: string): 'group' | 'private';
89
+ /**
90
+ * Wire-encode an already-rendered outbound payload into WeCom message/send body parts.
91
+ */
92
+ export declare function formatOutboundBody(payload: unknown): WecomSendBody;
93
+ export declare function buildSendRequestBody(targetId: string, content: WecomSendBody, agentId: string | number): Record<string, unknown>;
94
+ export declare function readTextBody(request: IncomingMessage, options?: {
95
+ readonly limit?: number;
96
+ }): Promise<string>;
@@ -0,0 +1,252 @@
1
+ /**
2
+ * WeCom (企业微信) protocol helpers — no legacy Adapter/Endpoint / segment-mapper.
3
+ * Canonicalization is owned by gateway/core before endpoint.send.
4
+ */
5
+ import { createHash, createDecipheriv } from 'node:crypto';
6
+ export function resolveWecomConfig(config = {}) {
7
+ const entry = config.endpoints?.find((item) => item.context === 'wecom');
8
+ const corpId = config.corpId ?? entry?.corpId ?? process.env.WECOM_CORP_ID;
9
+ const agentSecret = config.agentSecret ?? entry?.agentSecret ?? process.env.WECOM_AGENT_SECRET;
10
+ const token = config.token ?? entry?.token ?? process.env.WECOM_TOKEN;
11
+ const encodingAESKey = config.encodingAESKey
12
+ ?? entry?.encodingAESKey
13
+ ?? process.env.WECOM_AES_KEY;
14
+ if (!corpId || !agentSecret || !token || !encodingAESKey) {
15
+ throw new TypeError('WeCom adapter requires corpId + agentSecret + token + encodingAESKey (plugins.<key> or endpoints with context: wecom)');
16
+ }
17
+ const name = (typeof config.name === 'string' && config.name)
18
+ || (typeof entry?.name === 'string' && entry.name)
19
+ || process.env.WECOM_BOT_NAME
20
+ || 'wecom-bot';
21
+ return {
22
+ context: 'wecom',
23
+ name,
24
+ corpId,
25
+ agentSecret,
26
+ token,
27
+ encodingAESKey,
28
+ webhookPath: normalizeWebhookPath(config.webhookPath ?? entry?.webhookPath ?? '/wecom/callback'),
29
+ apiBaseUrl: config.apiBaseUrl
30
+ ?? entry?.apiBaseUrl
31
+ ?? 'https://qyapi.weixin.qq.com',
32
+ };
33
+ }
34
+ export function normalizeWebhookPath(path) {
35
+ const trimmed = path.trim() || '/wecom/callback';
36
+ return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
37
+ }
38
+ export function queryParam(value) {
39
+ return value ?? '';
40
+ }
41
+ /** URL 查询里的 Base64 可能把 `+` 解码成空格 */
42
+ export function normalizeEchostrParam(echostr) {
43
+ return echostr.replace(/ /g, '+');
44
+ }
45
+ export function getAesKey(encodingAESKey) {
46
+ const aesKey = Buffer.from(`${encodingAESKey}=`, 'base64');
47
+ if (aesKey.length !== 32) {
48
+ throw new Error(`encodingAESKey must produce a 32-byte key, got ${aesKey.length} bytes`);
49
+ }
50
+ return aesKey;
51
+ }
52
+ export function verifySignature(token, timestamp, nonce, encrypt, signature) {
53
+ try {
54
+ const hash = createHash('sha1')
55
+ .update([token, timestamp, nonce, encrypt].sort().join(''))
56
+ .digest('hex');
57
+ return hash === signature;
58
+ }
59
+ catch {
60
+ return false;
61
+ }
62
+ }
63
+ export function decryptMessage(encrypted, encodingAESKey, corpId) {
64
+ try {
65
+ const aesKey = getAesKey(encodingAESKey);
66
+ const buf = Buffer.from(encrypted, 'base64');
67
+ const iv = aesKey.subarray(0, 16);
68
+ const decipher = createDecipheriv('aes-256-cbc', aesKey, iv);
69
+ decipher.setAutoPadding(false);
70
+ const decrypted = Buffer.concat([decipher.update(buf), decipher.final()]);
71
+ const pad = decrypted[decrypted.length - 1];
72
+ const content = decrypted.subarray(0, decrypted.length - pad);
73
+ const msgLen = content.readUInt32BE(16);
74
+ const msg = content.subarray(20, 20 + msgLen).toString('utf8');
75
+ const extractedCorpId = content.subarray(20 + msgLen).toString('utf8');
76
+ if (extractedCorpId !== corpId)
77
+ return null;
78
+ return msg;
79
+ }
80
+ catch {
81
+ return null;
82
+ }
83
+ }
84
+ export function extractEncryptFromXml(xml) {
85
+ const match = xml.match(/<Encrypt><!\[CDATA\[([^[\]]+)\]\]><\/Encrypt>/);
86
+ return match?.[1] ?? null;
87
+ }
88
+ export function parseXmlMessage(xml) {
89
+ try {
90
+ const get = (tag) => {
91
+ const m = xml.match(new RegExp(`<${tag}><!\\[CDATA\\[([^\\[\\]]*)\\]\\]><\\/${tag}>`))
92
+ || xml.match(new RegExp(`<${tag}>([^<]*)<\\/${tag}>`));
93
+ return m ? m[1] : undefined;
94
+ };
95
+ const msgType = get('MsgType');
96
+ if (!msgType)
97
+ return null;
98
+ const msg = {
99
+ ToUserName: get('ToUserName') || '',
100
+ FromUserName: get('FromUserName') || '',
101
+ CreateTime: Number(get('CreateTime') || Date.now()),
102
+ MsgType: msgType,
103
+ MsgId: get('MsgId'),
104
+ AgentID: get('AgentID'),
105
+ Content: get('Content'),
106
+ PicUrl: get('PicUrl'),
107
+ MediaId: get('MediaId'),
108
+ ThumbMediaId: get('ThumbMediaId'),
109
+ Format: get('Format'),
110
+ Recognition: get('Recognition'),
111
+ Location_X: get('Location_X'),
112
+ Location_Y: get('Location_Y'),
113
+ Scale: get('Scale'),
114
+ Label: get('Label'),
115
+ Title: get('Title'),
116
+ Description: get('Description'),
117
+ Url: get('Url'),
118
+ Event: get('Event'),
119
+ EventKey: get('EventKey'),
120
+ };
121
+ return msg;
122
+ }
123
+ catch {
124
+ return null;
125
+ }
126
+ }
127
+ /** Build inbound text for MessageGateway.receive. */
128
+ export function formatInboundContent(msg) {
129
+ switch (msg.MsgType) {
130
+ case 'text':
131
+ return msg.Content || '(空消息)';
132
+ case 'image':
133
+ return msg.PicUrl ? `[image: ${msg.PicUrl}]` : '[image]';
134
+ case 'voice':
135
+ return msg.Recognition
136
+ ? msg.Recognition
137
+ : `[voice${msg.Format ? `: ${msg.Format}` : ''}]`;
138
+ case 'video':
139
+ case 'shortvideo':
140
+ return '[video]';
141
+ case 'location':
142
+ return `[位置] ${msg.Label || ''} (${msg.Location_X}, ${msg.Location_Y})`.trim();
143
+ case 'link':
144
+ return `[link: ${msg.Title ?? ''}${msg.Url ? ` ${msg.Url}` : ''}]`;
145
+ case 'event':
146
+ return `[事件] ${msg.Event || ''} ${msg.EventKey || ''}`.trim();
147
+ default:
148
+ return `[不支持的消息类型: ${msg.MsgType}]`;
149
+ }
150
+ }
151
+ export function resolveChatType(fromUserName) {
152
+ return fromUserName.endsWith('@chatroom') ? 'group' : 'private';
153
+ }
154
+ /**
155
+ * Wire-encode an already-rendered outbound payload into WeCom message/send body parts.
156
+ */
157
+ export function formatOutboundBody(payload) {
158
+ if (typeof payload === 'string') {
159
+ return { msgtype: 'text', data: { content: payload } };
160
+ }
161
+ if (!Array.isArray(payload)) {
162
+ return { msgtype: 'text', data: { content: String(payload ?? '') } };
163
+ }
164
+ const textParts = [];
165
+ let hasMedia = false;
166
+ let mediaType = '';
167
+ let mediaData = null;
168
+ for (const item of payload) {
169
+ if (typeof item === 'string') {
170
+ textParts.push(item);
171
+ continue;
172
+ }
173
+ if (!item || typeof item !== 'object')
174
+ continue;
175
+ const seg = item;
176
+ const data = seg.data ?? {};
177
+ switch (seg.type) {
178
+ case 'text':
179
+ textParts.push(String(data.content ?? data.text ?? ''));
180
+ break;
181
+ case 'at': {
182
+ const userId = data.id ?? data.userId;
183
+ if (userId)
184
+ textParts.push(`<@${userId}>`);
185
+ break;
186
+ }
187
+ case 'image':
188
+ if (!hasMedia) {
189
+ hasMedia = true;
190
+ mediaType = 'image';
191
+ mediaData = { media_id: data.file || data.url };
192
+ }
193
+ break;
194
+ case 'markdown':
195
+ if (!hasMedia) {
196
+ hasMedia = true;
197
+ mediaType = 'markdown';
198
+ mediaData = { content: data.content || data.text };
199
+ }
200
+ break;
201
+ case 'link':
202
+ if (!hasMedia) {
203
+ hasMedia = true;
204
+ mediaType = 'news';
205
+ mediaData = {
206
+ articles: [{
207
+ title: data.title || '链接',
208
+ description: data.text || data.content || '',
209
+ url: data.url,
210
+ picurl: data.picUrl,
211
+ }],
212
+ };
213
+ }
214
+ break;
215
+ default:
216
+ break;
217
+ }
218
+ }
219
+ if (hasMedia && mediaData) {
220
+ return { msgtype: mediaType, data: mediaData };
221
+ }
222
+ return { msgtype: 'text', data: { content: textParts.join('') } };
223
+ }
224
+ export function buildSendRequestBody(targetId, content, agentId) {
225
+ const body = {
226
+ msgtype: content.msgtype,
227
+ agentid: agentId,
228
+ [content.msgtype]: content.data,
229
+ };
230
+ if (targetId.endsWith('@chatroom')) {
231
+ body.chatid = targetId;
232
+ }
233
+ else {
234
+ body.touser = targetId;
235
+ }
236
+ return body;
237
+ }
238
+ export async function readTextBody(request, options = {}) {
239
+ const limit = options.limit ?? 1_048_576;
240
+ const chunks = [];
241
+ let size = 0;
242
+ for await (const chunk of request) {
243
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
244
+ size += buffer.length;
245
+ if (size > limit) {
246
+ request.destroy();
247
+ throw new Error(`Request body exceeds ${limit} bytes`);
248
+ }
249
+ chunks.push(buffer);
250
+ }
251
+ return Buffer.concat(chunks).toString('utf8');
252
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * WeCom webhook HTTP: URL verification (GET) + encrypted inbound (POST).
3
+ */
4
+ import type { IncomingMessage, ServerResponse } from 'node:http';
5
+ import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
6
+ import { type ResolvedWecomConfig, type WecomMessage } from './protocol.js';
7
+ export interface WecomWebhookHandler {
8
+ readonly config: ResolvedWecomConfig;
9
+ readonly isOpen: boolean;
10
+ admit(msg: WecomMessage): void;
11
+ }
12
+ export declare function registerWecomWebhookRoutes(http: HttpHost, handler: WecomWebhookHandler): HttpRouteRegistration[];
13
+ export declare function handleWecomVerificationRequest(_request: IncomingMessage, response: ServerResponse, url: URL, handler: WecomWebhookHandler): void;
14
+ export declare function handleWecomWebhookRequest(request: IncomingMessage, response: ServerResponse, url: URL, handler: WecomWebhookHandler): Promise<void>;