@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.
Files changed (160) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +18 -40
  3. package/adapters/weixin-ilink.ts +30 -0
  4. package/lib/aes-ecb.d.ts +0 -1
  5. package/lib/aes-ecb.js +0 -1
  6. package/lib/cdn-upload.d.ts +0 -1
  7. package/lib/cdn-upload.js +0 -1
  8. package/lib/cdn-url.d.ts +0 -1
  9. package/lib/cdn-url.js +0 -1
  10. package/lib/context-store.d.ts +0 -1
  11. package/lib/context-store.js +0 -1
  12. package/lib/credentials.d.ts +0 -1
  13. package/lib/credentials.js +0 -1
  14. package/lib/endpoint.d.ts +35 -30
  15. package/lib/endpoint.js +217 -235
  16. package/lib/ilink-api.d.ts +0 -1
  17. package/lib/ilink-api.js +0 -1
  18. package/lib/ilink-config-cache.d.ts +0 -1
  19. package/lib/ilink-config-cache.js +0 -1
  20. package/lib/ilink-logger.d.ts +0 -1
  21. package/lib/ilink-logger.js +0 -1
  22. package/lib/ilink-meta.d.ts +0 -1
  23. package/lib/ilink-meta.js +0 -1
  24. package/lib/ilink-random.d.ts +0 -1
  25. package/lib/ilink-random.js +0 -1
  26. package/lib/ilink-redact.d.ts +0 -1
  27. package/lib/ilink-redact.js +0 -1
  28. package/lib/ilink-session-guard.d.ts +0 -1
  29. package/lib/ilink-session-guard.js +0 -1
  30. package/lib/ilink-types.d.ts +0 -1
  31. package/lib/ilink-types.js +0 -1
  32. package/lib/index.d.ts +4 -19
  33. package/lib/index.js +2 -37
  34. package/lib/login.d.ts +4 -6
  35. package/lib/login.js +43 -65
  36. package/lib/media-download.d.ts +0 -1
  37. package/lib/media-download.js +0 -1
  38. package/lib/mime.d.ts +0 -1
  39. package/lib/mime.js +0 -1
  40. package/lib/outbound-media.d.ts +4 -4
  41. package/lib/outbound-media.js +44 -42
  42. package/lib/pic-decrypt.d.ts +0 -1
  43. package/lib/pic-decrypt.js +0 -1
  44. package/lib/protocol.d.ts +52 -0
  45. package/lib/protocol.js +109 -0
  46. package/lib/silk-transcode.d.ts +0 -1
  47. package/lib/silk-transcode.js +0 -1
  48. package/lib/types.d.ts +2 -16
  49. package/lib/types.js +0 -1
  50. package/lib/upload.d.ts +0 -1
  51. package/lib/upload.js +0 -1
  52. package/lib/weixin-inbound.d.ts +0 -1
  53. package/lib/weixin-inbound.js +0 -1
  54. package/lib/weixin-send-media.d.ts +0 -1
  55. package/lib/weixin-send-media.js +0 -1
  56. package/lib/weixin-send.d.ts +0 -1
  57. package/lib/weixin-send.js +0 -1
  58. package/package.json +37 -46
  59. package/plugin.ts +8 -0
  60. package/schema.json +52 -0
  61. package/src/endpoint.ts +261 -279
  62. package/src/index.ts +24 -53
  63. package/src/login.ts +56 -72
  64. package/src/outbound-media.ts +56 -50
  65. package/src/protocol.ts +172 -0
  66. package/src/types.ts +6 -15
  67. package/client/LoginAssistPanel.tsx +0 -200
  68. package/client/WeixinIlinkManagement.tsx +0 -139
  69. package/client/index.tsx +0 -11
  70. package/client/tsconfig.json +0 -13
  71. package/client/utils/api.ts +0 -30
  72. package/dist/index.js +0 -29
  73. package/lib/adapter.d.ts +0 -14
  74. package/lib/adapter.d.ts.map +0 -1
  75. package/lib/adapter.js +0 -21
  76. package/lib/adapter.js.map +0 -1
  77. package/lib/aes-ecb.d.ts.map +0 -1
  78. package/lib/aes-ecb.js.map +0 -1
  79. package/lib/cdn-upload.d.ts.map +0 -1
  80. package/lib/cdn-upload.js.map +0 -1
  81. package/lib/cdn-upload.test.d.ts +0 -2
  82. package/lib/cdn-upload.test.d.ts.map +0 -1
  83. package/lib/cdn-upload.test.js +0 -150
  84. package/lib/cdn-upload.test.js.map +0 -1
  85. package/lib/cdn-url.d.ts.map +0 -1
  86. package/lib/cdn-url.js.map +0 -1
  87. package/lib/context-store.d.ts.map +0 -1
  88. package/lib/context-store.js.map +0 -1
  89. package/lib/credentials.d.ts.map +0 -1
  90. package/lib/credentials.js.map +0 -1
  91. package/lib/endpoint.d.ts.map +0 -1
  92. package/lib/endpoint.js.map +0 -1
  93. package/lib/ilink-api.d.ts.map +0 -1
  94. package/lib/ilink-api.js.map +0 -1
  95. package/lib/ilink-config-cache.d.ts.map +0 -1
  96. package/lib/ilink-config-cache.js.map +0 -1
  97. package/lib/ilink-logger.d.ts.map +0 -1
  98. package/lib/ilink-logger.js.map +0 -1
  99. package/lib/ilink-meta.d.ts.map +0 -1
  100. package/lib/ilink-meta.js.map +0 -1
  101. package/lib/ilink-random.d.ts.map +0 -1
  102. package/lib/ilink-random.js.map +0 -1
  103. package/lib/ilink-redact.d.ts.map +0 -1
  104. package/lib/ilink-redact.js.map +0 -1
  105. package/lib/ilink-session-guard.d.ts.map +0 -1
  106. package/lib/ilink-session-guard.js.map +0 -1
  107. package/lib/ilink-types.d.ts.map +0 -1
  108. package/lib/ilink-types.js.map +0 -1
  109. package/lib/index.d.ts.map +0 -1
  110. package/lib/index.js.map +0 -1
  111. package/lib/login-assist-routes.d.ts +0 -7
  112. package/lib/login-assist-routes.d.ts.map +0 -1
  113. package/lib/login-assist-routes.js +0 -29
  114. package/lib/login-assist-routes.js.map +0 -1
  115. package/lib/login.d.ts.map +0 -1
  116. package/lib/login.js.map +0 -1
  117. package/lib/media-download.d.ts.map +0 -1
  118. package/lib/media-download.js.map +0 -1
  119. package/lib/mime.d.ts.map +0 -1
  120. package/lib/mime.js.map +0 -1
  121. package/lib/mime.test.d.ts +0 -2
  122. package/lib/mime.test.d.ts.map +0 -1
  123. package/lib/mime.test.js +0 -62
  124. package/lib/mime.test.js.map +0 -1
  125. package/lib/outbound-media.d.ts.map +0 -1
  126. package/lib/outbound-media.js.map +0 -1
  127. package/lib/pic-decrypt.d.ts.map +0 -1
  128. package/lib/pic-decrypt.js.map +0 -1
  129. package/lib/routes.d.ts +0 -8
  130. package/lib/routes.d.ts.map +0 -1
  131. package/lib/routes.js +0 -29
  132. package/lib/routes.js.map +0 -1
  133. package/lib/segment-mapper.d.ts +0 -2
  134. package/lib/segment-mapper.d.ts.map +0 -1
  135. package/lib/segment-mapper.js +0 -2
  136. package/lib/segment-mapper.js.map +0 -1
  137. package/lib/silk-transcode.d.ts.map +0 -1
  138. package/lib/silk-transcode.js.map +0 -1
  139. package/lib/silk-transcode.test.d.ts +0 -2
  140. package/lib/silk-transcode.test.d.ts.map +0 -1
  141. package/lib/silk-transcode.test.js +0 -53
  142. package/lib/silk-transcode.test.js.map +0 -1
  143. package/lib/types.d.ts.map +0 -1
  144. package/lib/types.js.map +0 -1
  145. package/lib/upload.d.ts.map +0 -1
  146. package/lib/upload.js.map +0 -1
  147. package/lib/upload.test.d.ts +0 -2
  148. package/lib/upload.test.d.ts.map +0 -1
  149. package/lib/upload.test.js +0 -250
  150. package/lib/upload.test.js.map +0 -1
  151. package/lib/weixin-inbound.d.ts.map +0 -1
  152. package/lib/weixin-inbound.js.map +0 -1
  153. package/lib/weixin-send-media.d.ts.map +0 -1
  154. package/lib/weixin-send-media.js.map +0 -1
  155. package/lib/weixin-send.d.ts.map +0 -1
  156. package/lib/weixin-send.js.map +0 -1
  157. package/src/adapter.ts +0 -24
  158. package/src/login-assist-routes.ts +0 -36
  159. package/src/routes.ts +0 -39
  160. package/src/segment-mapper.ts +0 -1
