@zhin.js/adapter-wechat-mp 1.1.18 → 1.1.20
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 +43 -0
- package/README.md +8 -7
- package/adapters/wechat-mp/index.js +3 -17
- package/adapters/wechat-mp/index.ts +2 -17
- package/commands/wechat-mp/endpoint/definition.js +0 -2
- package/commands/wechat-mp/endpoint/definition.ts +0 -2
- package/lib/adapter.d.ts +18 -0
- package/lib/adapter.js +16 -0
- package/lib/api-client.d.ts +34 -0
- package/lib/api-client.js +113 -0
- package/lib/client.d.ts +39 -33
- package/lib/client.js +292 -97
- package/lib/console.d.ts +1 -1
- package/lib/console.js +2 -5
- package/lib/endpoint.d.ts +11 -29
- package/lib/endpoint.js +30 -264
- package/lib/index.d.ts +2 -1
- package/lib/index.js +2 -1
- package/lib/media-upload.d.ts +5 -1
- package/lib/media-upload.js +6 -6
- package/lib/side-event-dispatch.d.ts +1 -1
- package/lib/side-event-dispatch.js +2 -2
- package/lib/webhook.d.ts +2 -2
- package/lib/webhook.js +4 -4
- package/package.json +15 -15
- package/plugin.js +0 -3
- package/src/adapter.ts +22 -0
- package/src/api-client.ts +164 -0
- package/src/client.ts +326 -134
- package/src/console.ts +2 -5
- package/src/endpoint.ts +33 -313
- package/src/index.ts +1 -1
- package/src/media-upload.ts +10 -5
- package/src/side-event-dispatch.ts +2 -1
- package/src/webhook.ts +5 -4
- package/lib/wechat-mp-runtime-state.d.ts +0 -1
- package/lib/wechat-mp-runtime-state.js +0 -6
- package/src/wechat-mp-runtime-state.ts +0 -7
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { createEndpointOperation, type EndpointManagementRequest, type EndpointOperationOptions } from 'zhin.js/adapter';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
import type {
|
|
4
|
+
ResolvedWeChatMpConfig,
|
|
5
|
+
TokenResponse,
|
|
6
|
+
WeChatAPIResponse,
|
|
7
|
+
} from './protocol.js';
|
|
8
|
+
import type { WeChatMediaUploadResult } from './media-upload.js';
|
|
9
|
+
|
|
10
|
+
const TOKEN_INVALID_ERRCODES = new Set([40001, 40014, 42001]);
|
|
11
|
+
/** 换取 token 请求的超时,防止挂起的请求让 single-flight 永久卡住。 */
|
|
12
|
+
const TOKEN_FETCH_TIMEOUT_MS = 15_000;
|
|
13
|
+
/** 官方 access_token 有效期为 7200s;对异常大的 expires_in 设上限。 */
|
|
14
|
+
const MAX_TOKEN_LIFETIME_SEC = 7200;
|
|
15
|
+
|
|
16
|
+
export type WeChatMpFetch = (
|
|
17
|
+
url: string,
|
|
18
|
+
init?: {
|
|
19
|
+
readonly method?: string;
|
|
20
|
+
readonly body?: unknown;
|
|
21
|
+
readonly headers?: Record<string, string>;
|
|
22
|
+
readonly signal?: AbortSignal;
|
|
23
|
+
},
|
|
24
|
+
) => Promise<{ readonly data: unknown }>;
|
|
25
|
+
|
|
26
|
+
/** Direct WeChat Official Account API client exposed to plugins. */
|
|
27
|
+
export class WeChatMpApiClient extends EventEmitter {
|
|
28
|
+
#accessToken: string | null = null;
|
|
29
|
+
#tokenExpireTime = 0;
|
|
30
|
+
#refreshing: Promise<string> | null = null;
|
|
31
|
+
|
|
32
|
+
constructor(
|
|
33
|
+
readonly apiConfig: ResolvedWeChatMpConfig,
|
|
34
|
+
fetch: WeChatMpFetch,
|
|
35
|
+
) {
|
|
36
|
+
super();
|
|
37
|
+
this.fetch = (url, init) => {
|
|
38
|
+
this.transportSignal?.throwIfAborted();
|
|
39
|
+
return fetch(url, { ...init, ...(this.transportSignal ? { signal: init?.signal ? AbortSignal.any([this.transportSignal, init.signal]) : this.transportSignal } : {}) });
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
readonly fetch: WeChatMpFetch;
|
|
43
|
+
protected transportSignal?: AbortSignal;
|
|
44
|
+
|
|
45
|
+
get accessToken(): string | null {
|
|
46
|
+
return this.#accessToken;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
get tokenExpired(): boolean {
|
|
50
|
+
return !this.#accessToken || Date.now() >= this.#tokenExpireTime;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 刷新 access_token。并发调用共享同一次请求:微信每次换取新 token 都会使旧 token
|
|
55
|
+
* 进入失效倒计时,且有每日调用次数上限,重复刷新会互相顶掉并消耗配额。
|
|
56
|
+
*/
|
|
57
|
+
refreshAccessToken(): Promise<string> {
|
|
58
|
+
if (!this.#refreshing) {
|
|
59
|
+
this.#refreshing = this.#fetchAccessToken().finally(() => {
|
|
60
|
+
this.#refreshing = null;
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return this.#refreshing;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async #fetchAccessToken(): Promise<string> {
|
|
67
|
+
const { appId, appSecret } = this.apiConfig;
|
|
68
|
+
const url = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${encodeURIComponent(appId)}&secret=${encodeURIComponent(appSecret)}`;
|
|
69
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
70
|
+
const response = await Promise.race([
|
|
71
|
+
this.fetch(url),
|
|
72
|
+
new Promise<never>((_, reject) => {
|
|
73
|
+
timeout = setTimeout(() => reject(new Error('Failed to get access token: request timed out')), TOKEN_FETCH_TIMEOUT_MS);
|
|
74
|
+
}),
|
|
75
|
+
]).finally(() => clearTimeout(timeout));
|
|
76
|
+
const data = response.data as TokenResponse & WeChatAPIResponse;
|
|
77
|
+
if (!data.access_token) {
|
|
78
|
+
throw new Error(data.errmsg
|
|
79
|
+
? `Failed to get access token: ${data.errcode} ${data.errmsg}`
|
|
80
|
+
: 'Failed to get access token');
|
|
81
|
+
}
|
|
82
|
+
this.#accessToken = data.access_token;
|
|
83
|
+
// expires_in 缺失/非法时 NaN 会让 tokenExpired 永远为 false;按官方默认 7200s 兜底。
|
|
84
|
+
const expiresIn = Number(data.expires_in);
|
|
85
|
+
const lifetimeSec = Number.isFinite(expiresIn) && expiresIn > 0
|
|
86
|
+
? Math.min(expiresIn, MAX_TOKEN_LIFETIME_SEC)
|
|
87
|
+
: MAX_TOKEN_LIFETIME_SEC;
|
|
88
|
+
this.#tokenExpireTime = Date.now() + (lifetimeSec - 300) * 1000;
|
|
89
|
+
return data.access_token;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async request<T = unknown>(
|
|
93
|
+
path: string,
|
|
94
|
+
init?: Parameters<WeChatMpFetch>[1],
|
|
95
|
+
): Promise<T> {
|
|
96
|
+
return (await this.#requestWithToken<T>(path, init)).data;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async #requestWithToken<T>(
|
|
100
|
+
path: string,
|
|
101
|
+
init?: Parameters<WeChatMpFetch>[1],
|
|
102
|
+
): Promise<{ readonly data: T; readonly token: string | null }> {
|
|
103
|
+
if (this.tokenExpired) await createEndpointOperation({ signal: init?.signal }).prepare(() => this.refreshAccessToken());
|
|
104
|
+
init?.signal?.throwIfAborted();
|
|
105
|
+
const token = this.#accessToken;
|
|
106
|
+
const separator = path.includes('?') ? '&' : '?';
|
|
107
|
+
const response = await this.fetch(
|
|
108
|
+
`https://api.weixin.qq.com${path}${separator}access_token=${token}`,
|
|
109
|
+
init,
|
|
110
|
+
);
|
|
111
|
+
return { data: response.data as T, token };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async sendCustomerService(messageData: unknown, request: EndpointOperationOptions = {}): Promise<WeChatAPIResponse> {
|
|
115
|
+
const first = await this.#requestWithToken<WeChatAPIResponse>(
|
|
116
|
+
'/cgi-bin/message/custom/send',
|
|
117
|
+
{ method: 'POST', body: messageData, signal: request.signal },
|
|
118
|
+
);
|
|
119
|
+
let result = first.data;
|
|
120
|
+
if (result.errcode && TOKEN_INVALID_ERRCODES.has(Number(result.errcode))) {
|
|
121
|
+
request.signal?.throwIfAborted();
|
|
122
|
+
// 失败请求使用的 token 若已被其他请求换掉,直接用新 token 重试,不再重复刷新。
|
|
123
|
+
if (this.#accessToken === first.token) await createEndpointOperation(request).prepare(() => this.refreshAccessToken());
|
|
124
|
+
request.signal?.throwIfAborted();
|
|
125
|
+
result = await this.request<WeChatAPIResponse>(
|
|
126
|
+
'/cgi-bin/message/custom/send',
|
|
127
|
+
{ method: 'POST', body: messageData, signal: request.signal },
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async uploadMedia(
|
|
134
|
+
{ type, body, signal }: WeChatMpUploadRequest,
|
|
135
|
+
): Promise<string> {
|
|
136
|
+
const data = await this.request<WeChatMediaUploadResult>(
|
|
137
|
+
`/cgi-bin/media/upload?type=${type}`,
|
|
138
|
+
{ method: 'POST', body, signal },
|
|
139
|
+
);
|
|
140
|
+
if (data.media_id) return data.media_id;
|
|
141
|
+
throw new Error(`WeChat media upload failed: ${data.errcode ?? 'unknown'} ${data.errmsg ?? ''}`.trim());
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async getFollowerIds(request: EndpointManagementRequest = {}): Promise<readonly string[]> {
|
|
145
|
+
const followers: string[] = [];
|
|
146
|
+
let nextOpenid = '';
|
|
147
|
+
do {
|
|
148
|
+
const data = await this.request<WeChatAPIResponse & {
|
|
149
|
+
count?: number;
|
|
150
|
+
data?: { openid?: string[] };
|
|
151
|
+
next_openid?: string;
|
|
152
|
+
}>(`/cgi-bin/user/get?next_openid=${encodeURIComponent(nextOpenid)}`, { signal: request.signal });
|
|
153
|
+
if (data.errcode && data.errcode !== 0) {
|
|
154
|
+
throw new Error(`WeChat API error: ${data.errcode} - ${data.errmsg}`);
|
|
155
|
+
}
|
|
156
|
+
followers.push(...(data.data?.openid ?? []).filter(Boolean));
|
|
157
|
+
if (Number(data.count ?? 0) < 10_000) break;
|
|
158
|
+
nextOpenid = data.next_openid ?? '';
|
|
159
|
+
} while (nextOpenid);
|
|
160
|
+
return Object.freeze(followers);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface WeChatMpUploadRequest extends EndpointOperationOptions { readonly type: 'image' | 'voice' | 'video'; readonly body: unknown; }
|
package/src/client.ts
CHANGED
|
@@ -1,160 +1,352 @@
|
|
|
1
|
-
import type
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
import { WeChatMpApiClient, type WeChatMpFetch } from './api-client.js';
|
|
2
|
+
/**
|
|
3
|
+
* WeChatMpClient — lifecycle, outbound, admit, access token refresh.
|
|
4
|
+
*/
|
|
5
|
+
import axios from 'axios';
|
|
6
|
+
import {
|
|
7
|
+
createEndpointOperation,
|
|
8
|
+
type EndpointOperationOptions,
|
|
9
|
+
type EndpointManagement,
|
|
10
|
+
type EndpointSendRequest,
|
|
11
|
+
type EndpointManagementRequest,
|
|
12
|
+
} from 'zhin.js/adapter';
|
|
13
|
+
import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
|
|
14
|
+
import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
|
|
15
|
+
import type { CapabilityId } from 'zhin.js';
|
|
16
|
+
import {
|
|
17
|
+
extractOutboundText,
|
|
18
|
+
formatCustomerServiceBody,
|
|
19
|
+
formatInboundContent,
|
|
20
|
+
formatInboundId,
|
|
21
|
+
wechatMpInboundConversation,
|
|
22
|
+
type ResolvedWeChatMpConfig,
|
|
23
|
+
type WeChatMessage,
|
|
5
24
|
} from './protocol.js';
|
|
6
|
-
import
|
|
7
|
-
|
|
25
|
+
import {
|
|
26
|
+
buildMediaUploadForm,
|
|
27
|
+
readOutboundMedia,
|
|
28
|
+
resolveMediaBinary,
|
|
29
|
+
type WeChatMediaUploadResult,
|
|
30
|
+
} from './media-upload.js';
|
|
31
|
+
import {
|
|
32
|
+
getPassiveReplyCapture,
|
|
33
|
+
recordPassiveReplyText,
|
|
34
|
+
} from './passive-reply.js';
|
|
35
|
+
import { registerWeChatMpWebhookRoutes } from './webhook.js';
|
|
36
|
+
import { receiveWeChatMpSideEvent } from './side-event-dispatch.js';
|
|
8
37
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const
|
|
38
|
+
/**
|
|
39
|
+
* canonical 媒体段类型 → 微信 /cgi-bin/media/upload 的 type。
|
|
40
|
+
* 客服消息无 file 投递面,file 段不可投递。
|
|
41
|
+
*/
|
|
42
|
+
const WECHAT_UPLOAD_TYPE: Readonly<Record<string, 'image' | 'voice' | 'video'>> = {
|
|
43
|
+
image: 'image',
|
|
44
|
+
audio: 'voice',
|
|
45
|
+
voice: 'voice',
|
|
46
|
+
video: 'video',
|
|
47
|
+
};
|
|
14
48
|
|
|
15
|
-
export
|
|
49
|
+
export interface WeChatMpEndpointOptions {
|
|
50
|
+
readonly id: CapabilityId;
|
|
51
|
+
readonly http: HttpHost;
|
|
52
|
+
readonly config: ResolvedWeChatMpConfig;
|
|
53
|
+
readonly fetch?: WeChatMpFetch;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function defaultFetch(
|
|
16
57
|
url: string,
|
|
17
|
-
init?: {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
58
|
+
init?: { readonly method?: string; readonly body?: unknown; readonly headers?: Record<string, string>; readonly signal?: AbortSignal },
|
|
59
|
+
): Promise<{ data: unknown }> {
|
|
60
|
+
return axios({
|
|
61
|
+
url,
|
|
62
|
+
method: (init?.method ?? 'GET') as 'GET' | 'POST',
|
|
63
|
+
data: init?.body,
|
|
64
|
+
headers: init?.headers,
|
|
65
|
+
signal: init?.signal,
|
|
66
|
+
}).then((response) => ({ data: response.data }));
|
|
67
|
+
}
|
|
23
68
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
#accessToken: string | null = null;
|
|
27
|
-
#tokenExpireTime = 0;
|
|
28
|
-
#refreshing: Promise<string> | null = null;
|
|
69
|
+
export class WeChatMpClient extends WeChatMpApiClient {
|
|
70
|
+
readonly #logger!: ReturnType<typeof getAdapterLogger>;
|
|
29
71
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
72
|
+
readonly #options: WeChatMpEndpointOptions;
|
|
73
|
+
#routeReleases: HttpRouteRegistration[] = [];
|
|
74
|
+
#tokenRefreshTimer?: ReturnType<typeof setInterval>;
|
|
75
|
+
/** MsgId → 首次回复 XML(微信 5s 重推去重,有界 LRU)。 */
|
|
76
|
+
readonly #replyCache = new Map<string, string>();
|
|
77
|
+
static readonly #REPLY_CACHE_LIMIT = 1000;
|
|
78
|
+
#open = false;
|
|
79
|
+
#started = false;
|
|
80
|
+
protected readonly endpointManagement: EndpointManagement = createWeChatMpEndpointManagement(() => this);
|
|
34
81
|
|
|
35
|
-
|
|
36
|
-
|
|
82
|
+
protected configName(): string { return this.#options.config.id; }
|
|
83
|
+
protected resolvePresentationLocale(_senderId: string): Promise<import('zhin.js').Locale | undefined> { return Promise.resolve(undefined); }
|
|
84
|
+
get transportState(): import('zhin.js/adapter').EndpointTransportState | undefined { return undefined; }
|
|
85
|
+
protected executionSignal?: AbortSignal;
|
|
86
|
+
protected setExecutionSignal(signal: AbortSignal): void { this.executionSignal = signal; this.transportSignal = signal; }
|
|
87
|
+
/** Native protocol work runs here; Endpoint attaches the framework receipt without replacing SDK APIs. */
|
|
88
|
+
protected receiveInboundCallback<T>(receive: () => T | Promise<T>): Promise<T> {
|
|
89
|
+
try { return Promise.resolve(receive()); } catch (error) { return Promise.reject(error); }
|
|
37
90
|
}
|
|
91
|
+
protected publishEvent(name: string, payload: unknown): Promise<unknown> { this.emit(name, payload); return Promise.resolve(); }
|
|
92
|
+
protected publishAccepted(name: string, payload: unknown): Promise<void> { return this.publishEvent(name, payload).then(() => undefined); }
|
|
93
|
+
protected publishPlatform(name: string, event: unknown): Promise<unknown> { this.emit(name, event); return Promise.resolve(); }
|
|
38
94
|
|
|
39
|
-
|
|
40
|
-
|
|
95
|
+
constructor(options: WeChatMpEndpointOptions) {
|
|
96
|
+
super(options.config, options.fetch ?? defaultFetch);
|
|
97
|
+
this.#logger = getAdapterLogger('wechat-mp', options.config.id);
|
|
98
|
+
this.#options = options;
|
|
41
99
|
}
|
|
42
100
|
|
|
43
|
-
/**
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
101
|
+
/** Used by webhook handler. */
|
|
102
|
+
get isOpen(): boolean {
|
|
103
|
+
return this.#open;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
get config(): ResolvedWeChatMpConfig {
|
|
107
|
+
return this.#options.config;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
get id(): CapabilityId {
|
|
111
|
+
return this.#options.id;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 微信 5s 重推去重:见过该 MsgId 时返回首次回复 XML(含空串=success)。 */
|
|
115
|
+
getCachedReply(msgId: string): string | undefined {
|
|
116
|
+
return this.#replyCache.get(msgId);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
cacheReply(msgId: string, replyXML: string): void {
|
|
120
|
+
if (this.#replyCache.has(msgId)) this.#replyCache.delete(msgId);
|
|
121
|
+
this.#replyCache.set(msgId, replyXML);
|
|
122
|
+
while (this.#replyCache.size > WeChatMpClient.#REPLY_CACHE_LIMIT) {
|
|
123
|
+
const oldest = this.#replyCache.keys().next().value;
|
|
124
|
+
if (oldest === undefined) break;
|
|
125
|
+
this.#replyCache.delete(oldest);
|
|
52
126
|
}
|
|
53
|
-
return this.#refreshing;
|
|
54
127
|
}
|
|
55
128
|
|
|
56
|
-
async
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
this.
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
129
|
+
protected async connectTransport(signal?: AbortSignal): Promise<void> {
|
|
130
|
+
if (signal) { this.setExecutionSignal(signal); signal.throwIfAborted(); }
|
|
131
|
+
if (this.#started) return;
|
|
132
|
+
this.#started = true;
|
|
133
|
+
try {
|
|
134
|
+
await this.refreshAccessToken();
|
|
135
|
+
this.executionSignal?.throwIfAborted();
|
|
136
|
+
const isOpen = () => this.#open;
|
|
137
|
+
this.#routeReleases.push(...registerWeChatMpWebhookRoutes(this.#options.http, {
|
|
138
|
+
config: this.config, get isOpen() { return isOpen(); }, id: this.#options.id,
|
|
139
|
+
admit: (message, event) => this.admit(message, event),
|
|
140
|
+
resolveLocale: senderId => this.resolvePresentationLocale(senderId),
|
|
141
|
+
getCachedReply: id => this.getCachedReply(id), cacheReply: (id, reply) => this.cacheReply(id, reply),
|
|
142
|
+
}));
|
|
143
|
+
this.#startTokenRefreshTimer();
|
|
144
|
+
this.#logger.debug(formatCompact({
|
|
145
|
+
endpoint: this.#options.config.id,
|
|
146
|
+
op: 'webhook',
|
|
147
|
+
path: this.#options.config.path,
|
|
148
|
+
}));
|
|
149
|
+
} catch (error) {
|
|
150
|
+
await this.disconnectTransport();
|
|
151
|
+
this.#logger.error('Failed to connect WeChat MP bot:', error);
|
|
152
|
+
throw error;
|
|
71
153
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
async
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
async #requestWithToken<T>(
|
|
90
|
-
path: string,
|
|
91
|
-
init?: Parameters<WeChatMpFetch>[1],
|
|
92
|
-
): Promise<{ readonly data: T; readonly token: string | null }> {
|
|
93
|
-
if (this.tokenExpired) await this.refreshAccessToken();
|
|
94
|
-
const token = this.#accessToken;
|
|
95
|
-
const separator = path.includes('?') ? '&' : '?';
|
|
96
|
-
const response = await this.fetch(
|
|
97
|
-
`https://api.weixin.qq.com${path}${separator}access_token=${token}`,
|
|
98
|
-
init,
|
|
99
|
-
);
|
|
100
|
-
return { data: response.data as T, token };
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
async sendCustomerService(messageData: unknown): Promise<WeChatAPIResponse> {
|
|
104
|
-
const first = await this.#requestWithToken<WeChatAPIResponse>(
|
|
105
|
-
'/cgi-bin/message/custom/send',
|
|
106
|
-
{ method: 'POST', body: messageData },
|
|
107
|
-
);
|
|
108
|
-
let result = first.data;
|
|
109
|
-
if (result.errcode && TOKEN_INVALID_ERRCODES.has(Number(result.errcode))) {
|
|
110
|
-
// 失败请求使用的 token 若已被其他请求换掉,直接用新 token 重试,不再重复刷新。
|
|
111
|
-
if (this.#accessToken === first.token) await this.refreshAccessToken();
|
|
112
|
-
result = await this.request<WeChatAPIResponse>(
|
|
113
|
-
'/cgi-bin/message/custom/send',
|
|
114
|
-
{ method: 'POST', body: messageData },
|
|
115
|
-
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
protected openInput(): void {
|
|
157
|
+
this.#open = true;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
protected closeInput(): void {
|
|
161
|
+
this.#open = false;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
protected async disconnectTransport(): Promise<void> {
|
|
165
|
+
this.#open = false;
|
|
166
|
+
if (this.#tokenRefreshTimer) {
|
|
167
|
+
clearInterval(this.#tokenRefreshTimer);
|
|
168
|
+
this.#tokenRefreshTimer = undefined;
|
|
116
169
|
}
|
|
117
|
-
|
|
170
|
+
for (const release of this.#routeReleases.splice(0)) release();
|
|
171
|
+
this.#started = false;
|
|
172
|
+
this.#logger.debug(formatCompact({ op: 'disconnect' }));
|
|
118
173
|
}
|
|
119
174
|
|
|
120
|
-
async
|
|
121
|
-
|
|
122
|
-
|
|
175
|
+
protected async sendPayload({ conversation, payload, signal }: EndpointSendRequest): Promise<string> {
|
|
176
|
+
if (getPassiveReplyCapture()) {
|
|
177
|
+
const text = extractOutboundText(payload);
|
|
178
|
+
recordPassiveReplyText(text);
|
|
179
|
+
return `passive_${Date.now()}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (this.#options.config.replyMode === 'customer_service') {
|
|
183
|
+
return this.#sendCustomerService({ target: conversation.id, payload, signal });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
this.#logger.warn(formatCompact({
|
|
187
|
+
op: 'send',
|
|
188
|
+
skip: 'passive_outside_webhook',
|
|
189
|
+
endpoint: this.#options.config.id,
|
|
190
|
+
target: `${conversation.kind}:${conversation.id}`,
|
|
191
|
+
}));
|
|
192
|
+
return `passive_skipped_${Date.now()}`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Test / internal: admit a parsed message when open (non-webhook path). */
|
|
196
|
+
admit(msg: WeChatMessage, nativeEvent: unknown = msg): void | Promise<unknown> {
|
|
197
|
+
if (!this.#open) return;
|
|
198
|
+
return this.receiveInboundCallback(() => this.#admitMessage(msg, nativeEvent));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
#admitMessage(msg: WeChatMessage, nativeEvent: unknown = msg): void | Promise<unknown> {
|
|
202
|
+
if (!this.#open) return;
|
|
203
|
+
void this.publishPlatform(msg.Event ? `${msg.MsgType}.${msg.Event}` : msg.MsgType, nativeEvent).catch((error) => {
|
|
204
|
+
this.#logger.warn(formatCompact({
|
|
205
|
+
op: 'wechat_mp_platform_event_failed',
|
|
206
|
+
error: error instanceof Error ? error.message : String(error),
|
|
207
|
+
}));
|
|
208
|
+
});
|
|
209
|
+
if (receiveWeChatMpSideEvent(
|
|
210
|
+
(name, payload) => this.publishEvent(name, payload),
|
|
211
|
+
this.#options.config.id,
|
|
212
|
+
msg,
|
|
213
|
+
this.#logger,
|
|
214
|
+
nativeEvent,
|
|
215
|
+
)) {
|
|
216
|
+
return undefined;
|
|
217
|
+
}
|
|
218
|
+
const conversation = wechatMpInboundConversation(String(this.#options.id), msg);
|
|
219
|
+
return this.publishEvent('message.receive', {
|
|
220
|
+
event: nativeEvent,
|
|
221
|
+
conversation,
|
|
222
|
+
message: { conversation, id: formatInboundId(msg) },
|
|
223
|
+
content: formatInboundContent(msg),
|
|
224
|
+
sender: { id: msg.FromUserName },
|
|
225
|
+
endpointId: this.#options.config.id,
|
|
226
|
+
metadata: Object.freeze({
|
|
227
|
+
msgType: msg.MsgType,
|
|
228
|
+
event: msg.Event,
|
|
229
|
+
toUserName: msg.ToUserName,
|
|
230
|
+
}),
|
|
231
|
+
}).catch((err) => {
|
|
232
|
+
this.#logger.warn(formatCompact({
|
|
233
|
+
op: 'wechat_mp_gateway_receive_failed',
|
|
234
|
+
target: `${conversation.kind}:${conversation.id}`,
|
|
235
|
+
error: err instanceof Error ? err.message : String(err),
|
|
236
|
+
}));
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async #sendCustomerService({ target, payload, signal }: WeChatCustomerServiceRequest): Promise<string> {
|
|
241
|
+
// 发送前检查过期(不只判 null):过期 token 直接刷新,不白跑一次 40001。
|
|
242
|
+
const materialized = await createEndpointOperation({ signal }).prepare(() => this.#materializeOutboundMedia(payload, { signal }));
|
|
243
|
+
const messageData = formatCustomerServiceBody(target, materialized);
|
|
244
|
+
const result = await this.sendCustomerService(messageData, { signal });
|
|
245
|
+
if (result.errcode && result.errcode !== 0) {
|
|
246
|
+
throw new Error(`WeChat API error: ${result.errcode} - ${result.errmsg}`);
|
|
247
|
+
}
|
|
248
|
+
this.#logger.debug(formatCompact({ op: 'wechat_mp_send', target, messageId: result.msgid }));
|
|
249
|
+
return result.msgid?.toString() || `cs_${Date.now()}`;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* 客服消息媒体段只接受 media_id:canonical MediaRef 是唯一来源。
|
|
254
|
+
* - kind=file(平台不透明引用,即既有 media_id)→ 直接透传;
|
|
255
|
+
* - kind=base64 / path / url → 经 /cgi-bin/media/upload 物化;
|
|
256
|
+
* - 无 MediaRef / 类型不可投递(file 段)→ warn + 丢弃;
|
|
257
|
+
* - 上传失败降级为文本(alt 优先),不阻断发送。
|
|
258
|
+
*/
|
|
259
|
+
async #materializeOutboundMedia(payload: unknown, request: EndpointOperationOptions): Promise<unknown> {
|
|
260
|
+
if (!Array.isArray(payload)) return payload;
|
|
261
|
+
const materialized = await Promise.all(payload.map(async (item) => {
|
|
262
|
+
if (typeof item === 'string' || !item || typeof item !== 'object') return item;
|
|
263
|
+
const seg = item as { type?: unknown; data?: Record<string, unknown> };
|
|
264
|
+
if (typeof seg.type !== 'string') return item;
|
|
265
|
+
const data = seg.data ?? {};
|
|
266
|
+
const uploadType = WECHAT_UPLOAD_TYPE[seg.type];
|
|
267
|
+
const isMediaSegment = uploadType != null || seg.type === 'file';
|
|
268
|
+
if (!isMediaSegment) return item;
|
|
269
|
+
const media = readOutboundMedia(data);
|
|
270
|
+
if (!media) {
|
|
271
|
+
// 已物化(mediaId/media_id)的段透传;其余无 canonical 媒体引用,丢弃留痕
|
|
272
|
+
if (typeof data.mediaId === 'string' && data.mediaId) return item;
|
|
273
|
+
if (typeof data.media_id === 'string' && data.media_id) return item;
|
|
274
|
+
this.#logger.warn(formatCompact({
|
|
275
|
+
op: 'wechat_mp_outbound_media_dropped',
|
|
276
|
+
endpoint: this.#options.config.id,
|
|
277
|
+
type: seg.type,
|
|
278
|
+
reason: 'missing_media_ref',
|
|
279
|
+
}));
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
if (media.kind === 'file') {
|
|
283
|
+
// 平台不透明引用:value 即 media_id,直接透传不上传
|
|
284
|
+
return { type: seg.type, data: { mediaId: media.value } };
|
|
285
|
+
}
|
|
286
|
+
if (!uploadType) {
|
|
287
|
+
this.#logger.warn(formatCompact({
|
|
288
|
+
op: 'wechat_mp_outbound_media_dropped',
|
|
289
|
+
endpoint: this.#options.config.id,
|
|
290
|
+
type: seg.type,
|
|
291
|
+
reason: 'unsupported_segment_type',
|
|
292
|
+
}));
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
try {
|
|
296
|
+
const mediaId = await this.#uploadMedia({ type: uploadType, media, signal: request.signal });
|
|
297
|
+
return { type: seg.type, data: { mediaId } };
|
|
298
|
+
} catch (error) {
|
|
299
|
+
if (request.signal?.aborted) throw error;
|
|
300
|
+
this.#logger.warn(formatCompact({
|
|
301
|
+
op: 'wechat_mp_media_upload_failed',
|
|
302
|
+
endpoint: this.#options.config.id,
|
|
303
|
+
error: error instanceof Error ? error.message : String(error),
|
|
304
|
+
}));
|
|
305
|
+
const alt = typeof data.alt === 'string' && data.alt ? data.alt : `[${seg.type}]`;
|
|
306
|
+
return { type: 'text', data: { text: alt } };
|
|
307
|
+
}
|
|
308
|
+
}));
|
|
309
|
+
return materialized.filter((item) => item != null);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** POST /cgi-bin/media/upload(临时素材,3 天有效),返回 media_id。 */
|
|
313
|
+
async #uploadMedia(
|
|
314
|
+
{ type, media, signal }: WeChatMediaRequest,
|
|
123
315
|
): Promise<string> {
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const data = await this.request<WeChatAPIResponse & {
|
|
137
|
-
count?: number;
|
|
138
|
-
data?: { openid?: string[] };
|
|
139
|
-
next_openid?: string;
|
|
140
|
-
}>(`/cgi-bin/user/get?next_openid=${encodeURIComponent(nextOpenid)}`);
|
|
141
|
-
if (data.errcode && data.errcode !== 0) {
|
|
142
|
-
throw new Error(`WeChat API error: ${data.errcode} - ${data.errmsg}`);
|
|
316
|
+
const binary = await createEndpointOperation({ signal }).prepare(() => resolveMediaBinary(media, { signal }));
|
|
317
|
+
const form = buildMediaUploadForm(binary);
|
|
318
|
+
return this.uploadMedia({ type, body: form, signal });
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
#startTokenRefreshTimer(): void {
|
|
322
|
+
if (this.#tokenRefreshTimer) clearInterval(this.#tokenRefreshTimer);
|
|
323
|
+
this.#tokenRefreshTimer = setInterval(() => {
|
|
324
|
+
if (this.tokenExpired) {
|
|
325
|
+
void this.refreshAccessToken().catch((error) => {
|
|
326
|
+
this.#logger.error('Failed to refresh access token in timer:', error);
|
|
327
|
+
});
|
|
143
328
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
nextOpenid = data.next_openid ?? '';
|
|
147
|
-
} while (nextOpenid);
|
|
148
|
-
return Object.freeze(followers);
|
|
329
|
+
}, 3_600_000);
|
|
330
|
+
(this.#tokenRefreshTimer as { unref?: () => void }).unref?.();
|
|
149
331
|
}
|
|
150
332
|
}
|
|
151
333
|
|
|
334
|
+
function createWeChatMpEndpointManagement(
|
|
335
|
+
requireClient: () => WeChatMpClient,
|
|
336
|
+
): EndpointManagement {
|
|
337
|
+
return Object.freeze<EndpointManagement>({
|
|
338
|
+
// 公众号无群/频道概念;关注者即"好友"(nickname 为 openid 占位,见 getFollowers)。
|
|
339
|
+
listFriends: async (request: EndpointManagementRequest = {}) => (await requireClient().getFollowerIds(request)).map((openid) => ({
|
|
340
|
+
user_id: openid,
|
|
341
|
+
nickname: openid,
|
|
342
|
+
remark: '',
|
|
343
|
+
})),
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
152
347
|
export type WeChatMpClientEventMap = Record<string, unknown>;
|
|
153
348
|
|
|
154
|
-
|
|
155
|
-
interface AdapterClientRegistry {
|
|
156
|
-
readonly 'wechat-mp': { readonly client: WeChatMpClient; readonly events: WeChatMpClientEventMap };
|
|
157
|
-
}
|
|
158
|
-
}
|
|
349
|
+
export type { WeChatMpFetch } from './api-client.js';
|
|
159
350
|
|
|
160
|
-
|
|
351
|
+
interface WeChatCustomerServiceRequest extends EndpointOperationOptions { readonly target: string; readonly payload: unknown; }
|
|
352
|
+
interface WeChatMediaRequest extends EndpointOperationOptions { readonly type: 'image' | 'voice' | 'video'; readonly media: Parameters<typeof resolveMediaBinary>[0]; }
|
package/src/console.ts
CHANGED
|
@@ -5,17 +5,14 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { createAdapterConsoleRpc, type AdapterConsoleResources } from 'zhin.js/adapter';
|
|
7
7
|
import type { PluginConsoleRpc } from 'zhin.js';
|
|
8
|
-
import { wechatMpRuntimeStateToken } from './wechat-mp-runtime-state.js';
|
|
9
8
|
import { wechatMpConsoleSpec } from './console-spec.js';
|
|
10
9
|
|
|
11
10
|
export function createWechatMpConsoleRpc(
|
|
12
|
-
|
|
11
|
+
_resources: AdapterConsoleResources,
|
|
13
12
|
getConfig: () => unknown,
|
|
14
13
|
): PluginConsoleRpc {
|
|
15
|
-
const state = resources.use(wechatMpRuntimeStateToken);
|
|
16
14
|
return createAdapterConsoleRpc({
|
|
17
15
|
...wechatMpConsoleSpec,
|
|
18
|
-
running: () => state.endpoints.values(),
|
|
19
16
|
getConfig,
|
|
20
|
-
}
|
|
17
|
+
});
|
|
21
18
|
}
|