@zhin.js/adapter-weixin-ilink 3.0.2 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -0
- package/README.md +18 -40
- package/adapters/weixin-ilink.ts +30 -0
- package/lib/aes-ecb.d.ts +0 -1
- package/lib/aes-ecb.js +0 -1
- package/lib/cdn-upload.d.ts +0 -1
- package/lib/cdn-upload.js +0 -1
- package/lib/cdn-url.d.ts +0 -1
- package/lib/cdn-url.js +0 -1
- package/lib/context-store.d.ts +0 -1
- package/lib/context-store.js +0 -1
- package/lib/credentials.d.ts +0 -1
- package/lib/credentials.js +0 -1
- package/lib/endpoint.d.ts +35 -30
- package/lib/endpoint.js +217 -235
- package/lib/ilink-api.d.ts +0 -1
- package/lib/ilink-api.js +0 -1
- package/lib/ilink-config-cache.d.ts +0 -1
- package/lib/ilink-config-cache.js +0 -1
- package/lib/ilink-logger.d.ts +0 -1
- package/lib/ilink-logger.js +0 -1
- package/lib/ilink-meta.d.ts +0 -1
- package/lib/ilink-meta.js +0 -1
- package/lib/ilink-random.d.ts +0 -1
- package/lib/ilink-random.js +0 -1
- package/lib/ilink-redact.d.ts +0 -1
- package/lib/ilink-redact.js +0 -1
- package/lib/ilink-session-guard.d.ts +0 -1
- package/lib/ilink-session-guard.js +0 -1
- package/lib/ilink-types.d.ts +0 -1
- package/lib/ilink-types.js +0 -1
- package/lib/index.d.ts +4 -19
- package/lib/index.js +2 -37
- package/lib/login.d.ts +4 -6
- package/lib/login.js +43 -65
- package/lib/media-download.d.ts +0 -1
- package/lib/media-download.js +0 -1
- package/lib/mime.d.ts +0 -1
- package/lib/mime.js +0 -1
- package/lib/outbound-media.d.ts +4 -4
- package/lib/outbound-media.js +44 -42
- package/lib/pic-decrypt.d.ts +0 -1
- package/lib/pic-decrypt.js +0 -1
- package/lib/protocol.d.ts +52 -0
- package/lib/protocol.js +109 -0
- package/lib/silk-transcode.d.ts +0 -1
- package/lib/silk-transcode.js +0 -1
- package/lib/types.d.ts +2 -16
- package/lib/types.js +0 -1
- package/lib/upload.d.ts +0 -1
- package/lib/upload.js +0 -1
- package/lib/weixin-inbound.d.ts +0 -1
- package/lib/weixin-inbound.js +0 -1
- package/lib/weixin-send-media.d.ts +0 -1
- package/lib/weixin-send-media.js +0 -1
- package/lib/weixin-send.d.ts +0 -1
- package/lib/weixin-send.js +0 -1
- package/package.json +37 -46
- package/plugin.ts +8 -0
- package/schema.json +52 -0
- package/src/endpoint.ts +261 -279
- package/src/index.ts +24 -53
- package/src/login.ts +56 -72
- package/src/outbound-media.ts +56 -50
- package/src/protocol.ts +172 -0
- package/src/types.ts +6 -15
- package/client/LoginAssistPanel.tsx +0 -200
- package/client/WeixinIlinkManagement.tsx +0 -139
- package/client/index.tsx +0 -11
- package/client/tsconfig.json +0 -13
- package/client/utils/api.ts +0 -30
- package/dist/index.js +0 -29
- package/lib/adapter.d.ts +0 -14
- package/lib/adapter.d.ts.map +0 -1
- package/lib/adapter.js +0 -21
- package/lib/adapter.js.map +0 -1
- package/lib/aes-ecb.d.ts.map +0 -1
- package/lib/aes-ecb.js.map +0 -1
- package/lib/cdn-upload.d.ts.map +0 -1
- package/lib/cdn-upload.js.map +0 -1
- package/lib/cdn-upload.test.d.ts +0 -2
- package/lib/cdn-upload.test.d.ts.map +0 -1
- package/lib/cdn-upload.test.js +0 -150
- package/lib/cdn-upload.test.js.map +0 -1
- package/lib/cdn-url.d.ts.map +0 -1
- package/lib/cdn-url.js.map +0 -1
- package/lib/context-store.d.ts.map +0 -1
- package/lib/context-store.js.map +0 -1
- package/lib/credentials.d.ts.map +0 -1
- package/lib/credentials.js.map +0 -1
- package/lib/endpoint.d.ts.map +0 -1
- package/lib/endpoint.js.map +0 -1
- package/lib/ilink-api.d.ts.map +0 -1
- package/lib/ilink-api.js.map +0 -1
- package/lib/ilink-config-cache.d.ts.map +0 -1
- package/lib/ilink-config-cache.js.map +0 -1
- package/lib/ilink-logger.d.ts.map +0 -1
- package/lib/ilink-logger.js.map +0 -1
- package/lib/ilink-meta.d.ts.map +0 -1
- package/lib/ilink-meta.js.map +0 -1
- package/lib/ilink-random.d.ts.map +0 -1
- package/lib/ilink-random.js.map +0 -1
- package/lib/ilink-redact.d.ts.map +0 -1
- package/lib/ilink-redact.js.map +0 -1
- package/lib/ilink-session-guard.d.ts.map +0 -1
- package/lib/ilink-session-guard.js.map +0 -1
- package/lib/ilink-types.d.ts.map +0 -1
- package/lib/ilink-types.js.map +0 -1
- package/lib/index.d.ts.map +0 -1
- package/lib/index.js.map +0 -1
- package/lib/login-assist-routes.d.ts +0 -7
- package/lib/login-assist-routes.d.ts.map +0 -1
- package/lib/login-assist-routes.js +0 -29
- package/lib/login-assist-routes.js.map +0 -1
- package/lib/login.d.ts.map +0 -1
- package/lib/login.js.map +0 -1
- package/lib/media-download.d.ts.map +0 -1
- package/lib/media-download.js.map +0 -1
- package/lib/mime.d.ts.map +0 -1
- package/lib/mime.js.map +0 -1
- package/lib/mime.test.d.ts +0 -2
- package/lib/mime.test.d.ts.map +0 -1
- package/lib/mime.test.js +0 -62
- package/lib/mime.test.js.map +0 -1
- package/lib/outbound-media.d.ts.map +0 -1
- package/lib/outbound-media.js.map +0 -1
- package/lib/pic-decrypt.d.ts.map +0 -1
- package/lib/pic-decrypt.js.map +0 -1
- package/lib/routes.d.ts +0 -8
- package/lib/routes.d.ts.map +0 -1
- package/lib/routes.js +0 -29
- package/lib/routes.js.map +0 -1
- package/lib/segment-mapper.d.ts +0 -2
- package/lib/segment-mapper.d.ts.map +0 -1
- package/lib/segment-mapper.js +0 -2
- package/lib/segment-mapper.js.map +0 -1
- package/lib/silk-transcode.d.ts.map +0 -1
- package/lib/silk-transcode.js.map +0 -1
- package/lib/silk-transcode.test.d.ts +0 -2
- package/lib/silk-transcode.test.d.ts.map +0 -1
- package/lib/silk-transcode.test.js +0 -53
- package/lib/silk-transcode.test.js.map +0 -1
- package/lib/types.d.ts.map +0 -1
- package/lib/types.js.map +0 -1
- package/lib/upload.d.ts.map +0 -1
- package/lib/upload.js.map +0 -1
- package/lib/upload.test.d.ts +0 -2
- package/lib/upload.test.d.ts.map +0 -1
- package/lib/upload.test.js +0 -250
- package/lib/upload.test.js.map +0 -1
- package/lib/weixin-inbound.d.ts.map +0 -1
- package/lib/weixin-inbound.js.map +0 -1
- package/lib/weixin-send-media.d.ts.map +0 -1
- package/lib/weixin-send-media.js.map +0 -1
- package/lib/weixin-send.d.ts.map +0 -1
- package/lib/weixin-send.js.map +0 -1
- package/src/adapter.ts +0 -24
- package/src/login-assist-routes.ts +0 -36
- package/src/routes.ts +0 -39
- package/src/segment-mapper.ts +0 -1
package/src/index.ts
CHANGED
|
@@ -1,55 +1,26 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
1
|
+
export {
|
|
2
|
+
formatInboundContent,
|
|
3
|
+
formatOutboundSegments,
|
|
4
|
+
inboundMessageId,
|
|
5
|
+
resolveWeixinIlinkConfig,
|
|
6
|
+
segmentLocalPath,
|
|
7
|
+
sleep,
|
|
8
|
+
type ResolvedWeixinIlinkConfig,
|
|
9
|
+
type WeixinIlinkAdapterConfig,
|
|
10
|
+
type WeixinIlinkEndpointConfig,
|
|
11
|
+
type WeixinInboundMediaPaths,
|
|
12
|
+
type WeixinMessageWithMedia,
|
|
13
|
+
type WeixinWireSegment,
|
|
14
|
+
} from './protocol.js';
|
|
10
15
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
interface Contexts {
|
|
14
|
-
web: PageManager;
|
|
15
|
-
router: Router;
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
interface Adapters {
|
|
19
|
-
"weixin-ilink": WeixinIlinkAdapter;
|
|
20
|
-
}
|
|
21
|
-
}
|
|
16
|
+
export type { WeixinMessage, MessageItem } from './ilink-types.js';
|
|
17
|
+
export type { WeixinIlinkCredentials } from './credentials.js';
|
|
22
18
|
|
|
23
|
-
export
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
provide({
|
|
32
|
-
name: "weixin-ilink",
|
|
33
|
-
description: "WeChat personal account via Tencent iLink Endpoint API (ClawBot)",
|
|
34
|
-
mounted: async (p: Plugin) => {
|
|
35
|
-
const adapter = new WeixinIlinkAdapter(p);
|
|
36
|
-
await adapter.start();
|
|
37
|
-
return adapter;
|
|
38
|
-
},
|
|
39
|
-
dispose: async (adapter: WeixinIlinkAdapter) => {
|
|
40
|
-
await adapter.stop();
|
|
41
|
-
},
|
|
42
|
-
} as any);
|
|
43
|
-
|
|
44
|
-
useContext("web", (pageManager) => {
|
|
45
|
-
pageManager.addEntry({
|
|
46
|
-
id: "weixin-ilink",
|
|
47
|
-
development: path.resolve(import.meta.dirname, "../client/index.tsx"),
|
|
48
|
-
production: path.resolve(import.meta.dirname, "../dist/index.js"),
|
|
49
|
-
meta: { name: "微信 iLink" },
|
|
50
|
-
});
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
useContext("router", "weixin-ilink", async (router: Router, adapter: WeixinIlinkAdapter) => {
|
|
54
|
-
registerRoutes(router, adapter, root);
|
|
55
|
-
});
|
|
19
|
+
export {
|
|
20
|
+
WeixinIlinkEndpoint,
|
|
21
|
+
type WeixinIlinkEndpointOptions,
|
|
22
|
+
type WeixinIlinkGetUpdates,
|
|
23
|
+
type WeixinIlinkNotifyStart,
|
|
24
|
+
type WeixinIlinkNotifyStop,
|
|
25
|
+
type WeixinIlinkSendText,
|
|
26
|
+
} from './endpoint.js';
|
package/src/login.ts
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Resolve iLink bot credentials (env / config / sidecar file / QR login).
|
|
3
|
+
* No legacy Plugin / loginAssist dependency — QR URL is logged for manual scan.
|
|
4
|
+
*/
|
|
5
|
+
import { apiGetFetch, apiPostFetch } from './ilink-api.js';
|
|
6
|
+
import { buildBaseInfo, DEFAULT_API_BASE_URL } from './ilink-meta.js';
|
|
7
|
+
import { logger } from './ilink-logger.js';
|
|
5
8
|
import {
|
|
6
9
|
loadCredentials,
|
|
7
10
|
saveCredentials,
|
|
8
11
|
type WeixinIlinkCredentials,
|
|
9
|
-
} from
|
|
10
|
-
import type {
|
|
12
|
+
} from './credentials.js';
|
|
13
|
+
import type { ResolvedWeixinIlinkConfig } from './protocol.js';
|
|
11
14
|
|
|
12
|
-
const DEFAULT_ILINK_BOT_TYPE =
|
|
15
|
+
const DEFAULT_ILINK_BOT_TYPE = '3';
|
|
13
16
|
const QR_LONG_POLL_TIMEOUT_MS = 35_000;
|
|
14
17
|
const LOGIN_TIMEOUT_MS = 480_000;
|
|
15
18
|
|
|
@@ -20,14 +23,14 @@ interface QRCodeResponse {
|
|
|
20
23
|
|
|
21
24
|
interface StatusResponse {
|
|
22
25
|
status:
|
|
23
|
-
|
|
|
24
|
-
|
|
|
25
|
-
|
|
|
26
|
-
|
|
|
27
|
-
|
|
|
28
|
-
|
|
|
29
|
-
|
|
|
30
|
-
|
|
|
26
|
+
| 'wait'
|
|
27
|
+
| 'scaned'
|
|
28
|
+
| 'confirmed'
|
|
29
|
+
| 'expired'
|
|
30
|
+
| 'scaned_but_redirect'
|
|
31
|
+
| 'need_verifycode'
|
|
32
|
+
| 'verify_code_blocked'
|
|
33
|
+
| 'binded_redirect';
|
|
31
34
|
bot_token?: string;
|
|
32
35
|
ilink_endpoint_id?: string;
|
|
33
36
|
baseurl?: string;
|
|
@@ -44,7 +47,7 @@ async function fetchQRCode(apiBaseUrl: string, botType: string): Promise<QRCodeR
|
|
|
44
47
|
baseUrl: apiBaseUrl,
|
|
45
48
|
endpoint: `ilink/bot/get_bot_qrcode?bot_type=${encodeURIComponent(botType)}`,
|
|
46
49
|
body: JSON.stringify({ local_token_list: getLocalBotTokenList(), base_info: buildBaseInfo() }),
|
|
47
|
-
label:
|
|
50
|
+
label: 'fetchQRCode',
|
|
48
51
|
});
|
|
49
52
|
return JSON.parse(rawText) as QRCodeResponse;
|
|
50
53
|
}
|
|
@@ -59,21 +62,20 @@ async function pollQRStatus(
|
|
|
59
62
|
baseUrl: apiBaseUrl,
|
|
60
63
|
endpoint,
|
|
61
64
|
timeoutMs: QR_LONG_POLL_TIMEOUT_MS,
|
|
62
|
-
label:
|
|
65
|
+
label: 'pollQRStatus',
|
|
63
66
|
});
|
|
64
67
|
return JSON.parse(rawText) as StatusResponse;
|
|
65
68
|
} catch (err) {
|
|
66
|
-
if (err instanceof Error && err.name ===
|
|
67
|
-
return { status:
|
|
69
|
+
if (err instanceof Error && err.name === 'AbortError') {
|
|
70
|
+
return { status: 'wait' };
|
|
68
71
|
}
|
|
69
72
|
logger.warn(`pollQRStatus error: ${String(err)}`);
|
|
70
|
-
return { status:
|
|
73
|
+
return { status: 'wait' };
|
|
71
74
|
}
|
|
72
75
|
}
|
|
73
76
|
|
|
74
77
|
export async function resolveCredentials(
|
|
75
|
-
|
|
76
|
-
config: WeixinIlinkEndpointConfig,
|
|
78
|
+
config: ResolvedWeixinIlinkConfig,
|
|
77
79
|
): Promise<WeixinIlinkCredentials> {
|
|
78
80
|
const envToken = process.env.WEIXIN_ILINK_TOKEN?.trim() || config.botToken?.trim();
|
|
79
81
|
if (envToken) {
|
|
@@ -94,73 +96,55 @@ export async function resolveCredentials(
|
|
|
94
96
|
};
|
|
95
97
|
}
|
|
96
98
|
|
|
97
|
-
return loginWithQr(
|
|
99
|
+
return loginWithQr(config);
|
|
98
100
|
}
|
|
99
101
|
|
|
100
102
|
export async function loginWithQr(
|
|
101
|
-
|
|
102
|
-
config: WeixinIlinkEndpointConfig,
|
|
103
|
+
config: ResolvedWeixinIlinkConfig,
|
|
103
104
|
): Promise<WeixinIlinkCredentials> {
|
|
104
105
|
const apiBaseUrl = config.baseUrl ?? DEFAULT_API_BASE_URL;
|
|
105
106
|
const qr = await fetchQRCode(apiBaseUrl, DEFAULT_ILINK_BOT_TYPE);
|
|
106
107
|
const qrcodeUrl = qr.qrcode_img_content || qr.qrcode;
|
|
107
108
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
}
|
|
112
|
-
void loginAssist.waitForInput("weixin-ilink", config.name, "qrcode", {
|
|
113
|
-
message: "请用微信扫描 ClawBot 二维码完成登录",
|
|
114
|
-
image: qrcodeUrl.startsWith("data:") ? qrcodeUrl : undefined,
|
|
115
|
-
url: qrcodeUrl.startsWith("http") ? qrcodeUrl : undefined,
|
|
116
|
-
qrcode: qr.qrcode,
|
|
117
|
-
});
|
|
118
|
-
const assistTask = loginAssist
|
|
119
|
-
.listPending()
|
|
120
|
-
.find((t) => t.adapter === "weixin-ilink" && t.endpointId === config.name);
|
|
109
|
+
logger.info(
|
|
110
|
+
`weixin-ilink QR login for ${config.name}: scan with WeChat ClawBot entry — ${qrcodeUrl.slice(0, 120)}${qrcodeUrl.length > 120 ? '…' : ''}`,
|
|
111
|
+
);
|
|
121
112
|
|
|
122
113
|
const deadline = Date.now() + LOGIN_TIMEOUT_MS;
|
|
123
114
|
let currentBaseUrl = apiBaseUrl;
|
|
124
115
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const status = await pollQRStatus(currentBaseUrl, qr.qrcode);
|
|
116
|
+
while (Date.now() < deadline) {
|
|
117
|
+
const status = await pollQRStatus(currentBaseUrl, qr.qrcode);
|
|
128
118
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
119
|
+
if (status.status === 'scaned_but_redirect' && status.redirect_host) {
|
|
120
|
+
currentBaseUrl = status.redirect_host.startsWith('http')
|
|
121
|
+
? status.redirect_host
|
|
122
|
+
: `https://${status.redirect_host}`;
|
|
123
|
+
}
|
|
134
124
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
}
|
|
139
|
-
const creds: WeixinIlinkCredentials = {
|
|
140
|
-
botToken: status.bot_token ?? loadCredentials(config.name)?.botToken ?? "",
|
|
141
|
-
ilinkUserId: status.ilink_user_id,
|
|
142
|
-
ilinkBotId: status.ilink_endpoint_id,
|
|
143
|
-
baseUrl: status.baseurl ?? currentBaseUrl,
|
|
144
|
-
};
|
|
145
|
-
if (!creds.botToken) {
|
|
146
|
-
throw new Error("binded_redirect 但本地无有效 bot_token");
|
|
147
|
-
}
|
|
148
|
-
saveCredentials(config.name, creds);
|
|
149
|
-
if (assistTask) loginAssist.submit(assistTask.id, "confirmed");
|
|
150
|
-
return creds;
|
|
125
|
+
if (status.status === 'confirmed' || status.status === 'binded_redirect') {
|
|
126
|
+
if (!status.bot_token && status.status !== 'binded_redirect') {
|
|
127
|
+
throw new Error('扫码成功但未返回 bot_token');
|
|
151
128
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
129
|
+
const creds: WeixinIlinkCredentials = {
|
|
130
|
+
botToken: status.bot_token ?? loadCredentials(config.name)?.botToken ?? '',
|
|
131
|
+
ilinkUserId: status.ilink_user_id,
|
|
132
|
+
ilinkBotId: status.ilink_endpoint_id,
|
|
133
|
+
baseUrl: status.baseurl ?? currentBaseUrl,
|
|
134
|
+
};
|
|
135
|
+
if (!creds.botToken) {
|
|
136
|
+
throw new Error('binded_redirect 但本地无有效 bot_token');
|
|
156
137
|
}
|
|
138
|
+
saveCredentials(config.name, creds);
|
|
139
|
+
return creds;
|
|
140
|
+
}
|
|
157
141
|
|
|
158
|
-
|
|
142
|
+
if (status.status === 'expired') {
|
|
143
|
+
throw new Error('二维码已过期,请重启 bot 重新登录');
|
|
159
144
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
} catch (err) {
|
|
163
|
-
if (assistTask) loginAssist.cancel(assistTask.id, String(err));
|
|
164
|
-
throw err;
|
|
145
|
+
|
|
146
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
165
147
|
}
|
|
148
|
+
|
|
149
|
+
throw new Error('微信扫码登录超时');
|
|
166
150
|
}
|
package/src/outbound-media.ts
CHANGED
|
@@ -1,92 +1,98 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 微信 iLink 出站媒体:base64 / 远程 URL 须落盘后再走 CDN 上传。
|
|
3
3
|
*/
|
|
4
|
-
import crypto from
|
|
5
|
-
import fs from
|
|
6
|
-
import path from
|
|
7
|
-
import
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { downloadRemoteImageToTemp } from './upload.js';
|
|
8
|
+
import { getExtensionFromMime } from './mime.js';
|
|
9
|
+
import type { WeixinWireSegment } from './protocol.js';
|
|
10
10
|
|
|
11
|
-
const MEDIA_TYPES = new Set([
|
|
11
|
+
const MEDIA_TYPES = new Set(['image', 'video', 'file', 'record', 'audio']);
|
|
12
|
+
|
|
13
|
+
export type OutboundWireContent = string | WeixinWireSegment | ReadonlyArray<string | WeixinWireSegment>;
|
|
12
14
|
|
|
13
15
|
function isRemoteUrl(value: string): boolean {
|
|
14
16
|
return /^https?:\/\//i.test(value);
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
function isInlineMediaRef(value: string): boolean {
|
|
18
|
-
return value.startsWith(
|
|
20
|
+
return value.startsWith('base64://') || /^data:[^/]+\/[^;]+;base64,/i.test(value);
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
function normalizeLocalPath(value: string): string {
|
|
22
|
-
return value.replace(/^file:\/\//,
|
|
24
|
+
return value.replace(/^file:\/\//, '');
|
|
23
25
|
}
|
|
24
26
|
|
|
25
|
-
export function segmentMediaRef(seg:
|
|
26
|
-
const data = seg.data as Record<string, unknown
|
|
27
|
-
|
|
27
|
+
export function segmentMediaRef(seg: WeixinWireSegment): string | undefined {
|
|
28
|
+
const data = seg.data as Record<string, unknown> | undefined;
|
|
29
|
+
if (!data) return undefined;
|
|
30
|
+
const candidates = [data.file, data.path, data.url].filter(
|
|
31
|
+
(v): v is string => typeof v === 'string' && Boolean(v),
|
|
32
|
+
);
|
|
28
33
|
return candidates[0];
|
|
29
34
|
}
|
|
30
35
|
|
|
31
36
|
function extractBase64Buffer(data: Record<string, unknown>): Buffer | undefined {
|
|
32
|
-
const url = typeof data.url ===
|
|
33
|
-
const file = typeof data.file ===
|
|
34
|
-
const base64 = typeof data.base64 ===
|
|
37
|
+
const url = typeof data.url === 'string' ? data.url : undefined;
|
|
38
|
+
const file = typeof data.file === 'string' ? data.file : undefined;
|
|
39
|
+
const base64 = typeof data.base64 === 'string' ? data.base64 : undefined;
|
|
35
40
|
|
|
36
|
-
if (url?.startsWith(
|
|
37
|
-
return Buffer.from(url.slice(9),
|
|
41
|
+
if (url?.startsWith('base64://')) {
|
|
42
|
+
return Buffer.from(url.slice(9), 'base64');
|
|
38
43
|
}
|
|
39
44
|
if (url && /^data:[^/]+\/[^;]+;base64,/i.test(url)) {
|
|
40
|
-
return Buffer.from(url.replace(/^data:[^/]+\/[^;]+;base64,/,
|
|
45
|
+
return Buffer.from(url.replace(/^data:[^/]+\/[^;]+;base64,/, ''), 'base64');
|
|
41
46
|
}
|
|
42
|
-
if (file?.startsWith(
|
|
43
|
-
return Buffer.from(file.slice(9),
|
|
47
|
+
if (file?.startsWith('base64://')) {
|
|
48
|
+
return Buffer.from(file.slice(9), 'base64');
|
|
44
49
|
}
|
|
45
50
|
if (base64) {
|
|
46
|
-
const raw = base64.startsWith(
|
|
47
|
-
return Buffer.from(raw,
|
|
51
|
+
const raw = base64.startsWith('base64://') ? base64.slice(9) : base64;
|
|
52
|
+
return Buffer.from(raw, 'base64');
|
|
48
53
|
}
|
|
49
54
|
return undefined;
|
|
50
55
|
}
|
|
51
56
|
|
|
52
|
-
function resolveExt(seg:
|
|
53
|
-
const data = seg.data as Record<string, unknown>;
|
|
54
|
-
const name = typeof data.name ===
|
|
57
|
+
function resolveExt(seg: WeixinWireSegment, mime?: string): string {
|
|
58
|
+
const data = (seg.data ?? {}) as Record<string, unknown>;
|
|
59
|
+
const name = typeof data.name === 'string' ? data.name : undefined;
|
|
55
60
|
if (name) {
|
|
56
61
|
const ext = path.extname(name);
|
|
57
62
|
if (ext) return ext;
|
|
58
63
|
}
|
|
59
|
-
const mimeHint = mime
|
|
64
|
+
const mimeHint = mime
|
|
65
|
+
?? (typeof data.mime === 'string' ? data.mime : typeof data.mimeType === 'string' ? data.mimeType : undefined);
|
|
60
66
|
if (mimeHint) {
|
|
61
|
-
return getExtensionFromMime(mimeHint) ??
|
|
67
|
+
return getExtensionFromMime(mimeHint) ?? '.bin';
|
|
62
68
|
}
|
|
63
|
-
if (seg.type ===
|
|
64
|
-
if (seg.type ===
|
|
65
|
-
if (seg.type ===
|
|
66
|
-
return
|
|
69
|
+
if (seg.type === 'image') return '.png';
|
|
70
|
+
if (seg.type === 'video') return '.mp4';
|
|
71
|
+
if (seg.type === 'record' || seg.type === 'audio') return '.mp3';
|
|
72
|
+
return '.bin';
|
|
67
73
|
}
|
|
68
74
|
|
|
69
|
-
function withLocalFile(seg:
|
|
70
|
-
const data = { ...seg.data, file: filePath } as Record<string, unknown>;
|
|
75
|
+
function withLocalFile(seg: WeixinWireSegment, filePath: string): WeixinWireSegment {
|
|
76
|
+
const data = { ...(seg.data ?? {}), file: filePath } as Record<string, unknown>;
|
|
71
77
|
delete data.base64;
|
|
72
|
-
return { type: seg.type, data }
|
|
78
|
+
return { type: seg.type, data };
|
|
73
79
|
}
|
|
74
80
|
|
|
75
|
-
function writeOutboundBuffer(buffer: Buffer, outboundDir: string, seg:
|
|
81
|
+
function writeOutboundBuffer(buffer: Buffer, outboundDir: string, seg: WeixinWireSegment): string {
|
|
76
82
|
fs.mkdirSync(outboundDir, { recursive: true });
|
|
77
83
|
const ext = resolveExt(seg);
|
|
78
84
|
const filePath = path.join(
|
|
79
85
|
outboundDir,
|
|
80
|
-
`out-${seg.type}-${Date.now()}-${crypto.randomBytes(4).toString(
|
|
86
|
+
`out-${seg.type}-${Date.now()}-${crypto.randomBytes(4).toString('hex')}${ext}`,
|
|
81
87
|
);
|
|
82
88
|
fs.writeFileSync(filePath, buffer);
|
|
83
89
|
return filePath;
|
|
84
90
|
}
|
|
85
91
|
|
|
86
92
|
async function materializeSegment(
|
|
87
|
-
seg:
|
|
93
|
+
seg: WeixinWireSegment,
|
|
88
94
|
outboundDir: string,
|
|
89
|
-
): Promise<
|
|
95
|
+
): Promise<WeixinWireSegment> {
|
|
90
96
|
if (!MEDIA_TYPES.has(seg.type)) return seg;
|
|
91
97
|
|
|
92
98
|
const ref = segmentMediaRef(seg);
|
|
@@ -101,7 +107,7 @@ async function materializeSegment(
|
|
|
101
107
|
}
|
|
102
108
|
}
|
|
103
109
|
|
|
104
|
-
const buffer = extractBase64Buffer(seg.data as Record<string, unknown>);
|
|
110
|
+
const buffer = extractBase64Buffer((seg.data ?? {}) as Record<string, unknown>);
|
|
105
111
|
if (buffer) {
|
|
106
112
|
return withLocalFile(seg, writeOutboundBuffer(buffer, outboundDir, seg));
|
|
107
113
|
}
|
|
@@ -109,30 +115,30 @@ async function materializeSegment(
|
|
|
109
115
|
return seg;
|
|
110
116
|
}
|
|
111
117
|
|
|
112
|
-
function asSegments(content:
|
|
113
|
-
if (typeof content ===
|
|
114
|
-
return [{ type:
|
|
118
|
+
function asSegments(content: OutboundWireContent): WeixinWireSegment[] {
|
|
119
|
+
if (typeof content === 'string') {
|
|
120
|
+
return [{ type: 'text', data: { text: content } }];
|
|
115
121
|
}
|
|
116
122
|
if (!Array.isArray(content)) {
|
|
117
|
-
return typeof content ===
|
|
118
|
-
? [content
|
|
123
|
+
return typeof content === 'object' && content !== null && 'type' in content
|
|
124
|
+
? [content]
|
|
119
125
|
: [];
|
|
120
126
|
}
|
|
121
127
|
return content.flatMap((item) => {
|
|
122
|
-
if (typeof item ===
|
|
123
|
-
return typeof item ===
|
|
128
|
+
if (typeof item === 'string') return [{ type: 'text', data: { text: item } }];
|
|
129
|
+
return typeof item === 'object' && item !== null && 'type' in item ? [item] : [];
|
|
124
130
|
});
|
|
125
131
|
}
|
|
126
132
|
|
|
127
133
|
/** 将 base64 / 远程图落盘,供 sendWeixinMediaFile 上传 */
|
|
128
134
|
export async function materializeOutboundMedia(
|
|
129
|
-
content:
|
|
135
|
+
content: OutboundWireContent,
|
|
130
136
|
outboundDir: string,
|
|
131
|
-
): Promise<
|
|
137
|
+
): Promise<OutboundWireContent> {
|
|
132
138
|
const segments = asSegments(content);
|
|
133
139
|
const out = await Promise.all(segments.map((seg) => materializeSegment(seg, outboundDir)));
|
|
134
|
-
if (typeof content ===
|
|
135
|
-
return out[0]?.type ===
|
|
140
|
+
if (typeof content === 'string') {
|
|
141
|
+
return out[0]?.type === 'text' ? String((out[0].data as { text?: string })?.text ?? content) : content;
|
|
136
142
|
}
|
|
137
143
|
if (!Array.isArray(content)) {
|
|
138
144
|
return out[0] ?? content;
|
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Weixin iLink protocol helpers (no legacy Adapter/Endpoint / segment-mapper).
|
|
3
|
+
* Canonicalization is owned by gateway/core before endpoint.send.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { pickCredential } from '@zhin.js/adapter';
|
|
7
|
+
import { bodyFromItemList, isMediaItem } from './weixin-inbound.js';
|
|
8
|
+
import { DEFAULT_API_BASE_URL, DEFAULT_CDN_BASE_URL } from './ilink-meta.js';
|
|
9
|
+
import type { WeixinMessage } from './ilink-types.js';
|
|
10
|
+
|
|
11
|
+
/** Plugin Runtime owner config (`plugins.<instanceKey>` / schema.json). */
|
|
12
|
+
export interface WeixinIlinkAdapterConfig {
|
|
13
|
+
readonly name?: string;
|
|
14
|
+
readonly botAgent?: string;
|
|
15
|
+
readonly baseUrl?: string;
|
|
16
|
+
readonly cdnBaseUrl?: string;
|
|
17
|
+
readonly longPollTimeoutMs?: number;
|
|
18
|
+
readonly botToken?: string;
|
|
19
|
+
/** Transitional: legacy root `endpoints[]` with `context: weixin-ilink`. */
|
|
20
|
+
readonly endpoints?: ReadonlyArray<Partial<ResolvedWeixinIlinkConfig> & {
|
|
21
|
+
readonly context?: string;
|
|
22
|
+
}>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ResolvedWeixinIlinkConfig {
|
|
26
|
+
readonly context: 'weixin-ilink';
|
|
27
|
+
readonly name: string;
|
|
28
|
+
readonly botAgent?: string;
|
|
29
|
+
readonly baseUrl: string;
|
|
30
|
+
readonly cdnBaseUrl: string;
|
|
31
|
+
readonly longPollTimeoutMs: number;
|
|
32
|
+
readonly botToken?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type WeixinIlinkEndpointConfig = ResolvedWeixinIlinkConfig;
|
|
36
|
+
|
|
37
|
+
export interface WeixinWireSegment {
|
|
38
|
+
readonly type: string;
|
|
39
|
+
readonly data?: Record<string, unknown>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface WeixinInboundMediaPaths {
|
|
43
|
+
readonly decryptedPicPath?: string;
|
|
44
|
+
readonly decryptedVideoPath?: string;
|
|
45
|
+
readonly decryptedFilePath?: string;
|
|
46
|
+
readonly decryptedVoicePath?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type WeixinMessageWithMedia = WeixinMessage & {
|
|
50
|
+
readonly _media?: WeixinInboundMediaPaths;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000;
|
|
54
|
+
|
|
55
|
+
export function resolveWeixinIlinkConfig(
|
|
56
|
+
config: WeixinIlinkAdapterConfig = {},
|
|
57
|
+
): ResolvedWeixinIlinkConfig {
|
|
58
|
+
const entry = config.endpoints?.find((item) => item.context === 'weixin-ilink');
|
|
59
|
+
const name = (typeof config.name === 'string' && config.name)
|
|
60
|
+
|| (typeof entry?.name === 'string' && entry.name)
|
|
61
|
+
|| process.env.WEIXIN_ILINK_BOT_NAME
|
|
62
|
+
|| 'weixin-ilink-bot';
|
|
63
|
+
const botToken = pickCredential(
|
|
64
|
+
typeof config.botToken === 'string' ? config.botToken.trim() : config.botToken,
|
|
65
|
+
typeof entry?.botToken === 'string' ? entry.botToken.trim() : entry?.botToken,
|
|
66
|
+
process.env.WEIXIN_ILINK_TOKEN?.trim(),
|
|
67
|
+
);
|
|
68
|
+
// No throw here: missing botToken is resolved later by resolveCredentials()
|
|
69
|
+
// (sidecar credentials file / QR login) during endpoint.start().
|
|
70
|
+
return {
|
|
71
|
+
context: 'weixin-ilink',
|
|
72
|
+
name,
|
|
73
|
+
botAgent: config.botAgent ?? entry?.botAgent,
|
|
74
|
+
baseUrl: config.baseUrl ?? entry?.baseUrl ?? DEFAULT_API_BASE_URL,
|
|
75
|
+
cdnBaseUrl: config.cdnBaseUrl ?? entry?.cdnBaseUrl ?? DEFAULT_CDN_BASE_URL,
|
|
76
|
+
longPollTimeoutMs: config.longPollTimeoutMs
|
|
77
|
+
?? entry?.longPollTimeoutMs
|
|
78
|
+
?? DEFAULT_LONG_POLL_TIMEOUT_MS,
|
|
79
|
+
botToken,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Build inbound text for MessageGateway.receive (gateway owns reply routing). */
|
|
84
|
+
export function formatInboundContent(msg: WeixinMessageWithMedia): string {
|
|
85
|
+
const parts: string[] = [];
|
|
86
|
+
const text = bodyFromItemList(msg.item_list);
|
|
87
|
+
if (text) parts.push(text);
|
|
88
|
+
|
|
89
|
+
const media = msg._media;
|
|
90
|
+
if (media?.decryptedPicPath) {
|
|
91
|
+
parts.push(`[image: ${media.decryptedPicPath}]`);
|
|
92
|
+
} else if (media?.decryptedVideoPath) {
|
|
93
|
+
parts.push(`[video: ${media.decryptedVideoPath}]`);
|
|
94
|
+
} else if (media?.decryptedFilePath) {
|
|
95
|
+
parts.push(`[file: ${media.decryptedFilePath}]`);
|
|
96
|
+
} else if (media?.decryptedVoicePath) {
|
|
97
|
+
parts.push(`[record: ${media.decryptedVoicePath}]`);
|
|
98
|
+
} else if (!text && msg.item_list?.some((item) => isMediaItem(item))) {
|
|
99
|
+
parts.push('[媒体消息]');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return parts.join('\n').trim() || '';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function inboundMessageId(msg: WeixinMessage): string {
|
|
106
|
+
return String(msg.message_id ?? msg.client_id ?? msg.seq ?? Date.now());
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Wire-encode an already-rendered outbound payload into Weixin wire segments.
|
|
111
|
+
* Segment canonicalization is intentionally not done here.
|
|
112
|
+
*/
|
|
113
|
+
export function formatOutboundSegments(payload: unknown): WeixinWireSegment[] {
|
|
114
|
+
if (typeof payload === 'string') {
|
|
115
|
+
return [{ type: 'text', data: { text: payload } }];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const items: Array<string | WeixinWireSegment> = Array.isArray(payload)
|
|
119
|
+
? payload as Array<string | WeixinWireSegment>
|
|
120
|
+
: payload && typeof payload === 'object' && 'type' in (payload as object)
|
|
121
|
+
? [payload as WeixinWireSegment]
|
|
122
|
+
: [];
|
|
123
|
+
|
|
124
|
+
if (items.length === 0) {
|
|
125
|
+
const text = payload == null
|
|
126
|
+
? ''
|
|
127
|
+
: typeof payload === 'object'
|
|
128
|
+
? JSON.stringify(payload)
|
|
129
|
+
: String(payload);
|
|
130
|
+
return [{ type: 'text', data: { text } }];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const segs: WeixinWireSegment[] = [];
|
|
134
|
+
for (const item of items) {
|
|
135
|
+
if (typeof item === 'string') {
|
|
136
|
+
segs.push({ type: 'text', data: { text: item } });
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
segs.push({ type: item.type, data: item.data ?? {} });
|
|
140
|
+
}
|
|
141
|
+
return segs.length ? segs : [{ type: 'text', data: { text: '' } }];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function segmentLocalPath(seg: WeixinWireSegment): string | undefined {
|
|
145
|
+
const data = seg.data ?? {};
|
|
146
|
+
const candidates = [data.file, data.path, data.url].filter(
|
|
147
|
+
(value): value is string => typeof value === 'string' && Boolean(value),
|
|
148
|
+
);
|
|
149
|
+
const ref = candidates[0];
|
|
150
|
+
if (!ref || ref.startsWith('base64://') || /^data:/.test(ref) || /^https?:\/\//i.test(ref)) {
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
return ref.replace(/^file:\/\//, '');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
157
|
+
return new Promise((resolve) => {
|
|
158
|
+
if (signal?.aborted) {
|
|
159
|
+
resolve();
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const timer = setTimeout(resolve, ms);
|
|
163
|
+
signal?.addEventListener(
|
|
164
|
+
'abort',
|
|
165
|
+
() => {
|
|
166
|
+
clearTimeout(timer);
|
|
167
|
+
resolve();
|
|
168
|
+
},
|
|
169
|
+
{ once: true },
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -1,16 +1,7 @@
|
|
|
1
|
-
export
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
/** iLink API 根地址,默认 https://ilinkai.weixin.qq.com */
|
|
7
|
-
baseUrl?: string;
|
|
8
|
-
/** CDN 根地址 */
|
|
9
|
-
cdnBaseUrl?: string;
|
|
10
|
-
/** 长轮询超时(ms),默认 35000 */
|
|
11
|
-
longPollTimeoutMs?: number;
|
|
12
|
-
/** 直接配置 token(优先于侧车凭证文件) */
|
|
13
|
-
botToken?: string;
|
|
14
|
-
}
|
|
1
|
+
export type {
|
|
2
|
+
WeixinIlinkEndpointConfig,
|
|
3
|
+
WeixinIlinkAdapterConfig,
|
|
4
|
+
ResolvedWeixinIlinkConfig,
|
|
5
|
+
} from './protocol.js';
|
|
15
6
|
|
|
16
|
-
export type { WeixinMessage, MessageItem } from
|
|
7
|
+
export type { WeixinMessage, MessageItem } from './ilink-types.js';
|