package/src/index.ts CHANGED
@@ -1,55 +1,26 @@
1
- /**
2
- * 微信 iLink(ClawBot)适配器入口
3
- */
4
- import path from "node:path";
5
- import { usePlugin, type Plugin } from "zhin.js";
6
- import type { Router } from "@zhin.js/host-router";
7
- import { PageManager } from "@zhin.js/host-api";
8
- import { WeixinIlinkAdapter } from "./adapter.js";
9
- import { registerRoutes } from "./routes.js";
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
- declare module "zhin.js" {
12
- namespace Plugin {
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 * from "./types.js";
24
- export { WeixinIlinkEndpoint } from "./endpoint.js";
25
- export { WeixinIlinkAdapter } from "./adapter.js";
26
- export { registerLoginAssistRoutes } from "./login-assist-routes.js";
27
-
28
- const plugin = usePlugin();
29
- const { provide, useContext, root } = plugin;
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
- import type { Plugin } from "zhin.js";
2
- import { apiGetFetch, apiPostFetch } from "./ilink-api.js";
3
- import { buildBaseInfo, DEFAULT_API_BASE_URL } from "./ilink-meta.js";
4
- import { logger } from "./ilink-logger.js";
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 "./credentials.js";
10
- import type { WeixinIlinkEndpointConfig } from "./types.js";
12
+ } from './credentials.js';
13
+ import type { ResolvedWeixinIlinkConfig } from './protocol.js';
11
14
 
12
- const DEFAULT_ILINK_BOT_TYPE = "3";
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
- | "wait"
24
- | "scaned"
25
- | "confirmed"
26
- | "expired"
27
- | "scaned_but_redirect"
28
- | "need_verifycode"
29
- | "verify_code_blocked"
30
- | "binded_redirect";
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: "fetchQRCode",
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: "pollQRStatus",
65
+ label: 'pollQRStatus',
63
66
  });
