@foxden-app/foxclaw 0.2.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 (126) hide show
  1. package/.env.example +36 -0
  2. package/LICENSE +22 -0
  3. package/README.md +244 -0
  4. package/README_EN.md +244 -0
  5. package/dist/channels/bridge_messaging_router.d.ts +27 -0
  6. package/dist/channels/bridge_messaging_router.js +85 -0
  7. package/dist/channels/telegram/telegram_channel_adapter.d.ts +12 -0
  8. package/dist/channels/telegram/telegram_channel_adapter.js +21 -0
  9. package/dist/channels/telegram/telegram_messaging_port.d.ts +25 -0
  10. package/dist/channels/telegram/telegram_messaging_port.js +51 -0
  11. package/dist/channels/weixin/account_store.d.ts +15 -0
  12. package/dist/channels/weixin/account_store.js +54 -0
  13. package/dist/channels/weixin/ilink/aes_ecb.d.ts +3 -0
  14. package/dist/channels/weixin/ilink/aes_ecb.js +12 -0
  15. package/dist/channels/weixin/ilink/api.d.ts +44 -0
  16. package/dist/channels/weixin/ilink/api.js +187 -0
  17. package/dist/channels/weixin/ilink/cdn_upload.d.ts +11 -0
  18. package/dist/channels/weixin/ilink/cdn_upload.js +60 -0
  19. package/dist/channels/weixin/ilink/cdn_url.d.ts +7 -0
  20. package/dist/channels/weixin/ilink/cdn_url.js +7 -0
  21. package/dist/channels/weixin/ilink/constants.d.ts +7 -0
  22. package/dist/channels/weixin/ilink/constants.js +27 -0
  23. package/dist/channels/weixin/ilink/context.d.ts +13 -0
  24. package/dist/channels/weixin/ilink/context.js +13 -0
  25. package/dist/channels/weixin/ilink/login_qr.d.ts +34 -0
  26. package/dist/channels/weixin/ilink/login_qr.js +233 -0
  27. package/dist/channels/weixin/ilink/media_image.d.ts +11 -0
  28. package/dist/channels/weixin/ilink/media_image.js +44 -0
  29. package/dist/channels/weixin/ilink/mime.d.ts +3 -0
  30. package/dist/channels/weixin/ilink/mime.js +36 -0
  31. package/dist/channels/weixin/ilink/pic_decrypt.d.ts +2 -0
  32. package/dist/channels/weixin/ilink/pic_decrypt.js +56 -0
  33. package/dist/channels/weixin/ilink/random.d.ts +2 -0
  34. package/dist/channels/weixin/ilink/random.js +7 -0
  35. package/dist/channels/weixin/ilink/redact.d.ts +4 -0
  36. package/dist/channels/weixin/ilink/redact.js +34 -0
  37. package/dist/channels/weixin/ilink/runtime_attach.d.ts +3 -0
  38. package/dist/channels/weixin/ilink/runtime_attach.js +13 -0
  39. package/dist/channels/weixin/ilink/send.d.ts +21 -0
  40. package/dist/channels/weixin/ilink/send.js +108 -0
  41. package/dist/channels/weixin/ilink/session_guard.d.ts +6 -0
  42. package/dist/channels/weixin/ilink/session_guard.js +39 -0
  43. package/dist/channels/weixin/ilink/types.d.ts +155 -0
  44. package/dist/channels/weixin/ilink/types.js +10 -0
  45. package/dist/channels/weixin/ilink/upload.d.ts +15 -0
  46. package/dist/channels/weixin/ilink/upload.js +75 -0
  47. package/dist/channels/weixin/sync_buf_store.d.ts +3 -0
  48. package/dist/channels/weixin/sync_buf_store.js +19 -0
  49. package/dist/channels/weixin/weixin_channel_adapter.d.ts +18 -0
  50. package/dist/channels/weixin/weixin_channel_adapter.js +273 -0
  51. package/dist/channels/weixin/weixin_messaging_port.d.ts +29 -0
  52. package/dist/channels/weixin/weixin_messaging_port.js +113 -0
  53. package/dist/codex_app/client.d.ts +176 -0
  54. package/dist/codex_app/client.js +1230 -0
  55. package/dist/codex_app/deeplink.d.ts +7 -0
  56. package/dist/codex_app/deeplink.js +29 -0
  57. package/dist/codex_app/local_usage.d.ts +16 -0
  58. package/dist/codex_app/local_usage.js +123 -0
  59. package/dist/config.d.ts +44 -0
  60. package/dist/config.js +131 -0
  61. package/dist/controller/access.d.ts +11 -0
  62. package/dist/controller/access.js +33 -0
  63. package/dist/controller/activity.d.ts +62 -0
  64. package/dist/controller/activity.js +330 -0
  65. package/dist/controller/commands.d.ts +6 -0
  66. package/dist/controller/commands.js +17 -0
  67. package/dist/controller/controller.d.ts +326 -0
  68. package/dist/controller/controller.js +7503 -0
  69. package/dist/controller/observer.d.ts +16 -0
  70. package/dist/controller/observer.js +98 -0
  71. package/dist/controller/presentation.d.ts +80 -0
  72. package/dist/controller/presentation.js +568 -0
  73. package/dist/controller/service_tier.d.ts +9 -0
  74. package/dist/controller/service_tier.js +32 -0
  75. package/dist/controller/session_observer.d.ts +22 -0
  76. package/dist/controller/session_observer.js +259 -0
  77. package/dist/controller/status.d.ts +10 -0
  78. package/dist/controller/status.js +28 -0
  79. package/dist/core/bridge_scope.d.ts +18 -0
  80. package/dist/core/bridge_scope.js +46 -0
  81. package/dist/core/channel_port.d.ts +15 -0
  82. package/dist/core/channel_port.js +1 -0
  83. package/dist/i18n.d.ts +1108 -0
  84. package/dist/i18n.js +1154 -0
  85. package/dist/lock.d.ts +7 -0
  86. package/dist/lock.js +80 -0
  87. package/dist/logger.d.ts +12 -0
  88. package/dist/logger.js +57 -0
  89. package/dist/main.d.ts +2 -0
  90. package/dist/main.js +236 -0
  91. package/dist/runtime.d.ts +3 -0
  92. package/dist/runtime.js +14 -0
  93. package/dist/store/database.d.ts +79 -0
  94. package/dist/store/database.js +489 -0
  95. package/dist/store/migrate_bridge_scope.d.ts +6 -0
  96. package/dist/store/migrate_bridge_scope.js +59 -0
  97. package/dist/telegram/addressing.d.ts +33 -0
  98. package/dist/telegram/addressing.js +57 -0
  99. package/dist/telegram/api.d.ts +14 -0
  100. package/dist/telegram/api.js +89 -0
  101. package/dist/telegram/gateway.d.ts +76 -0
  102. package/dist/telegram/gateway.js +383 -0
  103. package/dist/telegram/media.d.ts +34 -0
  104. package/dist/telegram/media.js +180 -0
  105. package/dist/telegram/rendering.d.ts +10 -0
  106. package/dist/telegram/rendering.js +21 -0
  107. package/dist/telegram/scope.d.ts +6 -0
  108. package/dist/telegram/scope.js +24 -0
  109. package/dist/telegram/text.d.ts +7 -0
  110. package/dist/telegram/text.js +47 -0
  111. package/dist/types.d.ts +343 -0
  112. package/dist/types.js +1 -0
  113. package/docs/agent-assisted-install.md +84 -0
  114. package/docs/install-for-beginners.md +287 -0
  115. package/docs/troubleshooting.md +239 -0
  116. package/package.json +62 -0
  117. package/scripts/doctor.sh +3 -0
  118. package/scripts/launchd/install.sh +54 -0
  119. package/scripts/status.sh +3 -0
  120. package/scripts/systemd/install.sh +83 -0
  121. package/scripts/systemd/uninstall.sh +15 -0
  122. package/skills/foxclaw/SKILL.md +167 -0
  123. package/skills/foxclaw/agents/openai.yaml +4 -0
  124. package/skills/foxclaw/references/telegram-setup.md +93 -0
  125. package/skills/foxclaw/scripts/bootstrap_host.py +350 -0
  126. package/skills/foxclaw/scripts/bootstrap_remote.py +67 -0
