@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/lib/weixin-inbound.js
CHANGED
|
@@ -1,224 +1,61 @@
|
|
|
1
|
-
import
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { logger } from "./ilink-logger.js";
|
|
4
|
-
import { generateId } from "./ilink-random.js";
|
|
1
|
+
import { generateId } from './ilink-random.js';
|
|
5
2
|
import { MessageItemType } from './ilink-types.js';
|
|
6
|
-
import { resolveStateDir } from "./credentials.js";
|
|
7
|
-
// ---------------------------------------------------------------------------
|
|
8
|
-
// Context token store (in-process cache + disk persistence)
|
|
9
|
-
// ---------------------------------------------------------------------------
|
|
10
|
-
/**
|
|
11
|
-
* contextToken is issued per-message by the Weixin getupdates API and must
|
|
12
|
-
* be echoed verbatim in every outbound send. The in-memory map is the primary
|
|
13
|
-
* lookup; a disk-backed file per account ensures tokens survive gateway restarts.
|
|
14
|
-
*/
|
|
15
|
-
const contextTokenStore = new Map();
|
|
16
|
-
function contextTokenKey(accountId, userId) {
|
|
17
|
-
return `${accountId}:${userId}`;
|
|
18
|
-
}
|
|
19
|
-
// ---------------------------------------------------------------------------
|
|
20
|
-
// Disk persistence helpers
|
|
21
|
-
// ---------------------------------------------------------------------------
|
|
22
|
-
function resolveContextTokenFilePath(accountId) {
|
|
23
|
-
return path.join(resolveStateDir(), "context-tokens", `${accountId}.context-tokens.json`);
|
|
24
|
-
}
|
|
25
|
-
/** Persist all context tokens for a given account to disk. */
|
|
26
|
-
function persistContextTokens(accountId) {
|
|
27
|
-
const prefix = `${accountId}:`;
|
|
28
|
-
const tokens = {};
|
|
29
|
-
for (const [k, v] of contextTokenStore) {
|
|
30
|
-
if (k.startsWith(prefix)) {
|
|
31
|
-
tokens[k.slice(prefix.length)] = v;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
const filePath = resolveContextTokenFilePath(accountId);
|
|
35
|
-
try {
|
|
36
|
-
const dir = path.dirname(filePath);
|
|
37
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
38
|
-
fs.writeFileSync(filePath, JSON.stringify(tokens, null, 0), "utf-8");
|
|
39
|
-
}
|
|
40
|
-
catch (err) {
|
|
41
|
-
logger.warn(`persistContextTokens: failed to write ${filePath}: ${String(err)}`);
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
/**
|
|
45
|
-
* 每条入站消息都会 setContextToken;全量同步写盘在消息高峰时是纯浪费。
|
|
46
|
-
* 按 account 防抖合并,500ms 内多次写入只落盘一次。
|
|
47
|
-
*/
|
|
48
|
-
const PERSIST_DEBOUNCE_MS = 500;
|
|
49
|
-
const persistTimers = new Map();
|
|
50
|
-
function schedulePersistContextTokens(accountId) {
|
|
51
|
-
const existing = persistTimers.get(accountId);
|
|
52
|
-
if (existing)
|
|
53
|
-
clearTimeout(existing);
|
|
54
|
-
const timer = setTimeout(() => {
|
|
55
|
-
persistTimers.delete(accountId);
|
|
56
|
-
persistContextTokens(accountId);
|
|
57
|
-
}, PERSIST_DEBOUNCE_MS);
|
|
58
|
-
// 不让防抖定时器拖住进程退出
|
|
59
|
-
timer.unref?.();
|
|
60
|
-
persistTimers.set(accountId, timer);
|
|
61
|
-
}
|
|
62
|
-
/** 立即落盘所有待写(endpoint stop / 测试用)。 */
|
|
63
|
-
export function flushContextTokenPersist(accountId) {
|
|
64
|
-
for (const [id, timer] of [...persistTimers]) {
|
|
65
|
-
if (accountId !== undefined && id !== accountId)
|
|
66
|
-
continue;
|
|
67
|
-
clearTimeout(timer);
|
|
68
|
-
persistTimers.delete(id);
|
|
69
|
-
persistContextTokens(id);
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
/**
|
|
73
|
-
* Restore persisted context tokens for an account into the in-memory map.
|
|
74
|
-
* Called once during gateway startAccount to survive restarts.
|
|
75
|
-
*/
|
|
76
|
-
export function restoreContextTokens(accountId) {
|
|
77
|
-
const filePath = resolveContextTokenFilePath(accountId);
|
|
78
|
-
try {
|
|
79
|
-
if (!fs.existsSync(filePath))
|
|
80
|
-
return;
|
|
81
|
-
const raw = fs.readFileSync(filePath, "utf-8");
|
|
82
|
-
const tokens = JSON.parse(raw);
|
|
83
|
-
let count = 0;
|
|
84
|
-
for (const [userId, token] of Object.entries(tokens)) {
|
|
85
|
-
if (typeof token === "string" && token) {
|
|
86
|
-
contextTokenStore.set(contextTokenKey(accountId, userId), token);
|
|
87
|
-
count++;
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
logger.info(`restoreContextTokens: restored ${count} tokens for account=${accountId}`);
|
|
91
|
-
}
|
|
92
|
-
catch (err) {
|
|
93
|
-
logger.warn(`restoreContextTokens: failed to read ${filePath}: ${String(err)}`);
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
/** Remove all context tokens for a given account (memory + disk). */
|
|
97
|
-
export function clearContextTokensForAccount(accountId) {
|
|
98
|
-
const prefix = `${accountId}:`;
|
|
99
|
-
for (const k of [...contextTokenStore.keys()]) {
|
|
100
|
-
if (k.startsWith(prefix)) {
|
|
101
|
-
contextTokenStore.delete(k);
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
const filePath = resolveContextTokenFilePath(accountId);
|
|
105
|
-
try {
|
|
106
|
-
if (fs.existsSync(filePath))
|
|
107
|
-
fs.unlinkSync(filePath);
|
|
108
|
-
}
|
|
109
|
-
catch (err) {
|
|
110
|
-
logger.warn(`clearContextTokensForAccount: failed to remove ${filePath}: ${String(err)}`);
|
|
111
|
-
}
|
|
112
|
-
logger.info(`clearContextTokensForAccount: cleared tokens for account=${accountId}`);
|
|
113
|
-
}
|
|
114
|
-
/** Store a context token for a given account+user pair (memory + debounced disk). */
|
|
115
|
-
export function setContextToken(accountId, userId, token) {
|
|
116
|
-
const k = contextTokenKey(accountId, userId);
|
|
117
|
-
logger.debug(`setContextToken: key=${k}`);
|
|
118
|
-
contextTokenStore.set(k, token);
|
|
119
|
-
schedulePersistContextTokens(accountId);
|
|
120
|
-
}
|
|
121
|
-
/** Retrieve the cached context token for a given account+user pair. */
|
|
122
|
-
export function getContextToken(accountId, userId) {
|
|
123
|
-
const k = contextTokenKey(accountId, userId);
|
|
124
|
-
const val = contextTokenStore.get(k);
|
|
125
|
-
logger.debug(`getContextToken: key=${k} found=${val !== undefined} storeSize=${contextTokenStore.size}`);
|
|
126
|
-
return val;
|
|
127
|
-
}
|
|
128
|
-
/**
|
|
129
|
-
* List all peer userIds that hold a context token for the given account
|
|
130
|
-
* (memory map, already merged with the disk-backed file on restore).
|
|
131
|
-
* Personal WeChat has no contact/profile API; this is the only peer catalog.
|
|
132
|
-
*/
|
|
133
|
-
export function listContextTokenUserIds(accountId) {
|
|
134
|
-
const prefix = `${accountId}:`;
|
|
135
|
-
const ids = [];
|
|
136
|
-
for (const k of contextTokenStore.keys()) {
|
|
137
|
-
if (k.startsWith(prefix))
|
|
138
|
-
ids.push(k.slice(prefix.length));
|
|
139
|
-
}
|
|
140
|
-
return ids;
|
|
141
|
-
}
|
|
142
|
-
/**
|
|
143
|
-
* Find all accountIds that have an active contextToken for the given userId.
|
|
144
|
-
* Used to infer the sending bot account from the recipient address when
|
|
145
|
-
* accountId is not explicitly provided (e.g. cron delivery).
|
|
146
|
-
*
|
|
147
|
-
* Returns all matching accountIds (not just the first) so the caller can
|
|
148
|
-
* detect ambiguity when multiple accounts have sessions with the same user.
|
|
149
|
-
*/
|
|
150
|
-
export function findAccountIdsByContextToken(accountIds, userId) {
|
|
151
|
-
return accountIds.filter((id) => contextTokenStore.has(contextTokenKey(id, userId)));
|
|
152
|
-
}
|
|
153
|
-
// ---------------------------------------------------------------------------
|
|
154
|
-
// Message ID generation
|
|
155
|
-
// ---------------------------------------------------------------------------
|
|
156
3
|
function generateMessageSid() {
|
|
157
|
-
return generateId(
|
|
4
|
+
return generateId('openclaw-weixin');
|
|
158
5
|
}
|
|
159
|
-
/** Returns true if the message item is a media type (image, video, file, or voice). */
|
|
160
6
|
export function isMediaItem(item) {
|
|
161
|
-
return
|
|
162
|
-
item.type === MessageItemType.VIDEO
|
|
163
|
-
item.type === MessageItemType.FILE
|
|
164
|
-
item.type === MessageItemType.VOICE
|
|
7
|
+
return item.type === MessageItemType.IMAGE
|
|
8
|
+
|| item.type === MessageItemType.VIDEO
|
|
9
|
+
|| item.type === MessageItemType.FILE
|
|
10
|
+
|| item.type === MessageItemType.VOICE;
|
|
165
11
|
}
|
|
166
12
|
export function bodyFromItemList(itemList) {
|
|
167
13
|
if (!itemList?.length)
|
|
168
|
-
return
|
|
14
|
+
return '';
|
|
169
15
|
for (const item of itemList) {
|
|
170
16
|
if (item.type === MessageItemType.TEXT && item.text_item?.text != null) {
|
|
171
17
|
return String(item.text_item.text);
|
|
172
18
|
}
|
|
173
|
-
// 语音转文字:如果语音消息有 text 字段,直接使用文字内容
|
|
174
19
|
if (item.type === MessageItemType.VOICE && item.voice_item?.text) {
|
|
175
20
|
return item.voice_item.text;
|
|
176
21
|
}
|
|
177
22
|
}
|
|
178
|
-
return
|
|
23
|
+
return '';
|
|
179
24
|
}
|
|
180
|
-
/**
|
|
181
|
-
* Convert a WeixinMessage from getUpdates to the inbound MsgContext for the core pipeline.
|
|
182
|
-
* Media: only pass MediaPath (local file, after CDN download + decrypt).
|
|
183
|
-
* We never pass MediaUrl — the upstream CDN URL is encrypted/auth-only.
|
|
184
|
-
* Priority when multiple media types present: image > video > file > voice.
|
|
185
|
-
*/
|
|
186
25
|
export function weixinMessageToMsgContext(msg, accountId, opts) {
|
|
187
|
-
const
|
|
26
|
+
const fromUserId = msg.from_user_id ?? '';
|
|
188
27
|
const ctx = {
|
|
189
28
|
Body: bodyFromItemList(msg.item_list),
|
|
190
|
-
From:
|
|
191
|
-
To:
|
|
29
|
+
From: fromUserId,
|
|
30
|
+
To: fromUserId,
|
|
192
31
|
AccountId: accountId,
|
|
193
|
-
OriginatingChannel:
|
|
194
|
-
OriginatingTo:
|
|
32
|
+
OriginatingChannel: 'openclaw-weixin',
|
|
33
|
+
OriginatingTo: fromUserId,
|
|
195
34
|
MessageSid: generateMessageSid(),
|
|
196
35
|
Timestamp: msg.create_time_ms,
|
|
197
|
-
Provider:
|
|
198
|
-
ChatType:
|
|
36
|
+
Provider: 'openclaw-weixin',
|
|
37
|
+
ChatType: 'direct',
|
|
199
38
|
};
|
|
200
|
-
if (msg.context_token)
|
|
39
|
+
if (msg.context_token)
|
|
201
40
|
ctx.context_token = msg.context_token;
|
|
202
|
-
}
|
|
203
41
|
if (opts?.decryptedPicPath) {
|
|
204
42
|
ctx.MediaPath = opts.decryptedPicPath;
|
|
205
|
-
ctx.MediaType =
|
|
43
|
+
ctx.MediaType = 'image/*';
|
|
206
44
|
}
|
|
207
45
|
else if (opts?.decryptedVideoPath) {
|
|
208
46
|
ctx.MediaPath = opts.decryptedVideoPath;
|
|
209
|
-
ctx.MediaType =
|
|
47
|
+
ctx.MediaType = 'video/mp4';
|
|
210
48
|
}
|
|
211
49
|
else if (opts?.decryptedFilePath) {
|
|
212
50
|
ctx.MediaPath = opts.decryptedFilePath;
|
|
213
|
-
ctx.MediaType = opts.fileMediaType ??
|
|
51
|
+
ctx.MediaType = opts.fileMediaType ?? 'application/octet-stream';
|
|
214
52
|
}
|
|
215
53
|
else if (opts?.decryptedVoicePath) {
|
|
216
54
|
ctx.MediaPath = opts.decryptedVoicePath;
|
|
217
|
-
ctx.MediaType = opts.voiceMediaType ??
|
|
55
|
+
ctx.MediaType = opts.voiceMediaType ?? 'audio/wav';
|
|
218
56
|
}
|
|
219
57
|
return ctx;
|
|
220
58
|
}
|
|
221
|
-
/** Extract the context_token from an inbound WeixinMsgContext. */
|
|
222
59
|
export function getContextTokenFromMsgContext(ctx) {
|
|
223
60
|
return ctx.context_token;
|
|
224
61
|
}
|
package/lib/weixin-send-media.js
CHANGED
|
@@ -15,7 +15,11 @@ import { uploadFileAttachmentToWeixin, uploadFileToWeixin, uploadVideoToWeixin }
|
|
|
15
15
|
export async function sendWeixinMediaFile(params) {
|
|
16
16
|
const { filePath, to, text, opts, cdnBaseUrl } = params;
|
|
17
17
|
const mime = getMimeFromFilename(filePath);
|
|
18
|
-
const uploadOpts = {
|
|
18
|
+
const uploadOpts = {
|
|
19
|
+
baseUrl: opts.baseUrl,
|
|
20
|
+
metadata: opts.metadata,
|
|
21
|
+
token: opts.token,
|
|
22
|
+
};
|
|
19
23
|
if (mime.startsWith("video/")) {
|
|
20
24
|
logger.info(`[weixin] sendWeixinMediaFile: uploading video filePath=${filePath} to=${to}`);
|
|
21
25
|
const uploaded = await uploadVideoToWeixin({
|
package/lib/weixin-send.js
CHANGED
|
@@ -51,6 +51,7 @@ export async function sendMessageWeixin(params) {
|
|
|
51
51
|
try {
|
|
52
52
|
await sendMessageApi({
|
|
53
53
|
baseUrl: opts.baseUrl,
|
|
54
|
+
metadata: opts.metadata,
|
|
54
55
|
token: opts.token,
|
|
55
56
|
timeoutMs: opts.timeoutMs,
|
|
56
57
|
body: req,
|
|
@@ -90,6 +91,7 @@ async function sendMediaItems(params) {
|
|
|
90
91
|
try {
|
|
91
92
|
await sendMessageApi({
|
|
92
93
|
baseUrl: opts.baseUrl,
|
|
94
|
+
metadata: opts.metadata,
|
|
93
95
|
token: opts.token,
|
|
94
96
|
timeoutMs: opts.timeoutMs,
|
|
95
97
|
body: req,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/adapter-weixin-ilink",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Zhin.js Weixin iLink (ClawBot) adapter for Plugin Runtime (long-poll)",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -48,17 +48,17 @@
|
|
|
48
48
|
}
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"@zhin.js/adapter": "1.1.
|
|
52
|
-
"@zhin.js/core": "1.1.
|
|
53
|
-
"@zhin.js/feature-kit": "1.1.
|
|
54
|
-
"@zhin.js/im-contract": "1.1.
|
|
55
|
-
"@zhin.js/logger": "1.1.
|
|
51
|
+
"@zhin.js/adapter": "1.1.14",
|
|
52
|
+
"@zhin.js/core": "1.1.37",
|
|
53
|
+
"@zhin.js/feature-kit": "1.1.1",
|
|
54
|
+
"@zhin.js/im-contract": "1.1.1",
|
|
55
|
+
"@zhin.js/logger": "1.1.1"
|
|
56
56
|
},
|
|
57
57
|
"peerDependencies": {
|
|
58
|
-
"@zhin.js/adapter": "^1.1.
|
|
59
|
-
"@zhin.js/command": "^1.1.
|
|
60
|
-
"@zhin.js/core": "^1.1.
|
|
61
|
-
"zhin.js": "^1.1.
|
|
58
|
+
"@zhin.js/adapter": "^1.1.14",
|
|
59
|
+
"@zhin.js/command": "^1.1.1",
|
|
60
|
+
"@zhin.js/core": "^1.1.37",
|
|
61
|
+
"zhin.js": "^1.1.2"
|
|
62
62
|
},
|
|
63
63
|
"peerDependenciesMeta": {
|
|
64
64
|
"@zhin.js/command": {
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
"@types/node": "^26.1.2",
|
|
73
73
|
"typescript": "^6.0.3",
|
|
74
74
|
"vitest": "^4.1.10",
|
|
75
|
-
"zhin.js": "1.1.
|
|
75
|
+
"zhin.js": "1.1.2"
|
|
76
76
|
},
|
|
77
77
|
"ilink_appid": "bot",
|
|
78
78
|
"engines": {
|
package/plugin.js
CHANGED
|
@@ -8,7 +8,7 @@ export default definePlugin({
|
|
|
8
8
|
displayName: 'Weixin iLink Adapter',
|
|
9
9
|
},
|
|
10
10
|
setup(context) {
|
|
11
|
-
// 运行中 endpoint 注册表(weixin-ilink
|
|
11
|
+
// 运行中 endpoint 注册表(weixin-ilink endpoint list 的"运行中"数据源)
|
|
12
12
|
context.resources.provide(weixinIlinkRuntimeStateToken, createEndpointRuntimeState());
|
|
13
13
|
},
|
|
14
14
|
});
|
package/schema.json
CHANGED
|
@@ -18,6 +18,12 @@
|
|
|
18
18
|
"type": "number",
|
|
19
19
|
"default": 35000
|
|
20
20
|
},
|
|
21
|
+
"dataDir": {
|
|
22
|
+
"type": "string",
|
|
23
|
+
"minLength": 1,
|
|
24
|
+
"default": "data/weixin-ilink",
|
|
25
|
+
"description": "Endpoint state root for credentials, cursors, context tokens, and media"
|
|
26
|
+
},
|
|
21
27
|
"master": {
|
|
22
28
|
"type": [
|
|
23
29
|
"string",
|
|
@@ -38,10 +44,11 @@
|
|
|
38
44
|
},
|
|
39
45
|
"endpoints": {
|
|
40
46
|
"type": "array",
|
|
47
|
+
"minItems": 1,
|
|
41
48
|
"description": "多账号:一个插件实例挂多个 endpoint。每项与顶层字段同构(id 必填,其余覆盖顶层)",
|
|
42
49
|
"items": {
|
|
43
50
|
"type": "object",
|
|
44
|
-
"additionalProperties":
|
|
51
|
+
"additionalProperties": false,
|
|
45
52
|
"properties": {
|
|
46
53
|
"master": {
|
|
47
54
|
"type": [
|
|
@@ -63,16 +70,37 @@
|
|
|
63
70
|
},
|
|
64
71
|
"botToken": {
|
|
65
72
|
"type": "string",
|
|
66
|
-
"description": "iLink bot token
|
|
73
|
+
"description": "iLink bot token; omit to load endpoint state or start QR login"
|
|
67
74
|
},
|
|
68
75
|
"id": {
|
|
69
76
|
"type": "string",
|
|
77
|
+
"pattern": "^[A-Za-z0-9_.-]+$",
|
|
70
78
|
"description": "Weixin iLink bot name"
|
|
79
|
+
},
|
|
80
|
+
"botAgent": {
|
|
81
|
+
"type": "string"
|
|
82
|
+
},
|
|
83
|
+
"baseUrl": {
|
|
84
|
+
"type": "string"
|
|
85
|
+
},
|
|
86
|
+
"cdnBaseUrl": {
|
|
87
|
+
"type": "string"
|
|
88
|
+
},
|
|
89
|
+
"longPollTimeoutMs": {
|
|
90
|
+
"type": "number"
|
|
91
|
+
},
|
|
92
|
+
"dataDir": {
|
|
93
|
+
"type": "string",
|
|
94
|
+
"minLength": 1,
|
|
95
|
+
"description": "Endpoint state root"
|
|
96
|
+
},
|
|
97
|
+
"commandPrefix": {
|
|
98
|
+
"type": "string",
|
|
99
|
+
"description": "本 endpoint 的命令前缀"
|
|
71
100
|
}
|
|
72
101
|
},
|
|
73
102
|
"required": [
|
|
74
|
-
"id"
|
|
75
|
-
"botToken"
|
|
103
|
+
"id"
|
|
76
104
|
]
|
|
77
105
|
}
|
|
78
106
|
},
|
package/src/client.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { WeixinContextTokenStore } from './context-store.js';
|
|
2
2
|
import type { WeixinIlinkCredentials } from './credentials.js';
|
|
3
3
|
import { sendTyping } from './ilink-api.js';
|
|
4
4
|
import type { WeixinConfigManager } from './ilink-config-cache.js';
|
|
5
|
+
import type { IlinkClientMetadata } from './ilink-meta.js';
|
|
5
6
|
import type { ResolvedWeixinIlinkConfig } from './protocol.js';
|
|
6
7
|
import { sendMessageWeixin } from './weixin-send.js';
|
|
7
8
|
import { sendWeixinMediaFile } from './weixin-send-media.js';
|
|
@@ -9,25 +10,41 @@ import { defineEndpointClient } from 'zhin.js/adapter';
|
|
|
9
10
|
|
|
10
11
|
/** Direct iLink API client backed by the Endpoint's live authenticated session. */
|
|
11
12
|
export class WeixinIlinkClient {
|
|
13
|
+
readonly config: ResolvedWeixinIlinkConfig;
|
|
14
|
+
readonly #metadata: IlinkClientMetadata;
|
|
15
|
+
readonly #resolveCredentials: () => WeixinIlinkCredentials | null;
|
|
16
|
+
readonly #resolveConfigManager: () => WeixinConfigManager | undefined;
|
|
17
|
+
readonly #sendTextImpl: typeof sendMessageWeixin;
|
|
18
|
+
readonly #contextTokens: WeixinContextTokenStore;
|
|
19
|
+
|
|
12
20
|
constructor(
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
21
|
+
config: ResolvedWeixinIlinkConfig,
|
|
22
|
+
metadata: IlinkClientMetadata,
|
|
23
|
+
resolveCredentials: () => WeixinIlinkCredentials | null,
|
|
24
|
+
resolveConfigManager: () => WeixinConfigManager | undefined,
|
|
25
|
+
sendTextImpl: typeof sendMessageWeixin,
|
|
26
|
+
contextTokens: WeixinContextTokenStore,
|
|
27
|
+
) {
|
|
28
|
+
this.config = config;
|
|
29
|
+
this.#metadata = metadata;
|
|
30
|
+
this.#resolveCredentials = resolveCredentials;
|
|
31
|
+
this.#resolveConfigManager = resolveConfigManager;
|
|
32
|
+
this.#sendTextImpl = sendTextImpl;
|
|
33
|
+
this.#contextTokens = contextTokens;
|
|
34
|
+
}
|
|
18
35
|
|
|
19
36
|
get credentials(): WeixinIlinkCredentials {
|
|
20
|
-
const credentials = this
|
|
37
|
+
const credentials = this.#resolveCredentials();
|
|
21
38
|
if (!credentials?.botToken) throw new Error('weixin-ilink client not authenticated');
|
|
22
39
|
return credentials;
|
|
23
40
|
}
|
|
24
41
|
|
|
25
42
|
get authenticated(): boolean {
|
|
26
|
-
return Boolean(this
|
|
43
|
+
return Boolean(this.#resolveCredentials()?.botToken);
|
|
27
44
|
}
|
|
28
45
|
|
|
29
46
|
get apiBaseUrl(): string {
|
|
30
|
-
return this
|
|
47
|
+
return this.#resolveCredentials()?.baseUrl ?? this.config.baseUrl;
|
|
31
48
|
}
|
|
32
49
|
|
|
33
50
|
get cdnBaseUrl(): string {
|
|
@@ -35,16 +52,25 @@ export class WeixinIlinkClient {
|
|
|
35
52
|
}
|
|
36
53
|
|
|
37
54
|
contextToken(userId: string): string | undefined {
|
|
38
|
-
return
|
|
55
|
+
return this.#contextTokens.get(userId);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
reachableUserIds(): string[] {
|
|
59
|
+
return this.#contextTokens.userIds();
|
|
39
60
|
}
|
|
40
61
|
|
|
41
62
|
async sendText(to: string, text: string): Promise<{ messageId: string }> {
|
|
42
63
|
const contextToken = this.contextToken(to);
|
|
43
64
|
if (!contextToken) throw new Error(`missing context_token for peer ${to}`);
|
|
44
|
-
return this
|
|
65
|
+
return this.#sendTextImpl({
|
|
45
66
|
to,
|
|
46
67
|
text,
|
|
47
|
-
opts: {
|
|
68
|
+
opts: {
|
|
69
|
+
baseUrl: this.apiBaseUrl,
|
|
70
|
+
metadata: this.#metadata,
|
|
71
|
+
token: this.credentials.botToken,
|
|
72
|
+
contextToken,
|
|
73
|
+
},
|
|
48
74
|
});
|
|
49
75
|
}
|
|
50
76
|
|
|
@@ -55,18 +81,24 @@ export class WeixinIlinkClient {
|
|
|
55
81
|
filePath,
|
|
56
82
|
to,
|
|
57
83
|
text,
|
|
58
|
-
opts: {
|
|
84
|
+
opts: {
|
|
85
|
+
baseUrl: this.apiBaseUrl,
|
|
86
|
+
metadata: this.#metadata,
|
|
87
|
+
token: this.credentials.botToken,
|
|
88
|
+
contextToken,
|
|
89
|
+
},
|
|
59
90
|
cdnBaseUrl: this.cdnBaseUrl,
|
|
60
91
|
});
|
|
61
92
|
}
|
|
62
93
|
|
|
63
94
|
async sendTyping(userId: string, status: number): Promise<boolean> {
|
|
64
|
-
const manager = this
|
|
95
|
+
const manager = this.#resolveConfigManager();
|
|
65
96
|
if (!this.authenticated || !manager) return false;
|
|
66
97
|
const cfg = await manager.getForUser(userId, this.contextToken(userId));
|
|
67
98
|
if (!cfg.typingTicket) return false;
|
|
68
99
|
await sendTyping({
|
|
69
100
|
baseUrl: this.apiBaseUrl,
|
|
101
|
+
metadata: this.#metadata,
|
|
70
102
|
token: this.credentials.botToken,
|
|
71
103
|
body: { ilink_user_id: userId, typing_ticket: cfg.typingTicket, status },
|
|
72
104
|
});
|
package/src/context-store.ts
CHANGED
|
@@ -1,10 +1,101 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import type { WeixinIlinkStateStore } from './credentials.js';
|
|
4
|
+
import { logger } from './ilink-logger.js';
|
|
5
|
+
|
|
6
|
+
const PERSIST_DEBOUNCE_MS = 500;
|
|
7
|
+
|
|
8
|
+
/** Endpoint-owned context-token repository with account-scoped persistence. */
|
|
9
|
+
export class WeixinContextTokenStore {
|
|
10
|
+
readonly state: WeixinIlinkStateStore;
|
|
11
|
+
readonly #tokens = new Map<string, string>();
|
|
12
|
+
#persistTimer?: ReturnType<typeof setTimeout>;
|
|
13
|
+
|
|
14
|
+
constructor(state: WeixinIlinkStateStore) {
|
|
15
|
+
this.state = state;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
get accountId(): string {
|
|
19
|
+
return this.state.endpointId;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
get(userId: string): string | undefined {
|
|
23
|
+
const value = this.#tokens.get(userId);
|
|
24
|
+
logger.debug(`contextTokenStore.get: account=${this.accountId} user=${userId} found=${value !== undefined} storeSize=${this.#tokens.size}`);
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
set(userId: string, token: string): void {
|
|
29
|
+
logger.debug(`contextTokenStore.set: account=${this.accountId} user=${userId}`);
|
|
30
|
+
this.#tokens.set(userId, token);
|
|
31
|
+
this.#schedulePersist();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
userIds(): string[] {
|
|
35
|
+
return [...this.#tokens.keys()];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
restore(): void {
|
|
39
|
+
const filePath = this.#filePath();
|
|
40
|
+
try {
|
|
41
|
+
if (!fs.existsSync(filePath)) return;
|
|
42
|
+
const tokens = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as Record<string, unknown>;
|
|
43
|
+
let count = 0;
|
|
44
|
+
for (const [userId, token] of Object.entries(tokens)) {
|
|
45
|
+
if (typeof token !== 'string' || !token) continue;
|
|
46
|
+
this.#tokens.set(userId, token);
|
|
47
|
+
count += 1;
|
|
48
|
+
}
|
|
49
|
+
logger.info(`contextTokenStore.restore: restored ${count} tokens for account=${this.accountId}`);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
logger.warn(`contextTokenStore.restore: failed to read ${filePath}: ${String(error)}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
flush(): void {
|
|
56
|
+
if (this.#persistTimer) {
|
|
57
|
+
clearTimeout(this.#persistTimer);
|
|
58
|
+
this.#persistTimer = undefined;
|
|
59
|
+
}
|
|
60
|
+
this.#persist();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
clear(): void {
|
|
64
|
+
if (this.#persistTimer) {
|
|
65
|
+
clearTimeout(this.#persistTimer);
|
|
66
|
+
this.#persistTimer = undefined;
|
|
67
|
+
}
|
|
68
|
+
this.#tokens.clear();
|
|
69
|
+
const filePath = this.#filePath();
|
|
70
|
+
try {
|
|
71
|
+
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
logger.warn(`contextTokenStore.clear: failed to remove ${filePath}: ${String(error)}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
#filePath(): string {
|
|
78
|
+
return this.state.contextTokensPath();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
#schedulePersist(): void {
|
|
82
|
+
if (this.#persistTimer) clearTimeout(this.#persistTimer);
|
|
83
|
+
this.#persistTimer = setTimeout(() => {
|
|
84
|
+
this.#persistTimer = undefined;
|
|
85
|
+
this.#persist();
|
|
86
|
+
}, PERSIST_DEBOUNCE_MS);
|
|
87
|
+
this.#persistTimer.unref?.();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
#persist(): void {
|
|
91
|
+
const filePath = this.#filePath();
|
|
92
|
+
try {
|
|
93
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
94
|
+
fs.writeFileSync(filePath, JSON.stringify(Object.fromEntries(this.#tokens)), 'utf-8');
|
|
95
|
+
} catch (error) {
|
|
96
|
+
logger.warn(`contextTokenStore.persist: failed to write ${filePath}: ${String(error)}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export { bodyFromItemList, isMediaItem } from './weixin-inbound.js';
|