@zhin.js/adapter-wechat-mp 1.0.0 → 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.
- package/CHANGELOG.md +478 -0
- package/README.md +40 -88
- package/adapters/wechat-mp.js +32 -0
- package/adapters/wechat-mp.ts +38 -0
- package/commands/endpoint/add/[id].js +3 -0
- package/commands/endpoint/add/[id].ts +3 -0
- package/commands/endpoint/list.js +3 -0
- package/commands/endpoint/list.ts +3 -0
- package/commands/endpoint/remove/[id].js +3 -0
- package/commands/endpoint/remove/[id].ts +3 -0
- package/lib/client.d.ts +32 -0
- package/lib/client.js +70 -0
- package/lib/endpoint.d.ts +32 -0
- package/lib/endpoint.js +264 -0
- package/lib/index.d.ts +6 -15
- package/lib/index.js +6 -25
- package/lib/media-upload.d.ts +22 -0
- package/lib/media-upload.js +64 -0
- package/lib/passive-reply.d.ts +0 -1
- package/lib/passive-reply.js +0 -1
- package/lib/protocol.d.ts +129 -0
- package/lib/protocol.js +349 -0
- package/lib/side-event-dispatch.d.ts +4 -0
- package/lib/side-event-dispatch.js +38 -0
- package/lib/webhook.d.ts +20 -0
- package/lib/webhook.js +152 -0
- package/lib/wechat-mp-endpoint-commands.d.ts +1 -0
- package/lib/wechat-mp-endpoint-commands.js +19 -0
- package/lib/wechat-mp-runtime-state.d.ts +1 -0
- package/lib/wechat-mp-runtime-state.js +6 -0
- package/package.json +53 -12
- package/plugin.js +14 -0
- package/schema.json +116 -0
- package/src/client.ts +121 -0
- package/src/endpoint.ts +313 -0
- package/src/index.ts +56 -35
- package/src/media-upload.ts +82 -0
- package/src/protocol.ts +507 -0
- package/src/side-event-dispatch.ts +45 -0
- package/src/webhook.ts +237 -0
- package/src/wechat-mp-endpoint-commands.ts +20 -0
- package/src/wechat-mp-runtime-state.ts +7 -0
- package/lib/adapter.d.ts +0 -13
- package/lib/adapter.d.ts.map +0 -1
- package/lib/adapter.js +0 -16
- package/lib/adapter.js.map +0 -1
- package/lib/bot.d.ts +0 -73
- package/lib/bot.d.ts.map +0 -1
- package/lib/bot.js +0 -783
- package/lib/bot.js.map +0 -1
- package/lib/index.d.ts.map +0 -1
- package/lib/index.js.map +0 -1
- package/lib/passive-reply.d.ts.map +0 -1
- package/lib/passive-reply.js.map +0 -1
- package/lib/types.d.ts +0 -58
- package/lib/types.d.ts.map +0 -1
- package/lib/types.js +0 -2
- package/lib/types.js.map +0 -1
- package/src/adapter.ts +0 -20
- package/src/bot.ts +0 -934
- package/src/types.ts +0 -60
- /package/{skills/wechat-mp/SKILL.md → agent/skills/wechat-mp.md} +0 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convention entry: discover `adapters/wechat-mp.ts` → defineAdapter.
|
|
3
|
+
*/
|
|
4
|
+
import { defineAdapter } from 'zhin.js/adapter';
|
|
5
|
+
import { httpHostToken } from '@zhin.js/host-http';
|
|
6
|
+
import { WeChatMpEndpoint } from '../src/endpoint.js';
|
|
7
|
+
import {
|
|
8
|
+
resolveWeChatMpConfig,
|
|
9
|
+
type WeChatMpAdapterConfig,
|
|
10
|
+
} from '../src/protocol.js';
|
|
11
|
+
import { wechatMpRuntimeStateToken } from '../src/wechat-mp-runtime-state.js';
|
|
12
|
+
|
|
13
|
+
export { WeChatMpEndpoint } from '../src/endpoint.js';
|
|
14
|
+
export type { WeChatMpEndpointOptions } from '../src/endpoint.js';
|
|
15
|
+
export type { WeChatMpFetch } from '../src/client.js';
|
|
16
|
+
|
|
17
|
+
export default defineAdapter<WeChatMpAdapterConfig>({
|
|
18
|
+
capabilities: ['inbound', 'outbound'],
|
|
19
|
+
// 客服消息媒体统一经 /cgi-bin/media/upload 物化为 media_id(url 下载后上传,
|
|
20
|
+
// kind=file 的 MediaRef 视为既有 media_id 直传);公众号无卡片交互面,交互段降级纯文本。
|
|
21
|
+
segments: {
|
|
22
|
+
outboundMedia: ['upload'],
|
|
23
|
+
interactive: 'text',
|
|
24
|
+
},
|
|
25
|
+
create(context) {
|
|
26
|
+
const config = resolveWeChatMpConfig(context.config);
|
|
27
|
+
// 注册到插件运行时状态(wechat-mp.endpoint list 的"运行中"数据源)
|
|
28
|
+
context.use(wechatMpRuntimeStateToken).endpoints.set(config.id, {
|
|
29
|
+
id: config.id,
|
|
30
|
+
mode: 'webhook',
|
|
31
|
+
});
|
|
32
|
+
return new WeChatMpEndpoint({
|
|
33
|
+
id: context.id,
|
|
34
|
+
http: context.use(httpHostToken),
|
|
35
|
+
config,
|
|
36
|
+
});
|
|
37
|
+
},
|
|
38
|
+
});
|
package/lib/client.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ResolvedWeChatMpConfig, WeChatAPIResponse } from './protocol.js';
|
|
2
|
+
export type WeChatMpFetch = (url: string, init?: {
|
|
3
|
+
readonly method?: string;
|
|
4
|
+
readonly body?: unknown;
|
|
5
|
+
readonly headers?: Record<string, string>;
|
|
6
|
+
}) => Promise<{
|
|
7
|
+
readonly data: unknown;
|
|
8
|
+
}>;
|
|
9
|
+
/** Direct WeChat Official Account API client exposed to plugins. */
|
|
10
|
+
export declare class WeChatMpClient {
|
|
11
|
+
#private;
|
|
12
|
+
readonly config: ResolvedWeChatMpConfig;
|
|
13
|
+
readonly fetch: WeChatMpFetch;
|
|
14
|
+
constructor(config: ResolvedWeChatMpConfig, fetch: WeChatMpFetch);
|
|
15
|
+
get accessToken(): string | null;
|
|
16
|
+
get tokenExpired(): boolean;
|
|
17
|
+
refreshAccessToken(): Promise<string>;
|
|
18
|
+
request<T = unknown>(path: string, init?: Parameters<WeChatMpFetch>[1]): Promise<T>;
|
|
19
|
+
sendCustomerService(messageData: unknown): Promise<WeChatAPIResponse>;
|
|
20
|
+
uploadMedia(type: 'image' | 'voice' | 'video', body: unknown): Promise<string>;
|
|
21
|
+
getFollowerIds(): Promise<readonly string[]>;
|
|
22
|
+
}
|
|
23
|
+
export type WeChatMpClientEventMap = Record<string, unknown>;
|
|
24
|
+
declare module '@zhin.js/feature-kit' {
|
|
25
|
+
interface AdapterClientRegistry {
|
|
26
|
+
readonly 'wechat-mp': {
|
|
27
|
+
readonly client: WeChatMpClient;
|
|
28
|
+
readonly events: WeChatMpClientEventMap;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export declare const wechatMpClient: import("@zhin.js/adapter").EndpointClientToken<WeChatMpClient, WeChatMpClientEventMap>;
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { defineEndpointClient } from 'zhin.js/adapter';
|
|
2
|
+
const TOKEN_INVALID_ERRCODES = new Set([40001, 40014, 42001]);
|
|
3
|
+
/** Direct WeChat Official Account API client exposed to plugins. */
|
|
4
|
+
export class WeChatMpClient {
|
|
5
|
+
config;
|
|
6
|
+
fetch;
|
|
7
|
+
#accessToken = null;
|
|
8
|
+
#tokenExpireTime = 0;
|
|
9
|
+
constructor(config, fetch) {
|
|
10
|
+
this.config = config;
|
|
11
|
+
this.fetch = fetch;
|
|
12
|
+
}
|
|
13
|
+
get accessToken() {
|
|
14
|
+
return this.#accessToken;
|
|
15
|
+
}
|
|
16
|
+
get tokenExpired() {
|
|
17
|
+
return !this.#accessToken || Date.now() >= this.#tokenExpireTime;
|
|
18
|
+
}
|
|
19
|
+
async refreshAccessToken() {
|
|
20
|
+
const { appId, appSecret } = this.config;
|
|
21
|
+
const url = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${appId}&secret=${appSecret}`;
|
|
22
|
+
const response = await this.fetch(url);
|
|
23
|
+
const data = response.data;
|
|
24
|
+
if (!data.access_token) {
|
|
25
|
+
throw new Error(data.errmsg
|
|
26
|
+
? `Failed to get access token: ${data.errcode} ${data.errmsg}`
|
|
27
|
+
: 'Failed to get access token');
|
|
28
|
+
}
|
|
29
|
+
this.#accessToken = data.access_token;
|
|
30
|
+
this.#tokenExpireTime = Date.now() + (data.expires_in - 300) * 1000;
|
|
31
|
+
return data.access_token;
|
|
32
|
+
}
|
|
33
|
+
async request(path, init) {
|
|
34
|
+
if (this.tokenExpired)
|
|
35
|
+
await this.refreshAccessToken();
|
|
36
|
+
const separator = path.includes('?') ? '&' : '?';
|
|
37
|
+
const response = await this.fetch(`https://api.weixin.qq.com${path}${separator}access_token=${this.#accessToken}`, init);
|
|
38
|
+
return response.data;
|
|
39
|
+
}
|
|
40
|
+
async sendCustomerService(messageData) {
|
|
41
|
+
let result = await this.request('/cgi-bin/message/custom/send', { method: 'POST', body: messageData });
|
|
42
|
+
if (result.errcode && TOKEN_INVALID_ERRCODES.has(Number(result.errcode))) {
|
|
43
|
+
await this.refreshAccessToken();
|
|
44
|
+
result = await this.request('/cgi-bin/message/custom/send', { method: 'POST', body: messageData });
|
|
45
|
+
}
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
async uploadMedia(type, body) {
|
|
49
|
+
const data = await this.request(`/cgi-bin/media/upload?type=${type}`, { method: 'POST', body });
|
|
50
|
+
if (data.media_id)
|
|
51
|
+
return data.media_id;
|
|
52
|
+
throw new Error(`WeChat media upload failed: ${data.errcode ?? 'unknown'} ${data.errmsg ?? ''}`.trim());
|
|
53
|
+
}
|
|
54
|
+
async getFollowerIds() {
|
|
55
|
+
const followers = [];
|
|
56
|
+
let nextOpenid = '';
|
|
57
|
+
do {
|
|
58
|
+
const data = await this.request(`/cgi-bin/user/get?next_openid=${encodeURIComponent(nextOpenid)}`);
|
|
59
|
+
if (data.errcode && data.errcode !== 0) {
|
|
60
|
+
throw new Error(`WeChat API error: ${data.errcode} - ${data.errmsg}`);
|
|
61
|
+
}
|
|
62
|
+
followers.push(...(data.data?.openid ?? []).filter(Boolean));
|
|
63
|
+
if (Number(data.count ?? 0) < 10_000)
|
|
64
|
+
break;
|
|
65
|
+
nextOpenid = data.next_openid ?? '';
|
|
66
|
+
} while (nextOpenid);
|
|
67
|
+
return Object.freeze(followers);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
export const wechatMpClient = defineEndpointClient('wechat-mp');
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Endpoint } from 'zhin.js/adapter';
|
|
2
|
+
import type { EndpointManagement, EndpointSendRequest } from 'zhin.js/adapter';
|
|
3
|
+
import type { HttpHost } from '@zhin.js/host-http';
|
|
4
|
+
import type { CapabilityId } from 'zhin.js';
|
|
5
|
+
import { type ResolvedWeChatMpConfig, type WeChatMessage } from './protocol.js';
|
|
6
|
+
import { WeChatMpClient, type WeChatMpFetch } from './client.js';
|
|
7
|
+
export interface WeChatMpEndpointOptions {
|
|
8
|
+
readonly id: CapabilityId;
|
|
9
|
+
readonly http: HttpHost;
|
|
10
|
+
readonly config: ResolvedWeChatMpConfig;
|
|
11
|
+
readonly fetch?: WeChatMpFetch;
|
|
12
|
+
}
|
|
13
|
+
export declare class WeChatMpEndpoint extends Endpoint<WeChatMpClient> {
|
|
14
|
+
#private;
|
|
15
|
+
readonly client: WeChatMpClient;
|
|
16
|
+
readonly management: EndpointManagement;
|
|
17
|
+
constructor(options: WeChatMpEndpointOptions);
|
|
18
|
+
/** Used by webhook handler. */
|
|
19
|
+
get isOpen(): boolean;
|
|
20
|
+
get config(): ResolvedWeChatMpConfig;
|
|
21
|
+
get id(): CapabilityId;
|
|
22
|
+
/** 微信 5s 重推去重:见过该 MsgId 时返回首次回复 XML(含空串=success)。 */
|
|
23
|
+
getCachedReply(msgId: string): string | undefined;
|
|
24
|
+
cacheReply(msgId: string, replyXML: string): void;
|
|
25
|
+
start(): Promise<void>;
|
|
26
|
+
open(): void;
|
|
27
|
+
close(): void;
|
|
28
|
+
stop(): Promise<void>;
|
|
29
|
+
send({ conversation, payload }: EndpointSendRequest): Promise<string>;
|
|
30
|
+
/** Test / internal: admit a parsed message when open (non-webhook path). */
|
|
31
|
+
admit(msg: WeChatMessage): void | Promise<unknown>;
|
|
32
|
+
}
|
package/lib/endpoint.js
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { Endpoint } from 'zhin.js/adapter';
|
|
2
|
+
/**
|
|
3
|
+
* WeChatMpEndpoint — lifecycle, outbound, admit, access token refresh.
|
|
4
|
+
*/
|
|
5
|
+
import axios from 'axios';
|
|
6
|
+
import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
|
|
7
|
+
import { extractOutboundText, formatCustomerServiceBody, formatInboundContent, formatInboundId, wechatMpInboundConversation, } from './protocol.js';
|
|
8
|
+
import { buildMediaUploadForm, readOutboundMedia, resolveMediaBinary, } from './media-upload.js';
|
|
9
|
+
import { getPassiveReplyCapture, recordPassiveReplyText, } from './passive-reply.js';
|
|
10
|
+
import { registerWeChatMpWebhookRoutes } from './webhook.js';
|
|
11
|
+
import { receiveWeChatMpSideEvent } from './side-event-dispatch.js';
|
|
12
|
+
import { WeChatMpClient } from './client.js';
|
|
13
|
+
/**
|
|
14
|
+
* canonical 媒体段类型 → 微信 /cgi-bin/media/upload 的 type。
|
|
15
|
+
* 客服消息无 file 投递面,file 段不可投递。
|
|
16
|
+
*/
|
|
17
|
+
const WECHAT_UPLOAD_TYPE = {
|
|
18
|
+
image: 'image',
|
|
19
|
+
audio: 'voice',
|
|
20
|
+
voice: 'voice',
|
|
21
|
+
video: 'video',
|
|
22
|
+
};
|
|
23
|
+
function defaultFetch(url, init) {
|
|
24
|
+
return axios({
|
|
25
|
+
url,
|
|
26
|
+
method: (init?.method ?? 'GET'),
|
|
27
|
+
data: init?.body,
|
|
28
|
+
headers: init?.headers,
|
|
29
|
+
}).then((response) => ({ data: response.data }));
|
|
30
|
+
}
|
|
31
|
+
export class WeChatMpEndpoint extends Endpoint {
|
|
32
|
+
client;
|
|
33
|
+
#logger;
|
|
34
|
+
#options;
|
|
35
|
+
#fetch;
|
|
36
|
+
#routeReleases = [];
|
|
37
|
+
#tokenRefreshTimer;
|
|
38
|
+
/** MsgId → 首次回复 XML(微信 5s 重推去重,有界 LRU)。 */
|
|
39
|
+
#replyCache = new Map();
|
|
40
|
+
static #REPLY_CACHE_LIMIT = 1000;
|
|
41
|
+
#open = false;
|
|
42
|
+
#started = false;
|
|
43
|
+
management = createWeChatMpEndpointManagement(() => this.client);
|
|
44
|
+
constructor(options) {
|
|
45
|
+
super();
|
|
46
|
+
this.#logger = getAdapterLogger('wechat-mp', options.config.id);
|
|
47
|
+
this.#options = options;
|
|
48
|
+
this.#fetch = options.fetch ?? defaultFetch;
|
|
49
|
+
this.client = new WeChatMpClient(options.config, this.#fetch);
|
|
50
|
+
}
|
|
51
|
+
/** Used by webhook handler. */
|
|
52
|
+
get isOpen() {
|
|
53
|
+
return this.#open;
|
|
54
|
+
}
|
|
55
|
+
get config() {
|
|
56
|
+
return this.#options.config;
|
|
57
|
+
}
|
|
58
|
+
get id() {
|
|
59
|
+
return this.#options.id;
|
|
60
|
+
}
|
|
61
|
+
/** 微信 5s 重推去重:见过该 MsgId 时返回首次回复 XML(含空串=success)。 */
|
|
62
|
+
getCachedReply(msgId) {
|
|
63
|
+
return this.#replyCache.get(msgId);
|
|
64
|
+
}
|
|
65
|
+
cacheReply(msgId, replyXML) {
|
|
66
|
+
if (this.#replyCache.has(msgId))
|
|
67
|
+
this.#replyCache.delete(msgId);
|
|
68
|
+
this.#replyCache.set(msgId, replyXML);
|
|
69
|
+
while (this.#replyCache.size > WeChatMpEndpoint.#REPLY_CACHE_LIMIT) {
|
|
70
|
+
const oldest = this.#replyCache.keys().next().value;
|
|
71
|
+
if (oldest === undefined)
|
|
72
|
+
break;
|
|
73
|
+
this.#replyCache.delete(oldest);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async start() {
|
|
77
|
+
if (this.#started)
|
|
78
|
+
return;
|
|
79
|
+
this.#started = true;
|
|
80
|
+
try {
|
|
81
|
+
await this.client.refreshAccessToken();
|
|
82
|
+
this.#routeReleases.push(...registerWeChatMpWebhookRoutes(this.#options.http, this));
|
|
83
|
+
this.#startTokenRefreshTimer();
|
|
84
|
+
this.#logger.debug(formatCompact({
|
|
85
|
+
endpoint: this.#options.config.id,
|
|
86
|
+
op: 'webhook',
|
|
87
|
+
path: this.#options.config.path,
|
|
88
|
+
}));
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
await this.stop();
|
|
92
|
+
this.#logger.error('Failed to connect WeChat MP bot:', error);
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
open() {
|
|
97
|
+
this.#open = true;
|
|
98
|
+
}
|
|
99
|
+
close() {
|
|
100
|
+
this.#open = false;
|
|
101
|
+
}
|
|
102
|
+
async stop() {
|
|
103
|
+
this.#open = false;
|
|
104
|
+
if (this.#tokenRefreshTimer) {
|
|
105
|
+
clearInterval(this.#tokenRefreshTimer);
|
|
106
|
+
this.#tokenRefreshTimer = undefined;
|
|
107
|
+
}
|
|
108
|
+
for (const release of this.#routeReleases.splice(0))
|
|
109
|
+
release();
|
|
110
|
+
this.#started = false;
|
|
111
|
+
this.#logger.debug(formatCompact({ op: 'disconnect' }));
|
|
112
|
+
}
|
|
113
|
+
async send({ conversation, payload }) {
|
|
114
|
+
if (getPassiveReplyCapture()) {
|
|
115
|
+
const text = extractOutboundText(payload);
|
|
116
|
+
recordPassiveReplyText(text);
|
|
117
|
+
return `passive_${Date.now()}`;
|
|
118
|
+
}
|
|
119
|
+
if (this.#options.config.replyMode === 'customer_service') {
|
|
120
|
+
return this.#sendCustomerService(conversation.id, payload);
|
|
121
|
+
}
|
|
122
|
+
this.#logger.warn(formatCompact({
|
|
123
|
+
op: 'send',
|
|
124
|
+
skip: 'passive_outside_webhook',
|
|
125
|
+
endpoint: this.#options.config.id,
|
|
126
|
+
target: `${conversation.kind}:${conversation.id}`,
|
|
127
|
+
}));
|
|
128
|
+
return `passive_skipped_${Date.now()}`;
|
|
129
|
+
}
|
|
130
|
+
/** Test / internal: admit a parsed message when open (non-webhook path). */
|
|
131
|
+
admit(msg) {
|
|
132
|
+
if (!this.#open)
|
|
133
|
+
return;
|
|
134
|
+
void this.emitPlatform(msg.Event ? `${msg.MsgType}.${msg.Event}` : msg.MsgType, msg).catch((error) => {
|
|
135
|
+
this.#logger.warn(formatCompact({
|
|
136
|
+
op: 'wechat_mp_platform_event_failed',
|
|
137
|
+
error: error instanceof Error ? error.message : String(error),
|
|
138
|
+
}));
|
|
139
|
+
});
|
|
140
|
+
if (receiveWeChatMpSideEvent((name, payload) => this.emit(name, payload), this.#options.config.id, msg, this.#logger)) {
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
const conversation = wechatMpInboundConversation(String(this.#options.id), msg);
|
|
144
|
+
return this.emit('message.receive', {
|
|
145
|
+
conversation,
|
|
146
|
+
message: { conversation, id: formatInboundId(msg) },
|
|
147
|
+
content: formatInboundContent(msg),
|
|
148
|
+
sender: { id: msg.FromUserName },
|
|
149
|
+
endpointId: this.#options.config.id,
|
|
150
|
+
metadata: Object.freeze({
|
|
151
|
+
msgType: msg.MsgType,
|
|
152
|
+
event: msg.Event,
|
|
153
|
+
toUserName: msg.ToUserName,
|
|
154
|
+
}),
|
|
155
|
+
}).catch((err) => {
|
|
156
|
+
this.#logger.warn(formatCompact({
|
|
157
|
+
op: 'wechat_mp_gateway_receive_failed',
|
|
158
|
+
target: `${conversation.kind}:${conversation.id}`,
|
|
159
|
+
error: err instanceof Error ? err.message : String(err),
|
|
160
|
+
}));
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
async #sendCustomerService(target, payload) {
|
|
164
|
+
// 发送前检查过期(不只判 null):过期 token 直接刷新,不白跑一次 40001。
|
|
165
|
+
const materialized = await this.#materializeOutboundMedia(payload);
|
|
166
|
+
const messageData = formatCustomerServiceBody(target, materialized);
|
|
167
|
+
const result = await this.client.sendCustomerService(messageData);
|
|
168
|
+
if (result.errcode && result.errcode !== 0) {
|
|
169
|
+
throw new Error(`WeChat API error: ${result.errcode} - ${result.errmsg}`);
|
|
170
|
+
}
|
|
171
|
+
this.#logger.debug(formatCompact({ op: 'wechat_mp_send', target, messageId: result.msgid }));
|
|
172
|
+
return result.msgid?.toString() || `cs_${Date.now()}`;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* 客服消息媒体段只接受 media_id:canonical MediaRef 是唯一来源。
|
|
176
|
+
* - kind=file(平台不透明引用,即既有 media_id)→ 直接透传;
|
|
177
|
+
* - kind=base64 / path / url → 经 /cgi-bin/media/upload 物化;
|
|
178
|
+
* - 无 MediaRef / 类型不可投递(file 段)→ warn + 丢弃;
|
|
179
|
+
* - 上传失败降级为文本(alt 优先),不阻断发送。
|
|
180
|
+
*/
|
|
181
|
+
async #materializeOutboundMedia(payload) {
|
|
182
|
+
if (!Array.isArray(payload))
|
|
183
|
+
return payload;
|
|
184
|
+
const materialized = await Promise.all(payload.map(async (item) => {
|
|
185
|
+
if (typeof item === 'string' || !item || typeof item !== 'object')
|
|
186
|
+
return item;
|
|
187
|
+
const seg = item;
|
|
188
|
+
if (typeof seg.type !== 'string')
|
|
189
|
+
return item;
|
|
190
|
+
const data = seg.data ?? {};
|
|
191
|
+
const uploadType = WECHAT_UPLOAD_TYPE[seg.type];
|
|
192
|
+
const isMediaSegment = uploadType != null || seg.type === 'file';
|
|
193
|
+
if (!isMediaSegment)
|
|
194
|
+
return item;
|
|
195
|
+
const media = readOutboundMedia(data);
|
|
196
|
+
if (!media) {
|
|
197
|
+
// 已物化(mediaId/media_id)的段透传;其余无 canonical 媒体引用,丢弃留痕
|
|
198
|
+
if (typeof data.mediaId === 'string' && data.mediaId)
|
|
199
|
+
return item;
|
|
200
|
+
if (typeof data.media_id === 'string' && data.media_id)
|
|
201
|
+
return item;
|
|
202
|
+
this.#logger.warn(formatCompact({
|
|
203
|
+
op: 'wechat_mp_outbound_media_dropped',
|
|
204
|
+
endpoint: this.#options.config.id,
|
|
205
|
+
type: seg.type,
|
|
206
|
+
reason: 'missing_media_ref',
|
|
207
|
+
}));
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
if (media.kind === 'file') {
|
|
211
|
+
// 平台不透明引用:value 即 media_id,直接透传不上传
|
|
212
|
+
return { type: seg.type, data: { mediaId: media.value } };
|
|
213
|
+
}
|
|
214
|
+
if (!uploadType) {
|
|
215
|
+
this.#logger.warn(formatCompact({
|
|
216
|
+
op: 'wechat_mp_outbound_media_dropped',
|
|
217
|
+
endpoint: this.#options.config.id,
|
|
218
|
+
type: seg.type,
|
|
219
|
+
reason: 'unsupported_segment_type',
|
|
220
|
+
}));
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
const mediaId = await this.#uploadMedia(uploadType, media);
|
|
225
|
+
return { type: seg.type, data: { mediaId } };
|
|
226
|
+
}
|
|
227
|
+
catch (error) {
|
|
228
|
+
this.#logger.warn(formatCompact({
|
|
229
|
+
op: 'wechat_mp_media_upload_failed',
|
|
230
|
+
endpoint: this.#options.config.id,
|
|
231
|
+
error: error instanceof Error ? error.message : String(error),
|
|
232
|
+
}));
|
|
233
|
+
const alt = typeof data.alt === 'string' && data.alt ? data.alt : `[${seg.type}]`;
|
|
234
|
+
return { type: 'text', data: { text: alt } };
|
|
235
|
+
}
|
|
236
|
+
}));
|
|
237
|
+
return materialized.filter((item) => item != null);
|
|
238
|
+
}
|
|
239
|
+
/** POST /cgi-bin/media/upload(临时素材,3 天有效),返回 media_id。 */
|
|
240
|
+
async #uploadMedia(type, media) {
|
|
241
|
+
const binary = await resolveMediaBinary(media);
|
|
242
|
+
const form = buildMediaUploadForm(binary);
|
|
243
|
+
return this.client.uploadMedia(type, form);
|
|
244
|
+
}
|
|
245
|
+
#startTokenRefreshTimer() {
|
|
246
|
+
this.#tokenRefreshTimer = setInterval(() => {
|
|
247
|
+
if (this.client.tokenExpired) {
|
|
248
|
+
void this.client.refreshAccessToken().catch((error) => {
|
|
249
|
+
this.#logger.error('Failed to refresh access token in timer:', error);
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
}, 3_600_000);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
function createWeChatMpEndpointManagement(requireClient) {
|
|
256
|
+
return Object.freeze({
|
|
257
|
+
// 公众号无群/频道概念;关注者即"好友"(nickname 为 openid 占位,见 getFollowers)。
|
|
258
|
+
listFriends: async () => (await requireClient().getFollowerIds()).map((openid) => ({
|
|
259
|
+
user_id: openid,
|
|
260
|
+
nickname: openid,
|
|
261
|
+
remark: '',
|
|
262
|
+
})),
|
|
263
|
+
});
|
|
264
|
+
}
|
package/lib/index.d.ts
CHANGED
|
@@ -1,15 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
}
|
|
8
|
-
interface Adapters {
|
|
9
|
-
"wechat-mp": WeChatMPAdapter;
|
|
10
|
-
}
|
|
11
|
-
}
|
|
12
|
-
export * from "./types.js";
|
|
13
|
-
export { WeChatMPBot } from "./bot.js";
|
|
14
|
-
export { WeChatMPAdapter } from "./adapter.js";
|
|
15
|
-
//# sourceMappingURL=index.d.ts.map
|
|
1
|
+
export { buildTextReply, computeSignatureHash, decryptEchostr, decryptMessage, encryptMessage, extractOutboundText, formatCustomerServiceBody, formatInboundContent, formatInboundId, isEncryptedEchostr, normalizeEchostrParam, parseXMLMessage, queryParam, readTextBody, resolveEventPassiveReply, resolveWeChatMpConfig, verifySignature, type ResolvedWeChatMpConfig, type TokenResponse, type WeChatAPIResponse, type WeChatMessage, type WeChatMpAdapterConfig, type WeChatWireSegment, } from './protocol.js';
|
|
2
|
+
export { getPassiveReplyCapture, recordPassiveReplyText, runWithPassiveReplyCapture, type PassiveReplyCapture, } from './passive-reply.js';
|
|
3
|
+
export { WeChatMpClient, wechatMpClient, type WeChatMpClientEventMap, type WeChatMpFetch, } from './client.js';
|
|
4
|
+
export { WeChatMpEndpoint, type WeChatMpEndpointOptions, } from './endpoint.js';
|
|
5
|
+
export { buildMediaUploadForm, readOutboundMedia, resolveMediaBinary, type MediaBinary, type WeChatMediaUploadResult, } from './media-upload.js';
|
|
6
|
+
export { registerWeChatMpWebhookRoutes, handleWeChatMpVerification, handleWeChatMpMessage, collectPassiveReply, type WeChatMpWebhookHandler, } from './webhook.js';
|
package/lib/index.js
CHANGED
|
@@ -1,25 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
export
|
|
7
|
-
export { WeChatMPBot } from "./bot.js";
|
|
8
|
-
export { WeChatMPAdapter } from "./adapter.js";
|
|
9
|
-
const plugin = usePlugin();
|
|
10
|
-
const { provide, useContext } = plugin;
|
|
11
|
-
useContext("router", (router) => {
|
|
12
|
-
provide({
|
|
13
|
-
name: "wechat-mp",
|
|
14
|
-
description: "WeChat MP Bot Adapter",
|
|
15
|
-
mounted: async (p) => {
|
|
16
|
-
const adapter = new WeChatMPAdapter(p, router);
|
|
17
|
-
await adapter.start();
|
|
18
|
-
return adapter;
|
|
19
|
-
},
|
|
20
|
-
dispose: async (adapter) => {
|
|
21
|
-
await adapter.stop();
|
|
22
|
-
},
|
|
23
|
-
});
|
|
24
|
-
});
|
|
25
|
-
//# sourceMappingURL=index.js.map
|
|
1
|
+
export { buildTextReply, computeSignatureHash, decryptEchostr, decryptMessage, encryptMessage, extractOutboundText, formatCustomerServiceBody, formatInboundContent, formatInboundId, isEncryptedEchostr, normalizeEchostrParam, parseXMLMessage, queryParam, readTextBody, resolveEventPassiveReply, resolveWeChatMpConfig, verifySignature, } from './protocol.js';
|
|
2
|
+
export { getPassiveReplyCapture, recordPassiveReplyText, runWithPassiveReplyCapture, } from './passive-reply.js';
|
|
3
|
+
export { WeChatMpClient, wechatMpClient, } from './client.js';
|
|
4
|
+
export { WeChatMpEndpoint, } from './endpoint.js';
|
|
5
|
+
export { buildMediaUploadForm, readOutboundMedia, resolveMediaBinary, } from './media-upload.js';
|
|
6
|
+
export { registerWeChatMpWebhookRoutes, handleWeChatMpVerification, handleWeChatMpMessage, collectPassiveReply, } from './webhook.js';
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type MediaRef } from '@zhin.js/core';
|
|
2
|
+
export interface MediaBinary {
|
|
3
|
+
readonly data: Buffer;
|
|
4
|
+
readonly mimeType: string;
|
|
5
|
+
readonly fileName: string;
|
|
6
|
+
}
|
|
7
|
+
export interface WeChatMediaUploadResult {
|
|
8
|
+
readonly type?: string;
|
|
9
|
+
readonly media_id?: string;
|
|
10
|
+
readonly created_at?: number;
|
|
11
|
+
readonly errcode?: number;
|
|
12
|
+
readonly errmsg?: string;
|
|
13
|
+
}
|
|
14
|
+
/** 从 canonical MediaRef 解析二进制:base64 解码 / 本地读盘 / URL 下载。 */
|
|
15
|
+
export declare function resolveMediaBinary(media: MediaRef, download?: (url: string) => Promise<Buffer>): Promise<MediaBinary>;
|
|
16
|
+
/** 临时素材上传的 multipart body(字段名固定为 `media`)。 */
|
|
17
|
+
export declare function buildMediaUploadForm(binary: MediaBinary): FormData;
|
|
18
|
+
/**
|
|
19
|
+
* 出站媒体段的媒体引用:已有 media_id 的视为已物化(返回 undefined 透传);
|
|
20
|
+
* 否则只读 canonical `data.media`(MediaRef-only,无 legacy 字段回退)。
|
|
21
|
+
*/
|
|
22
|
+
export declare function readOutboundMedia(data: Record<string, unknown>): MediaRef | undefined;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WeChat MP 临时素材上传(客服消息 image 段需要 media_id)。
|
|
3
|
+
* 接口:POST https://api.weixin.qq.com/cgi-bin/media/upload?access_token=…&type=image
|
|
4
|
+
* 与客服消息同属公众号基础接口域,不引入额外授权域
|
|
5
|
+
* (客服消息本身要求已认证服务号;订阅号无客服消息权限,上传同样不可用)。
|
|
6
|
+
*/
|
|
7
|
+
import { readFile } from 'node:fs/promises';
|
|
8
|
+
import { basename } from 'node:path';
|
|
9
|
+
import { isMediaRef } from '@zhin.js/core';
|
|
10
|
+
const MIME_EXT = {
|
|
11
|
+
'image/jpeg': 'jpg',
|
|
12
|
+
'image/png': 'png',
|
|
13
|
+
'image/gif': 'gif',
|
|
14
|
+
'image/webp': 'webp',
|
|
15
|
+
'image/bmp': 'bmp',
|
|
16
|
+
};
|
|
17
|
+
/** 从 canonical MediaRef 解析二进制:base64 解码 / 本地读盘 / URL 下载。 */
|
|
18
|
+
export async function resolveMediaBinary(media, download = defaultDownload) {
|
|
19
|
+
const mimeType = media.mime_type ?? 'image/png';
|
|
20
|
+
const ext = MIME_EXT[mimeType] ?? 'png';
|
|
21
|
+
if (media.kind === 'base64') {
|
|
22
|
+
const value = media.value.startsWith('base64://')
|
|
23
|
+
? media.value.slice('base64://'.length)
|
|
24
|
+
: media.value;
|
|
25
|
+
return { data: Buffer.from(value, 'base64'), mimeType, fileName: `image.${ext}` };
|
|
26
|
+
}
|
|
27
|
+
if (media.kind === 'path') {
|
|
28
|
+
const path = media.value.startsWith('file://') ? media.value.slice('file://'.length) : media.value;
|
|
29
|
+
return { data: await readFile(path), mimeType, fileName: basename(path) };
|
|
30
|
+
}
|
|
31
|
+
if (media.kind === 'file') {
|
|
32
|
+
// 平台不透明引用(media_id):由调用方直接透传,不走上传。
|
|
33
|
+
throw new Error('MediaRef kind=file is a platform opaque reference; binary resolution not applicable');
|
|
34
|
+
}
|
|
35
|
+
return { data: await download(media.value), mimeType, fileName: `image.${ext}` };
|
|
36
|
+
}
|
|
37
|
+
async function defaultDownload(url) {
|
|
38
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(30_000) });
|
|
39
|
+
if (!response.ok)
|
|
40
|
+
throw new Error(`download failed: HTTP ${response.status}`);
|
|
41
|
+
return Buffer.from(await response.arrayBuffer());
|
|
42
|
+
}
|
|
43
|
+
/** 临时素材上传的 multipart body(字段名固定为 `media`)。 */
|
|
44
|
+
export function buildMediaUploadForm(binary) {
|
|
45
|
+
// Buffer 的 ArrayBufferLike 不满足 BlobPart(SharedArrayBuffer 分支),拷贝为 Uint8Array<ArrayBuffer>
|
|
46
|
+
const bytes = new Uint8Array(binary.data.byteLength);
|
|
47
|
+
bytes.set(binary.data);
|
|
48
|
+
const form = new FormData();
|
|
49
|
+
form.append('media', new Blob([bytes], { type: binary.mimeType }), binary.fileName);
|
|
50
|
+
return form;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* 出站媒体段的媒体引用:已有 media_id 的视为已物化(返回 undefined 透传);
|
|
54
|
+
* 否则只读 canonical `data.media`(MediaRef-only,无 legacy 字段回退)。
|
|
55
|
+
*/
|
|
56
|
+
export function readOutboundMedia(data) {
|
|
57
|
+
if (typeof data.mediaId === 'string' && data.mediaId)
|
|
58
|
+
return undefined;
|
|
59
|
+
if (typeof data.media_id === 'string' && data.media_id)
|
|
60
|
+
return undefined;
|
|
61
|
+
if (isMediaRef(data.media))
|
|
62
|
+
return data.media;
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
package/lib/passive-reply.d.ts
CHANGED
|
@@ -5,4 +5,3 @@ export type PassiveReplyCapture = {
|
|
|
5
5
|
export declare function getPassiveReplyCapture(): PassiveReplyCapture | undefined;
|
|
6
6
|
export declare function runWithPassiveReplyCapture<T>(fn: () => Promise<T>): Promise<T>;
|
|
7
7
|
export declare function recordPassiveReplyText(text: string): void;
|
|
8
|
-
//# sourceMappingURL=passive-reply.d.ts.map
|
package/lib/passive-reply.js
CHANGED