@zhin.js/adapter-weixin-ilink 1.1.0 → 1.1.2
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 +74 -0
- package/README.md +11 -6
- package/adapters/{weixin-ilink.js → weixin-ilink/index.js} +6 -6
- package/adapters/{weixin-ilink.ts → weixin-ilink/index.ts} +9 -9
- package/commands/{endpoint/add/[id].js → weixin-ilink/endpoint/add/[id]/index.js} +1 -1
- package/commands/weixin-ilink/endpoint/add/[id]/index.ts +3 -0
- package/{lib/weixin-ilink-endpoint-commands.js → commands/weixin-ilink/endpoint/definition.js} +4 -3
- package/{src/weixin-ilink-endpoint-commands.ts → commands/weixin-ilink/endpoint/definition.ts} +3 -3
- package/commands/{endpoint/list.js → weixin-ilink/endpoint/list/index.js} +1 -1
- package/commands/weixin-ilink/endpoint/list/index.ts +3 -0
- package/commands/{endpoint/remove/[id].js → weixin-ilink/endpoint/remove/[id]/index.js} +1 -1
- package/commands/weixin-ilink/endpoint/remove/[id]/index.ts +3 -0
- package/lib/client.d.ts +5 -4
- package/lib/client.js +33 -16
- package/lib/context-store.d.ts +15 -1
- package/lib/context-store.js +93 -1
- package/lib/credentials.d.ts +15 -7
- package/lib/credentials.js +59 -42
- package/lib/endpoint.d.ts +9 -4
- package/lib/endpoint.js +41 -27
- package/lib/ilink-api.d.ts +6 -5
- package/lib/ilink-api.js +21 -14
- package/lib/ilink-config-cache.d.ts +3 -7
- package/lib/ilink-config-cache.js +13 -14
- package/lib/ilink-logger.d.ts +0 -1
- package/lib/ilink-logger.js +11 -25
- package/lib/ilink-meta.d.ts +13 -7
- package/lib/ilink-meta.js +24 -20
- package/lib/ilink-session-guard.d.ts +13 -14
- package/lib/ilink-session-guard.js +29 -42
- package/lib/index.d.ts +5 -2
- package/lib/index.js +4 -0
- package/lib/login.d.ts +4 -3
- package/lib/login.js +24 -21
- package/lib/outbound-media.js +1 -1
- package/lib/protocol.d.ts +7 -10
- package/lib/protocol.js +15 -15
- package/lib/types.d.ts +1 -1
- package/lib/weixin-ilink-runtime-state.js +1 -1
- package/lib/weixin-inbound.d.ts +3 -49
- package/lib/weixin-inbound.js +20 -183
- package/lib/weixin-send-media.js +5 -1
- package/lib/weixin-send.js +2 -0
- package/package.json +11 -11
- package/plugin.js +1 -1
- package/schema.json +32 -4
- package/src/client.ts +46 -14
- package/src/context-store.ts +101 -10
- package/src/credentials.ts +68 -45
- package/src/endpoint.ts +59 -38
- package/src/ilink-api.ts +26 -20
- package/src/ilink-config-cache.ts +17 -13
- package/src/ilink-logger.ts +11 -26
- package/src/ilink-meta.ts +31 -21
- package/src/ilink-session-guard.ts +29 -46
- package/src/index.ts +15 -2
- package/src/login.ts +32 -19
- package/src/outbound-media.ts +1 -1
- package/src/protocol.ts +20 -29
- package/src/types.ts +0 -1
- package/src/upload.test.ts +11 -8
- package/src/weixin-ilink-runtime-state.ts +1 -1
- package/src/weixin-inbound.ts +23 -215
- package/src/weixin-send-media.ts +5 -1
- package/src/weixin-send.ts +2 -0
- package/commands/endpoint/add/[id].ts +0 -3
- package/commands/endpoint/list.ts +0 -3
- package/commands/endpoint/remove/[id].ts +0 -3
- package/lib/weixin-ilink-endpoint-commands.d.ts +0 -1
package/src/weixin-inbound.ts
CHANGED
|
@@ -1,287 +1,95 @@
|
|
|
1
|
-
import
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
|
|
4
|
-
import { logger } from "./ilink-logger.js";
|
|
5
|
-
import { generateId } from "./ilink-random.js";
|
|
1
|
+
import { generateId } from './ilink-random.js';
|
|
6
2
|
import { MessageItemType, type WeixinMessage, type MessageItem } from './ilink-types.js';
|
|
7
3
|
|
|
8
|
-
import { resolveStateDir } from "./credentials.js";
|
|
9
|
-
|
|
10
|
-
// ---------------------------------------------------------------------------
|
|
11
|
-
// Context token store (in-process cache + disk persistence)
|
|
12
|
-
// ---------------------------------------------------------------------------
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* contextToken is issued per-message by the Weixin getupdates API and must
|
|
16
|
-
* be echoed verbatim in every outbound send. The in-memory map is the primary
|
|
17
|
-
* lookup; a disk-backed file per account ensures tokens survive gateway restarts.
|
|
18
|
-
*/
|
|
19
|
-
const contextTokenStore = new Map<string, string>();
|
|
20
|
-
|
|
21
|
-
function contextTokenKey(accountId: string, userId: string): string {
|
|
22
|
-
return `${accountId}:${userId}`;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
// ---------------------------------------------------------------------------
|
|
26
|
-
// Disk persistence helpers
|
|
27
|
-
// ---------------------------------------------------------------------------
|
|
28
|
-
|
|
29
|
-
function resolveContextTokenFilePath(accountId: string): string {
|
|
30
|
-
return path.join(resolveStateDir(), "context-tokens", `${accountId}.context-tokens.json`);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** Persist all context tokens for a given account to disk. */
|
|
34
|
-
function persistContextTokens(accountId: string): void {
|
|
35
|
-
const prefix = `${accountId}:`;
|
|
36
|
-
const tokens: Record<string, string> = {};
|
|
37
|
-
for (const [k, v] of contextTokenStore) {
|
|
38
|
-
if (k.startsWith(prefix)) {
|
|
39
|
-
tokens[k.slice(prefix.length)] = v;
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
const filePath = resolveContextTokenFilePath(accountId);
|
|
43
|
-
try {
|
|
44
|
-
const dir = path.dirname(filePath);
|
|
45
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
46
|
-
fs.writeFileSync(filePath, JSON.stringify(tokens, null, 0), "utf-8");
|
|
47
|
-
} catch (err) {
|
|
48
|
-
logger.warn(`persistContextTokens: failed to write ${filePath}: ${String(err)}`);
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* 每条入站消息都会 setContextToken;全量同步写盘在消息高峰时是纯浪费。
|
|
54
|
-
* 按 account 防抖合并,500ms 内多次写入只落盘一次。
|
|
55
|
-
*/
|
|
56
|
-
const PERSIST_DEBOUNCE_MS = 500;
|
|
57
|
-
const persistTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
58
|
-
|
|
59
|
-
function schedulePersistContextTokens(accountId: string): void {
|
|
60
|
-
const existing = persistTimers.get(accountId);
|
|
61
|
-
if (existing) clearTimeout(existing);
|
|
62
|
-
const timer = setTimeout(() => {
|
|
63
|
-
persistTimers.delete(accountId);
|
|
64
|
-
persistContextTokens(accountId);
|
|
65
|
-
}, PERSIST_DEBOUNCE_MS);
|
|
66
|
-
// 不让防抖定时器拖住进程退出
|
|
67
|
-
(timer as { unref?: () => void }).unref?.();
|
|
68
|
-
persistTimers.set(accountId, timer);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** 立即落盘所有待写(endpoint stop / 测试用)。 */
|
|
72
|
-
export function flushContextTokenPersist(accountId?: string): void {
|
|
73
|
-
for (const [id, timer] of [...persistTimers]) {
|
|
74
|
-
if (accountId !== undefined && id !== accountId) continue;
|
|
75
|
-
clearTimeout(timer);
|
|
76
|
-
persistTimers.delete(id);
|
|
77
|
-
persistContextTokens(id);
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Restore persisted context tokens for an account into the in-memory map.
|
|
83
|
-
* Called once during gateway startAccount to survive restarts.
|
|
84
|
-
*/
|
|
85
|
-
export function restoreContextTokens(accountId: string): void {
|
|
86
|
-
const filePath = resolveContextTokenFilePath(accountId);
|
|
87
|
-
try {
|
|
88
|
-
if (!fs.existsSync(filePath)) return;
|
|
89
|
-
const raw = fs.readFileSync(filePath, "utf-8");
|
|
90
|
-
const tokens = JSON.parse(raw) as Record<string, string>;
|
|
91
|
-
let count = 0;
|
|
92
|
-
for (const [userId, token] of Object.entries(tokens)) {
|
|
93
|
-
if (typeof token === "string" && token) {
|
|
94
|
-
contextTokenStore.set(contextTokenKey(accountId, userId), token);
|
|
95
|
-
count++;
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
logger.info(`restoreContextTokens: restored ${count} tokens for account=${accountId}`);
|
|
99
|
-
} catch (err) {
|
|
100
|
-
logger.warn(`restoreContextTokens: failed to read ${filePath}: ${String(err)}`);
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/** Remove all context tokens for a given account (memory + disk). */
|
|
105
|
-
export function clearContextTokensForAccount(accountId: string): void {
|
|
106
|
-
const prefix = `${accountId}:`;
|
|
107
|
-
for (const k of [...contextTokenStore.keys()]) {
|
|
108
|
-
if (k.startsWith(prefix)) {
|
|
109
|
-
contextTokenStore.delete(k);
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
const filePath = resolveContextTokenFilePath(accountId);
|
|
113
|
-
try {
|
|
114
|
-
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
|
115
|
-
} catch (err) {
|
|
116
|
-
logger.warn(`clearContextTokensForAccount: failed to remove ${filePath}: ${String(err)}`);
|
|
117
|
-
}
|
|
118
|
-
logger.info(`clearContextTokensForAccount: cleared tokens for account=${accountId}`);
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/** Store a context token for a given account+user pair (memory + debounced disk). */
|
|
122
|
-
export function setContextToken(accountId: string, userId: string, token: string): void {
|
|
123
|
-
const k = contextTokenKey(accountId, userId);
|
|
124
|
-
logger.debug(`setContextToken: key=${k}`);
|
|
125
|
-
contextTokenStore.set(k, token);
|
|
126
|
-
schedulePersistContextTokens(accountId);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/** Retrieve the cached context token for a given account+user pair. */
|
|
130
|
-
export function getContextToken(accountId: string, userId: string): string | undefined {
|
|
131
|
-
const k = contextTokenKey(accountId, userId);
|
|
132
|
-
const val = contextTokenStore.get(k);
|
|
133
|
-
logger.debug(
|
|
134
|
-
`getContextToken: key=${k} found=${val !== undefined} storeSize=${contextTokenStore.size}`,
|
|
135
|
-
);
|
|
136
|
-
return val;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* List all peer userIds that hold a context token for the given account
|
|
141
|
-
* (memory map, already merged with the disk-backed file on restore).
|
|
142
|
-
* Personal WeChat has no contact/profile API; this is the only peer catalog.
|
|
143
|
-
*/
|
|
144
|
-
export function listContextTokenUserIds(accountId: string): string[] {
|
|
145
|
-
const prefix = `${accountId}:`;
|
|
146
|
-
const ids: string[] = [];
|
|
147
|
-
for (const k of contextTokenStore.keys()) {
|
|
148
|
-
if (k.startsWith(prefix)) ids.push(k.slice(prefix.length));
|
|
149
|
-
}
|
|
150
|
-
return ids;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
* Find all accountIds that have an active contextToken for the given userId.
|
|
155
|
-
* Used to infer the sending bot account from the recipient address when
|
|
156
|
-
* accountId is not explicitly provided (e.g. cron delivery).
|
|
157
|
-
*
|
|
158
|
-
* Returns all matching accountIds (not just the first) so the caller can
|
|
159
|
-
* detect ambiguity when multiple accounts have sessions with the same user.
|
|
160
|
-
*/
|
|
161
|
-
export function findAccountIdsByContextToken(
|
|
162
|
-
accountIds: string[],
|
|
163
|
-
userId: string,
|
|
164
|
-
): string[] {
|
|
165
|
-
return accountIds.filter((id) => contextTokenStore.has(contextTokenKey(id, userId)));
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// ---------------------------------------------------------------------------
|
|
169
|
-
// Message ID generation
|
|
170
|
-
// ---------------------------------------------------------------------------
|
|
171
|
-
|
|
172
4
|
function generateMessageSid(): string {
|
|
173
|
-
return generateId(
|
|
5
|
+
return generateId('openclaw-weixin');
|
|
174
6
|
}
|
|
175
7
|
|
|
176
|
-
/** Inbound context passed to the OpenClaw core pipeline (matches MsgContext shape). */
|
|
177
8
|
export type WeixinMsgContext = {
|
|
178
9
|
Body: string;
|
|
179
10
|
From: string;
|
|
180
11
|
To: string;
|
|
181
12
|
AccountId: string;
|
|
182
|
-
OriginatingChannel:
|
|
13
|
+
OriginatingChannel: 'openclaw-weixin';
|
|
183
14
|
OriginatingTo: string;
|
|
184
15
|
MessageSid: string;
|
|
185
16
|
Timestamp?: number;
|
|
186
|
-
Provider:
|
|
187
|
-
ChatType:
|
|
188
|
-
/** Set by monitor after resolveAgentRoute so dispatchReplyFromConfig uses the correct session. */
|
|
17
|
+
Provider: 'openclaw-weixin';
|
|
18
|
+
ChatType: 'direct';
|
|
189
19
|
SessionKey?: string;
|
|
190
20
|
context_token?: string;
|
|
191
21
|
MediaUrl?: string;
|
|
192
22
|
MediaPath?: string;
|
|
193
23
|
MediaType?: string;
|
|
194
|
-
/** Raw message body for framework command authorization. */
|
|
195
24
|
CommandBody?: string;
|
|
196
|
-
/** Whether the sender is authorized to execute slash commands. */
|
|
197
25
|
CommandAuthorized?: boolean;
|
|
198
26
|
};
|
|
199
27
|
|
|
200
|
-
/** Returns true if the message item is a media type (image, video, file, or voice). */
|
|
201
28
|
export function isMediaItem(item: MessageItem): boolean {
|
|
202
|
-
return
|
|
203
|
-
item.type === MessageItemType.
|
|
204
|
-
item.type === MessageItemType.
|
|
205
|
-
item.type === MessageItemType.
|
|
206
|
-
item.type === MessageItemType.VOICE
|
|
207
|
-
);
|
|
29
|
+
return item.type === MessageItemType.IMAGE
|
|
30
|
+
|| item.type === MessageItemType.VIDEO
|
|
31
|
+
|| item.type === MessageItemType.FILE
|
|
32
|
+
|| item.type === MessageItemType.VOICE;
|
|
208
33
|
}
|
|
209
34
|
|
|
210
35
|
export function bodyFromItemList(itemList?: MessageItem[]): string {
|
|
211
|
-
if (!itemList?.length) return
|
|
36
|
+
if (!itemList?.length) return '';
|
|
212
37
|
for (const item of itemList) {
|
|
213
38
|
if (item.type === MessageItemType.TEXT && item.text_item?.text != null) {
|
|
214
39
|
return String(item.text_item.text);
|
|
215
40
|
}
|
|
216
|
-
// 语音转文字:如果语音消息有 text 字段,直接使用文字内容
|
|
217
41
|
if (item.type === MessageItemType.VOICE && item.voice_item?.text) {
|
|
218
42
|
return item.voice_item.text;
|
|
219
43
|
}
|
|
220
44
|
}
|
|
221
|
-
return
|
|
45
|
+
return '';
|
|
222
46
|
}
|
|
223
47
|
|
|
224
48
|
export type WeixinInboundMediaOpts = {
|
|
225
|
-
/** Local path to decrypted image file. */
|
|
226
49
|
decryptedPicPath?: string;
|
|
227
|
-
/** Local path to transcoded/raw voice file (.wav or .silk). */
|
|
228
50
|
decryptedVoicePath?: string;
|
|
229
|
-
/** MIME type for the voice file (e.g. "audio/wav" or "audio/silk"). */
|
|
230
51
|
voiceMediaType?: string;
|
|
231
|
-
/** Local path to decrypted file attachment. */
|
|
232
52
|
decryptedFilePath?: string;
|
|
233
|
-
/** MIME type for the file attachment (guessed from file_name). */
|
|
234
53
|
fileMediaType?: string;
|
|
235
|
-
/** Local path to decrypted video file. */
|
|
236
54
|
decryptedVideoPath?: string;
|
|
237
55
|
};
|
|
238
56
|
|
|
239
|
-
/**
|
|
240
|
-
* Convert a WeixinMessage from getUpdates to the inbound MsgContext for the core pipeline.
|
|
241
|
-
* Media: only pass MediaPath (local file, after CDN download + decrypt).
|
|
242
|
-
* We never pass MediaUrl — the upstream CDN URL is encrypted/auth-only.
|
|
243
|
-
* Priority when multiple media types present: image > video > file > voice.
|
|
244
|
-
*/
|
|
245
57
|
export function weixinMessageToMsgContext(
|
|
246
58
|
msg: WeixinMessage,
|
|
247
59
|
accountId: string,
|
|
248
60
|
opts?: WeixinInboundMediaOpts,
|
|
249
61
|
): WeixinMsgContext {
|
|
250
|
-
const
|
|
62
|
+
const fromUserId = msg.from_user_id ?? '';
|
|
251
63
|
const ctx: WeixinMsgContext = {
|
|
252
64
|
Body: bodyFromItemList(msg.item_list),
|
|
253
|
-
From:
|
|
254
|
-
To:
|
|
65
|
+
From: fromUserId,
|
|
66
|
+
To: fromUserId,
|
|
255
67
|
AccountId: accountId,
|
|
256
|
-
OriginatingChannel:
|
|
257
|
-
OriginatingTo:
|
|
68
|
+
OriginatingChannel: 'openclaw-weixin',
|
|
69
|
+
OriginatingTo: fromUserId,
|
|
258
70
|
MessageSid: generateMessageSid(),
|
|
259
71
|
Timestamp: msg.create_time_ms,
|
|
260
|
-
Provider:
|
|
261
|
-
ChatType:
|
|
72
|
+
Provider: 'openclaw-weixin',
|
|
73
|
+
ChatType: 'direct',
|
|
262
74
|
};
|
|
263
|
-
if (msg.context_token)
|
|
264
|
-
ctx.context_token = msg.context_token;
|
|
265
|
-
}
|
|
75
|
+
if (msg.context_token) ctx.context_token = msg.context_token;
|
|
266
76
|
|
|
267
77
|
if (opts?.decryptedPicPath) {
|
|
268
78
|
ctx.MediaPath = opts.decryptedPicPath;
|
|
269
|
-
ctx.MediaType =
|
|
79
|
+
ctx.MediaType = 'image/*';
|
|
270
80
|
} else if (opts?.decryptedVideoPath) {
|
|
271
81
|
ctx.MediaPath = opts.decryptedVideoPath;
|
|
272
|
-
ctx.MediaType =
|
|
82
|
+
ctx.MediaType = 'video/mp4';
|
|
273
83
|
} else if (opts?.decryptedFilePath) {
|
|
274
84
|
ctx.MediaPath = opts.decryptedFilePath;
|
|
275
|
-
ctx.MediaType = opts.fileMediaType ??
|
|
85
|
+
ctx.MediaType = opts.fileMediaType ?? 'application/octet-stream';
|
|
276
86
|
} else if (opts?.decryptedVoicePath) {
|
|
277
87
|
ctx.MediaPath = opts.decryptedVoicePath;
|
|
278
|
-
ctx.MediaType = opts.voiceMediaType ??
|
|
88
|
+
ctx.MediaType = opts.voiceMediaType ?? 'audio/wav';
|
|
279
89
|
}
|
|
280
|
-
|
|
281
90
|
return ctx;
|
|
282
91
|
}
|
|
283
92
|
|
|
284
|
-
/** Extract the context_token from an inbound WeixinMsgContext. */
|
|
285
93
|
export function getContextTokenFromMsgContext(ctx: WeixinMsgContext): string | undefined {
|
|
286
94
|
return ctx.context_token;
|
|
287
95
|
}
|
package/src/weixin-send-media.ts
CHANGED
|
@@ -23,7 +23,11 @@ export async function sendWeixinMediaFile(params: {
|
|
|
23
23
|
}): Promise<{ messageId: string }> {
|
|
24
24
|
const { filePath, to, text, opts, cdnBaseUrl } = params;
|
|
25
25
|
const mime = getMimeFromFilename(filePath);
|
|
26
|
-
const uploadOpts: WeixinApiOptions = {
|
|
26
|
+
const uploadOpts: WeixinApiOptions = {
|
|
27
|
+
baseUrl: opts.baseUrl,
|
|
28
|
+
metadata: opts.metadata,
|
|
29
|
+
token: opts.token,
|
|
30
|
+
};
|
|
27
31
|
|
|
28
32
|
if (mime.startsWith("video/")) {
|
|
29
33
|
logger.info(`[weixin] sendWeixinMediaFile: uploading video filePath=${filePath} to=${to}`);
|
package/src/weixin-send.ts
CHANGED
|
@@ -72,6 +72,7 @@ export async function sendMessageWeixin(params: {
|
|
|
72
72
|
try {
|
|
73
73
|
await sendMessageApi({
|
|
74
74
|
baseUrl: opts.baseUrl,
|
|
75
|
+
metadata: opts.metadata,
|
|
75
76
|
token: opts.token,
|
|
76
77
|
timeoutMs: opts.timeoutMs,
|
|
77
78
|
body: req,
|
|
@@ -119,6 +120,7 @@ async function sendMediaItems(params: {
|
|
|
119
120
|
try {
|
|
120
121
|
await sendMessageApi({
|
|
121
122
|
baseUrl: opts.baseUrl,
|
|
123
|
+
metadata: opts.metadata,
|
|
122
124
|
token: opts.token,
|
|
123
125
|
timeoutMs: opts.timeoutMs,
|
|
124
126
|
body: req,
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export declare const weixinIlinkEndpointCommands: import("@zhin.js/adapter").EndpointCommands<Readonly<import("@zhin.js/command").CommandDefinition<unknown, unknown, import("@zhin.js/command").CommandMessage, string | undefined>>>;
|