64
67
  return JSON.parse(rawText) as StatusResponse;
65
68
  } catch (err) {
66
- if (err instanceof Error && err.name === "AbortError") {
67
- return { status: "wait" };
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: "wait" };
73
+ return { status: 'wait' };
71
74
  }
72
75
  }
73
76
 
74
77
  export async function resolveCredentials(
75
- plugin: Plugin,
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(plugin, config);
99
+ return loginWithQr(config);
98
100
  }
99
101
 
100
102
  export async function loginWithQr(
101
- plugin: Plugin,
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
- const loginAssist = plugin.inject("loginAssist");
109
- if (!loginAssist) {
110
- throw new Error("loginAssist 服务不可用,请确保已加载 zhin 核心服务");
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
- try {
126
- while (Date.now() < deadline) {
127
- const status = await pollQRStatus(currentBaseUrl, qr.qrcode);
116
+ while (Date.now() < deadline) {
117
+ const status = await pollQRStatus(currentBaseUrl, qr.qrcode);
128
118
 
129
- if (status.status === "scaned_but_redirect" && status.redirect_host) {
130
- currentBaseUrl = status.redirect_host.startsWith("http")
131
- ? status.redirect_host
132
- : `https://${status.redirect_host}`;
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
- if (status.status === "confirmed" || status.status === "binded_redirect") {
136
- if (!status.bot_token && status.status !== "binded_redirect") {
137
- throw new Error("扫码成功但未返回 bot_token");
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
- if (status.status === "expired") {
154
- if (assistTask) loginAssist.cancel(assistTask.id, "expired");
155
- throw new Error("二维码已过期,请重启 bot 重新登录");
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
- await new Promise((r) => setTimeout(r, 500));
142
+ if (status.status === 'expired') {
143
+ throw new Error('二维码已过期,请重启 bot 重新登录');
159
144
  }
160
- if (assistTask) loginAssist.cancel(assistTask.id, "timeout");
161
- throw new Error("微信扫码登录超时");
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
  }
@@ -1,92 +1,98 @@
1
1
  /**
2
2
  * 微信 iLink 出站媒体:base64 / 远程 URL 须落盘后再走 CDN 上传。
3
3
  */
4
- import crypto from "node:crypto";
5
- import fs from "node:fs";
6
- import path from "node:path";
7
- import type { MessageSegment, SendContent } from "zhin.js";
8
- import { downloadRemoteImageToTemp } from "./upload.js";
9
- import { getExtensionFromMime } from "./mime.js";
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(["image", "video", "file", "record", "audio"]);
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("base64://") || /^data:[^/]+\/[^;]+;base64,/i.test(value);
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: MessageSegment): string | undefined {
26
- const data = seg.data as Record<string, unknown>;
27
- const candidates = [data.file, data.path, data.url].filter((v): v is string => typeof v === "string" && Boolean(v));
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 === "string" ? data.url : undefined;
33
- const file = typeof data.file === "string" ? data.file : undefined;
34
- const base64 = typeof data.base64 === "string" ? data.base64 : undefined;
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("base64://")) {
37
- return Buffer.from(url.slice(9), "base64");
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,/, ""), "base64");
45
+ return Buffer.from(url.replace(/^data:[^/]+\/[^;]+;base64,/, ''), 'base64');
41
46
  }
42
- if (file?.startsWith("base64://")) {
43
- return Buffer.from(file.slice(9), "base64");
47
+ if (file?.startsWith('base64://')) {
48
+ return Buffer.from(file.slice(9), 'base64');
44
49
  }
45
50
  if (base64) {
46
- const raw = base64.startsWith("base64://") ? base64.slice(9) : base64;
47
- return Buffer.from(raw, "base64");
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: MessageSegment, mime?: string): string {
53
- const data = seg.data as Record<string, unknown>;
54
- const name = typeof data.name === "string" ? data.name : undefined;
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 ?? (typeof data.mime === "string" ? data.mime : typeof data.mimeType === "string" ? data.mimeType : undefined);
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) ?? ".bin";
67
+ return getExtensionFromMime(mimeHint) ?? '.bin';
62
68
  }
63
- if (seg.type === "image") return ".png";
64
- if (seg.type === "video") return ".mp4";
65
- if (seg.type === "record" || seg.type === "audio") return ".mp3";
66
- return ".bin";
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: MessageSegment, filePath: string): MessageSegment {
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 } as MessageSegment;
78
+ return { type: seg.type, data };
73
79
  }
74
80
 
75
- function writeOutboundBuffer(buffer: Buffer, outboundDir: string, seg: MessageSegment): string {
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("hex")}${ext}`,
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: MessageSegment,
93
+ seg: WeixinWireSegment,
88
94
  outboundDir: string,
89
- ): Promise<MessageSegment> {
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: SendContent): MessageSegment[] {
113
- if (typeof content === "string") {
114
- return [{ type: "text", data: { text: content } }];
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 === "object" && content !== null && "type" in content
118
- ? [content as MessageSegment]
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 === "string") return [{ type: "text", data: { text: item } } as MessageSegment];
123
- return typeof item === "object" && item !== null && "type" in item ? [item as MessageSegment] : [];
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: SendContent,
135
+ content: OutboundWireContent,
130
136
  outboundDir: string,
131
- ): Promise<SendContent> {
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 === "string") {
135
- return out[0]?.type === "text" ? (out[0].data as { text: string }).text : content;
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;
@@ -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 interface WeixinIlinkEndpointConfig {
2
- context: "weixin-ilink";
3
- name: string;
4
- /** 观测用 bot_agent,默认 Zhin.js/<version> */
5
- botAgent?: string;
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 "./ilink-types.js";
7
+ export type { WeixinMessage, MessageItem } from './ilink-types.js';