@@ -0,0 +1,233 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { apiGetFetch } from './api.js';
3
+ import { FIXED_QR_BASE_URL } from './constants.js';
4
+ import { getIlinkRuntimeContext } from './context.js';
5
+ import { redactToken } from './redact.js';
6
+ const ACTIVE_LOGIN_TTL_MS = 5 * 60_000;
7
+ const QR_LONG_POLL_TIMEOUT_MS = 35_000;
8
+ export const DEFAULT_ILINK_BOT_TYPE = '3';
9
+ const activeLogins = new Map();
10
+ function isLoginFresh(login) {
11
+ return Date.now() - login.startedAt < ACTIVE_LOGIN_TTL_MS;
12
+ }
13
+ function purgeExpiredLogins() {
14
+ for (const [id, login] of activeLogins) {
15
+ if (!isLoginFresh(login)) {
16
+ activeLogins.delete(id);
17
+ }
18
+ }
19
+ }
20
+ async function fetchQRCode(apiBaseUrl, botType) {
21
+ const log = getIlinkRuntimeContext().logger;
22
+ log.info(`Fetching QR code from: ${apiBaseUrl} bot_type=${botType}`);
23
+ const rawText = await apiGetFetch({
24
+ baseUrl: apiBaseUrl,
25
+ endpoint: `ilink/bot/get_bot_qrcode?bot_type=${encodeURIComponent(botType)}`,
26
+ label: 'fetchQRCode',
27
+ });
28
+ return JSON.parse(rawText);
29
+ }
30
+ async function pollQRStatus(apiBaseUrl, qrcode) {
31
+ const log = getIlinkRuntimeContext().logger;
32
+ log.debug(`Long-poll QR status from: ${apiBaseUrl} qrcode=***`);
33
+ try {
34
+ const rawText = await apiGetFetch({
35
+ baseUrl: apiBaseUrl,
36
+ endpoint: `ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`,
37
+ timeoutMs: QR_LONG_POLL_TIMEOUT_MS,
38
+ label: 'pollQRStatus',
39
+ });
40
+ log.debug(`pollQRStatus: body=${rawText.substring(0, 200)}`);
41
+ return JSON.parse(rawText);
42
+ }
43
+ catch (err) {
44
+ if (err instanceof Error && err.name === 'AbortError') {
45
+ log.debug(`pollQRStatus: client-side timeout after ${QR_LONG_POLL_TIMEOUT_MS}ms, returning wait`);
46
+ return { status: 'wait' };
47
+ }
48
+ log.warn(`pollQRStatus: network/gateway error, will retry: ${String(err)}`);
49
+ return { status: 'wait' };
50
+ }
51
+ }
52
+ export async function startWeixinLoginWithQr(opts) {
53
+ const log = getIlinkRuntimeContext().logger;
54
+ const sessionKey = opts.accountId || randomUUID();
55
+ purgeExpiredLogins();
56
+ const existing = activeLogins.get(sessionKey);
57
+ if (!opts.force && existing && isLoginFresh(existing) && existing.qrcodeUrl) {
58
+ return {
59
+ qrcodeUrl: existing.qrcodeUrl,
60
+ message: '二维码已就绪,请使用微信扫描。',
61
+ sessionKey,
62
+ };
63
+ }
64
+ try {
65
+ const botType = opts.botType || DEFAULT_ILINK_BOT_TYPE;
66
+ log.info(`Starting Weixin login with bot_type=${botType}`);
67
+ const qrResponse = await fetchQRCode(FIXED_QR_BASE_URL, botType);
68
+ log.info(`QR code received, qrcode=${redactToken(qrResponse.qrcode)} imgContentLen=${qrResponse.qrcode_img_content?.length ?? 0}`);
69
+ const login = {
70
+ sessionKey,
71
+ id: randomUUID(),
72
+ qrcode: qrResponse.qrcode,
73
+ qrcodeUrl: qrResponse.qrcode_img_content,
74
+ startedAt: Date.now(),
75
+ };
76
+ activeLogins.set(sessionKey, login);
77
+ return {
78
+ qrcodeUrl: qrResponse.qrcode_img_content,
79
+ message: '使用微信扫描以下二维码,以完成连接。',
80
+ sessionKey,
81
+ };
82
+ }
83
+ catch (err) {
84
+ log.error(`Failed to start Weixin login: ${String(err)}`);
85
+ return {
86
+ message: `Failed to start login: ${String(err)}`,
87
+ sessionKey,
88
+ };
89
+ }
90
+ }
91
+ const MAX_QR_REFRESH_COUNT = 3;
92
+ export async function waitForWeixinLogin(opts) {
93
+ const log = getIlinkRuntimeContext().logger;
94
+ const notify = opts.notify ?? (() => { });
95
+ const activeLogin = activeLogins.get(opts.sessionKey);
96
+ if (!activeLogin) {
97
+ log.warn(`waitForWeixinLogin: no active login sessionKey=${opts.sessionKey}`);
98
+ return {
99
+ connected: false,
100
+ message: '当前没有进行中的登录,请先发起登录。',
101
+ };
102
+ }
103
+ if (!isLoginFresh(activeLogin)) {
104
+ log.warn(`waitForWeixinLogin: login QR expired sessionKey=${opts.sessionKey}`);
105
+ activeLogins.delete(opts.sessionKey);
106
+ return {
107
+ connected: false,
108
+ message: '二维码已过期,请重新生成。',
109
+ };
110
+ }
111
+ const timeoutMs = Math.max(opts.timeoutMs ?? 480_000, 1000);
112
+ const deadline = Date.now() + timeoutMs;
113
+ let scannedPrinted = false;
114
+ let qrRefreshCount = 1;
115
+ activeLogin.currentApiBaseUrl = FIXED_QR_BASE_URL;
116
+ log.info('Starting to poll QR code status...');
117
+ while (Date.now() < deadline) {
118
+ try {
119
+ const currentBaseUrl = activeLogin.currentApiBaseUrl ?? FIXED_QR_BASE_URL;
120
+ const statusResponse = await pollQRStatus(currentBaseUrl, activeLogin.qrcode);
121
+ log.debug(`pollQRStatus: status=${statusResponse.status} hasBotToken=${Boolean(statusResponse.bot_token)} hasBotId=${Boolean(statusResponse.ilink_bot_id)}`);
122
+ activeLogin.status = statusResponse.status;
123
+ switch (statusResponse.status) {
124
+ case 'wait':
125
+ if (opts.verbose) {
126
+ notify('.');
127
+ }
128
+ break;
129
+ case 'scaned':
130
+ if (!scannedPrinted) {
131
+ notify('\n已扫码,请在微信中继续操作...\n');
132
+ scannedPrinted = true;
133
+ }
134
+ break;
135
+ case 'expired': {
136
+ qrRefreshCount++;
137
+ if (qrRefreshCount > MAX_QR_REFRESH_COUNT) {
138
+ log.warn(`waitForWeixinLogin: QR expired ${MAX_QR_REFRESH_COUNT} times, giving up sessionKey=${opts.sessionKey}`);
139
+ activeLogins.delete(opts.sessionKey);
140
+ return {
141
+ connected: false,
142
+ message: '登录超时:二维码多次过期,请重新开始登录流程。',
143
+ };
144
+ }
145
+ notify(`\n二维码已过期,正在刷新...(${qrRefreshCount}/${MAX_QR_REFRESH_COUNT})\n`);
146
+ log.info(`waitForWeixinLogin: QR expired, refreshing (${qrRefreshCount}/${MAX_QR_REFRESH_COUNT})`);
147
+ try {
148
+ const botType = opts.botType || DEFAULT_ILINK_BOT_TYPE;
149
+ const qrResponse = await fetchQRCode(FIXED_QR_BASE_URL, botType);
150
+ activeLogin.qrcode = qrResponse.qrcode;
151
+ activeLogin.qrcodeUrl = qrResponse.qrcode_img_content;
152
+ activeLogin.startedAt = Date.now();
153
+ scannedPrinted = false;
154
+ log.info(`waitForWeixinLogin: new QR code obtained qrcode=${redactToken(qrResponse.qrcode)}`);
155
+ notify('新二维码已生成,请重新扫描\n\n');
156
+ activeLogins.set(opts.sessionKey, activeLogin);
157
+ opts.onQrRefreshed?.(qrResponse.qrcode_img_content);
158
+ }
159
+ catch (refreshErr) {
160
+ log.error(`waitForWeixinLogin: failed to refresh QR code: ${String(refreshErr)}`);
161
+ activeLogins.delete(opts.sessionKey);
162
+ return {
163
+ connected: false,
164
+ message: `刷新二维码失败: ${String(refreshErr)}`,
165
+ };
166
+ }
167
+ break;
168
+ }
169
+ case 'scaned_but_redirect': {
170
+ const redirectHost = statusResponse.redirect_host;
171
+ if (redirectHost) {
172
+ const newBaseUrl = `https://${redirectHost}`;
173
+ activeLogin.currentApiBaseUrl = newBaseUrl;
174
+ log.info(`waitForWeixinLogin: IDC redirect, switching polling host to ${redirectHost}`);
175
+ }
176
+ else {
177
+ log.warn(`waitForWeixinLogin: received scaned_but_redirect but redirect_host is missing, continuing with current host`);
178
+ }
179
+ break;
180
+ }
181
+ case 'confirmed': {
182
+ if (!statusResponse.ilink_bot_id) {
183
+ activeLogins.delete(opts.sessionKey);
184
+ log.error('Login confirmed but ilink_bot_id missing from response');
185
+ return {
186
+ connected: false,
187
+ message: '登录失败:服务器未返回 ilink_bot_id。',
188
+ };
189
+ }
190
+ const botToken = statusResponse.bot_token?.trim();
191
+ if (!botToken) {
192
+ activeLogins.delete(opts.sessionKey);
193
+ log.error('Login confirmed but bot_token missing from response');
194
+ return {
195
+ connected: false,
196
+ message: '登录失败:服务器未返回 bot_token。',
197
+ };
198
+ }
199
+ activeLogin.botToken = botToken;
200
+ activeLogins.delete(opts.sessionKey);
201
+ log.info(`Login confirmed ilink_bot_id=${statusResponse.ilink_bot_id} ilink_user_id=${redactToken(statusResponse.ilink_user_id)}`);
202
+ return {
203
+ connected: true,
204
+ botToken,
205
+ accountId: statusResponse.ilink_bot_id,
206
+ message: '与微信连接成功。',
207
+ ...(statusResponse.baseurl !== undefined && statusResponse.baseurl !== ''
208
+ ? { baseUrl: statusResponse.baseurl }
209
+ : {}),
210
+ ...(statusResponse.ilink_user_id !== undefined
211
+ ? { userId: statusResponse.ilink_user_id }
212
+ : {}),
213
+ };
214
+ }
215
+ }
216
+ }
217
+ catch (err) {
218
+ log.error(`Error polling QR status: ${String(err)}`);
219
+ activeLogins.delete(opts.sessionKey);
220
+ return {
221
+ connected: false,
222
+ message: `Login failed: ${String(err)}`,
223
+ };
224
+ }
225
+ await new Promise((r) => setTimeout(r, 1000));
226
+ }
227
+ log.warn(`waitForWeixinLogin: timed out waiting for QR scan sessionKey=${opts.sessionKey} timeoutMs=${timeoutMs}`);
228
+ activeLogins.delete(opts.sessionKey);
229
+ return {
230
+ connected: false,
231
+ message: '登录超时,请重试。',
232
+ };
233
+ }
@@ -0,0 +1,11 @@
1
+ import type { MessageItem } from './types.js';
2
+ /**
3
+ * Download and decrypt a single IMAGE MessageItem to a local file.
4
+ * Returns undefined if not an image or missing CDN refs.
5
+ */
6
+ export declare function downloadWeixinImageItemToFile(params: {
7
+ item: MessageItem;
8
+ cdnBaseUrl: string;
9
+ destDir: string;
10
+ label?: string;
11
+ }): Promise<string | undefined>;
@@ -0,0 +1,44 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { getIlinkRuntimeContext } from './context.js';
4
+ import { downloadAndDecryptBuffer, downloadPlainCdnBuffer } from './pic_decrypt.js';
5
+ import { MessageItemType } from './types.js';
6
+ import { tempFileName } from './random.js';
7
+ const WEIXIN_IMAGE_MAX_BYTES = 20 * 1024 * 1024;
8
+ /**
9
+ * Download and decrypt a single IMAGE MessageItem to a local file.
10
+ * Returns undefined if not an image or missing CDN refs.
11
+ */
12
+ export async function downloadWeixinImageItemToFile(params) {
13
+ const log = getIlinkRuntimeContext().logger;
14
+ const { item, cdnBaseUrl, destDir, label = 'weixin-image' } = params;
15
+ if (item.type !== MessageItemType.IMAGE) {
16
+ return undefined;
17
+ }
18
+ const img = item.image_item;
19
+ if (!img?.media?.encrypt_query_param && !img?.media?.full_url) {
20
+ return undefined;
21
+ }
22
+ const aesKeyBase64 = img.aeskey
23
+ ? Buffer.from(img.aeskey, 'hex').toString('base64')
24
+ : img.media?.aes_key;
25
+ try {
26
+ const buf = aesKeyBase64
27
+ ? await downloadAndDecryptBuffer(img.media.encrypt_query_param ?? '', aesKeyBase64, cdnBaseUrl, `${label} image`, img.media?.full_url)
28
+ : await downloadPlainCdnBuffer(img.media.encrypt_query_param ?? '', cdnBaseUrl, `${label} image-plain`, img.media?.full_url);
29
+ if (buf.length > WEIXIN_IMAGE_MAX_BYTES) {
30
+ log.warn(`${label}: image too large ${buf.length} bytes, skipping`);
31
+ return undefined;
32
+ }
33
+ await fs.mkdir(destDir, { recursive: true });
34
+ const name = tempFileName('weixin-inbound', '.bin');
35
+ const filePath = path.join(destDir, name);
36
+ await fs.writeFile(filePath, buf);
37
+ log.debug(`${label}: saved image to ${filePath} bytes=${buf.length}`);
38
+ return filePath;
39
+ }
40
+ catch (err) {
41
+ log.error(`${label}: image download/decrypt failed: ${String(err)}`);
42
+ return undefined;
43
+ }
44
+ }
@@ -0,0 +1,3 @@
1
+ export declare function getMimeFromFilename(filename: string): string;
2
+ export declare function getExtensionFromMime(mimeType: string): string;
3
+ export declare function getExtensionFromContentTypeOrUrl(contentType: string | null, url: string): string;
@@ -0,0 +1,36 @@
1
+ import path from 'node:path';
2
+ const EXTENSION_TO_MIME = {
3
+ '.png': 'image/png',
4
+ '.jpg': 'image/jpeg',
5
+ '.jpeg': 'image/jpeg',
6
+ '.gif': 'image/gif',
7
+ '.webp': 'image/webp',
8
+ '.bmp': 'image/bmp',
9
+ '.pdf': 'application/pdf',
10
+ '.txt': 'text/plain',
11
+ '.zip': 'application/zip',
12
+ };
13
+ const MIME_TO_EXTENSION = {
14
+ 'image/jpeg': '.jpg',
15
+ 'image/png': '.png',
16
+ 'image/gif': '.gif',
17
+ 'image/webp': '.webp',
18
+ 'image/bmp': '.bmp',
19
+ };
20
+ export function getMimeFromFilename(filename) {
21
+ const ext = path.extname(filename).toLowerCase();
22
+ return EXTENSION_TO_MIME[ext] ?? 'application/octet-stream';
23
+ }
24
+ export function getExtensionFromMime(mimeType) {
25
+ const ct = mimeType.split(';')[0].trim().toLowerCase();
26
+ return MIME_TO_EXTENSION[ct] ?? '.bin';
27
+ }
28
+ export function getExtensionFromContentTypeOrUrl(contentType, url) {
29
+ if (contentType) {
30
+ const ext = getExtensionFromMime(contentType);
31
+ if (ext !== '.bin')
32
+ return ext;
33
+ }
34
+ const ext = path.extname(new URL(url).pathname).toLowerCase();
35
+ return EXTENSION_TO_MIME[ext] ? ext : '.bin';
36
+ }
@@ -0,0 +1,2 @@
1
+ export declare function downloadAndDecryptBuffer(encryptedQueryParam: string, aesKeyBase64: string, cdnBaseUrl: string, label: string, fullUrl?: string): Promise<Buffer>;
2
+ export declare function downloadPlainCdnBuffer(encryptedQueryParam: string, cdnBaseUrl: string, label: string, fullUrl?: string): Promise<Buffer>;
@@ -0,0 +1,56 @@
1
+ import { decryptAesEcb } from './aes_ecb.js';
2
+ import { buildCdnDownloadUrl, ENABLE_CDN_URL_FALLBACK } from './cdn_url.js';
3
+ import { getIlinkRuntimeContext } from './context.js';
4
+ async function fetchCdnBytes(url, label) {
5
+ const log = getIlinkRuntimeContext().logger;
6
+ const res = await fetch(url);
7
+ log.debug(`${label}: response status=${res.status} ok=${res.ok}`);
8
+ if (!res.ok) {
9
+ const body = await res.text().catch(() => '(unreadable)');
10
+ throw new Error(`${label}: CDN download ${res.status} ${res.statusText} body=${body}`);
11
+ }
12
+ return Buffer.from(await res.arrayBuffer());
13
+ }
14
+ function parseAesKey(aesKeyBase64, label) {
15
+ const decoded = Buffer.from(aesKeyBase64, 'base64');
16
+ if (decoded.length === 16) {
17
+ return decoded;
18
+ }
19
+ if (decoded.length === 32 && /^[0-9a-fA-F]{32}$/.test(decoded.toString('ascii'))) {
20
+ return Buffer.from(decoded.toString('ascii'), 'hex');
21
+ }
22
+ throw new Error(`${label}: invalid aes_key encoding`);
23
+ }
24
+ export async function downloadAndDecryptBuffer(encryptedQueryParam, aesKeyBase64, cdnBaseUrl, label, fullUrl) {
25
+ const log = getIlinkRuntimeContext().logger;
26
+ const key = parseAesKey(aesKeyBase64, label);
27
+ let url;
28
+ if (fullUrl) {
29
+ url = fullUrl;
30
+ }
31
+ else if (ENABLE_CDN_URL_FALLBACK) {
32
+ url = buildCdnDownloadUrl(encryptedQueryParam, cdnBaseUrl);
33
+ }
34
+ else {
35
+ throw new Error(`${label}: fullUrl is required`);
36
+ }
37
+ log.debug(`${label}: fetching url=${url}`);
38
+ const encrypted = await fetchCdnBytes(url, label);
39
+ const decrypted = decryptAesEcb(encrypted, key);
40
+ return decrypted;
41
+ }
42
+ export async function downloadPlainCdnBuffer(encryptedQueryParam, cdnBaseUrl, label, fullUrl) {
43
+ const log = getIlinkRuntimeContext().logger;
44
+ let url;
45
+ if (fullUrl) {
46
+ url = fullUrl;
47
+ }
48
+ else if (ENABLE_CDN_URL_FALLBACK) {
49
+ url = buildCdnDownloadUrl(encryptedQueryParam, cdnBaseUrl);
50
+ }
51
+ else {
52
+ throw new Error(`${label}: fullUrl is required`);
53
+ }
54
+ log.debug(`${label}: fetching url=${url}`);
55
+ return fetchCdnBytes(url, label);
56
+ }
@@ -0,0 +1,2 @@
1
+ export declare function generateId(prefix: string): string;
2
+ export declare function tempFileName(prefix: string, ext: string): string;
@@ -0,0 +1,7 @@
1
+ import crypto from 'node:crypto';
2
+ export function generateId(prefix) {
3
+ return `${prefix}:${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
4
+ }
5
+ export function tempFileName(prefix, ext) {
6
+ return `${prefix}-${Date.now()}-${crypto.randomBytes(4).toString('hex')}${ext}`;
7
+ }
@@ -0,0 +1,4 @@
1
+ export declare function truncate(s: string | undefined, max: number): string;
2
+ export declare function redactToken(token: string | undefined, prefixLen?: number): string;
3
+ export declare function redactBody(body: string | undefined, maxLen?: number): string;
4
+ export declare function redactUrl(rawUrl: string): string;
@@ -0,0 +1,34 @@
1
+ const DEFAULT_BODY_MAX_LEN = 200;
2
+ const DEFAULT_TOKEN_PREFIX_LEN = 6;
3
+ export function truncate(s, max) {
4
+ if (!s)
5
+ return '';
6
+ if (s.length <= max)
7
+ return s;
8
+ return `${s.slice(0, max)}…(len=${s.length})`;
9
+ }
10
+ export function redactToken(token, prefixLen = DEFAULT_TOKEN_PREFIX_LEN) {
11
+ if (!token)
12
+ return '(none)';
13
+ if (token.length <= prefixLen)
14
+ return `****(len=${token.length})`;
15
+ return `${token.slice(0, prefixLen)}…(len=${token.length})`;
16
+ }
17
+ export function redactBody(body, maxLen = DEFAULT_BODY_MAX_LEN) {
18
+ if (!body)
19
+ return '(empty)';
20
+ const redacted = body.replace(/"(context_token|bot_token|token|authorization|Authorization)"\s*:\s*"[^"]*"/g, '"$1":"<redacted>"');
21
+ if (redacted.length <= maxLen)
22
+ return redacted;
23
+ return `${redacted.slice(0, maxLen)}…(truncated, totalLen=${redacted.length})`;
24
+ }
25
+ export function redactUrl(rawUrl) {
26
+ try {
27
+ const u = new URL(rawUrl);
28
+ const base = `${u.origin}${u.pathname}`;
29
+ return u.search ? `${base}?<redacted>` : base;
30
+ }
31
+ catch {
32
+ return truncate(rawUrl, 80);
33
+ }
34
+ }
@@ -0,0 +1,3 @@
1
+ import type { Logger } from '../../../logger.js';
2
+ /** Wire bridge {@link Logger} and package version into iLink HTTP helpers. */
3
+ export declare function attachIlinkRuntimeFromBridgeLogger(logger: Logger, routeTag?: string | null): void;
@@ -0,0 +1,13 @@
1
+ import { BRIDGE_PACKAGE_VERSION, ILINK_APP_CLIENT_VERSION, ILINK_APP_ID } from './constants.js';
2
+ import { setIlinkRuntimeContext } from './context.js';
3
+ /** Wire bridge {@link Logger} and package version into iLink HTTP helpers. */
4
+ export function attachIlinkRuntimeFromBridgeLogger(logger, routeTag) {
5
+ const trimmed = routeTag?.trim();
6
+ setIlinkRuntimeContext({
7
+ logger,
8
+ channelVersion: BRIDGE_PACKAGE_VERSION,
9
+ ilinkAppId: ILINK_APP_ID,
10
+ ilinkAppClientVersion: ILINK_APP_CLIENT_VERSION,
11
+ ...(trimmed ? { routeTag: trimmed } : {}),
12
+ });
13
+ }
@@ -0,0 +1,21 @@
1
+ import type { WeixinApiOptions } from './api.js';
2
+ import type { UploadedFileInfo } from './upload.js';
3
+ export declare function sendMessageWeixin(params: {
4
+ to: string;
5
+ text: string;
6
+ opts: WeixinApiOptions & {
7
+ contextToken?: string;
8
+ };
9
+ }): Promise<{
10
+ messageId: string;
11
+ }>;
12
+ export declare function sendImageMessageWeixin(params: {
13
+ to: string;
14
+ text: string;
15
+ uploaded: UploadedFileInfo;
16
+ opts: WeixinApiOptions & {
17
+ contextToken?: string;
18
+ };
19
+ }): Promise<{
20
+ messageId: string;
21
+ }>;
@@ -0,0 +1,108 @@
1
+ import { sendMessage as sendMessageApi } from './api.js';
2
+ import { getIlinkRuntimeContext } from './context.js';
3
+ import { generateId } from './random.js';
4
+ import { MessageItemType, MessageState, MessageType } from './types.js';
5
+ function generateClientId() {
6
+ return generateId('bridge-weixin');
7
+ }
8
+ function buildTextMessageReq(params) {
9
+ const { to, text, contextToken, clientId } = params;
10
+ const item_list = text ? [{ type: MessageItemType.TEXT, text_item: { text } }] : [];
11
+ const msg = {
12
+ from_user_id: '',
13
+ to_user_id: to,
14
+ client_id: clientId,
15
+ message_type: MessageType.BOT,
16
+ message_state: MessageState.FINISH,
17
+ ...(item_list.length > 0 ? { item_list } : {}),
18
+ ...(contextToken !== undefined && contextToken !== '' ? { context_token: contextToken } : {}),
19
+ };
20
+ return { msg };
21
+ }
22
+ export async function sendMessageWeixin(params) {
23
+ const log = getIlinkRuntimeContext().logger;
24
+ const { to, text, opts } = params;
25
+ if (!opts.contextToken) {
26
+ log.warn(`sendMessageWeixin: contextToken missing for to=${to}, sending without context`);
27
+ }
28
+ const clientId = generateClientId();
29
+ const req = buildTextMessageReq({
30
+ to,
31
+ text,
32
+ clientId,
33
+ ...(opts.contextToken !== undefined && opts.contextToken !== '' ? { contextToken: opts.contextToken } : {}),
34
+ });
35
+ try {
36
+ await sendMessageApi({
37
+ baseUrl: opts.baseUrl,
38
+ body: req,
39
+ ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
40
+ ...(opts.token !== undefined && opts.token !== '' ? { token: opts.token } : {}),
41
+ });
42
+ }
43
+ catch (err) {
44
+ log.error(`sendMessageWeixin: failed to=${to} clientId=${clientId} err=${String(err)}`);
45
+ throw err;
46
+ }
47
+ return { messageId: clientId };
48
+ }
49
+ async function sendMediaItems(params) {
50
+ const log = getIlinkRuntimeContext().logger;
51
+ const { to, text, mediaItem, opts, label } = params;
52
+ const items = [];
53
+ if (text) {
54
+ items.push({ type: MessageItemType.TEXT, text_item: { text } });
55
+ }
56
+ items.push(mediaItem);
57
+ let lastClientId = '';
58
+ for (const item of items) {
59
+ lastClientId = generateClientId();
60
+ const msg = {
61
+ from_user_id: '',
62
+ to_user_id: to,
63
+ client_id: lastClientId,
64
+ message_type: MessageType.BOT,
65
+ message_state: MessageState.FINISH,
66
+ item_list: [item],
67
+ ...(opts.contextToken !== undefined && opts.contextToken !== '' ? { context_token: opts.contextToken } : {}),
68
+ };
69
+ const req = { msg };
70
+ try {
71
+ await sendMessageApi({
72
+ baseUrl: opts.baseUrl,
73
+ body: req,
74
+ ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
75
+ ...(opts.token !== undefined && opts.token !== '' ? { token: opts.token } : {}),
76
+ });
77
+ }
78
+ catch (err) {
79
+ log.error(`${label}: failed to=${to} clientId=${lastClientId} err=${String(err)}`);
80
+ throw err;
81
+ }
82
+ }
83
+ log.info(`${label}: success to=${to} clientId=${lastClientId}`);
84
+ return { messageId: lastClientId };
85
+ }
86
+ function uploadedAesKeyBase64(uploaded) {
87
+ return Buffer.from(uploaded.aeskey, 'hex').toString('base64');
88
+ }
89
+ export async function sendImageMessageWeixin(params) {
90
+ const log = getIlinkRuntimeContext().logger;
91
+ const { to, text, uploaded, opts } = params;
92
+ if (!opts.contextToken) {
93
+ log.warn(`sendImageMessageWeixin: contextToken missing for to=${to}, sending without context`);
94
+ }
95
+ log.info(`sendImageMessageWeixin: to=${to} filekey=${uploaded.filekey} fileSize=${uploaded.fileSize} aeskey=present`);
96
+ const imageItem = {
97
+ type: MessageItemType.IMAGE,
98
+ image_item: {
99
+ media: {
100
+ encrypt_query_param: uploaded.downloadEncryptedQueryParam,
101
+ aes_key: uploadedAesKeyBase64(uploaded),
102
+ encrypt_type: 1,
103
+ },
104
+ mid_size: uploaded.fileSizeCiphertext,
105
+ },
106
+ };
107
+ return sendMediaItems({ to, text, mediaItem: imageItem, opts, label: 'sendImageMessageWeixin' });
108
+ }
@@ -0,0 +1,6 @@
1
+ export declare const SESSION_EXPIRED_ERRCODE = -14;
2
+ export declare function pauseSession(accountId: string): void;
3
+ export declare function isSessionPaused(accountId: string): boolean;
4
+ export declare function getRemainingPauseMs(accountId: string): number;
5
+ export declare function assertSessionActive(accountId: string): void;
6
+ export declare function resetSessionGuardForTest(): void;
@@ -0,0 +1,39 @@
1
+ import { getIlinkRuntimeContext } from './context.js';
2
+ const SESSION_PAUSE_DURATION_MS = 60 * 60 * 1000;
3
+ export const SESSION_EXPIRED_ERRCODE = -14;
4
+ const pauseUntilMap = new Map();
5
+ export function pauseSession(accountId) {
6
+ const until = Date.now() + SESSION_PAUSE_DURATION_MS;
7
+ pauseUntilMap.set(accountId, until);
8
+ getIlinkRuntimeContext().logger.info(`session-guard: paused accountId=${accountId} until=${new Date(until).toISOString()}`);
9
+ }
10
+ export function isSessionPaused(accountId) {
11
+ const until = pauseUntilMap.get(accountId);
12
+ if (until === undefined)
13
+ return false;
14
+ if (Date.now() >= until) {
15
+ pauseUntilMap.delete(accountId);
16
+ return false;
17
+ }
18
+ return true;
19
+ }
20
+ export function getRemainingPauseMs(accountId) {
21
+ const until = pauseUntilMap.get(accountId);
22
+ if (until === undefined)
23
+ return 0;
24
+ const remaining = until - Date.now();
25
+ if (remaining <= 0) {
26
+ pauseUntilMap.delete(accountId);
27
+ return 0;
28
+ }
29
+ return remaining;
30
+ }
31
+ export function assertSessionActive(accountId) {
32
+ if (isSessionPaused(accountId)) {
33
+ const remainingMin = Math.ceil(getRemainingPauseMs(accountId) / 60_000);
34
+ throw new Error(`session paused for accountId=${accountId}, ${remainingMin} min remaining (errcode ${SESSION_EXPIRED_ERRCODE})`);
35
+ }
36
+ }
37
+ export function resetSessionGuardForTest() {
38
+ pauseUntilMap.clear();
39
+ }