@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.
- package/.env.example +36 -0
- package/LICENSE +22 -0
- package/README.md +244 -0
- package/README_EN.md +244 -0
- package/dist/channels/bridge_messaging_router.d.ts +27 -0
- package/dist/channels/bridge_messaging_router.js +85 -0
- package/dist/channels/telegram/telegram_channel_adapter.d.ts +12 -0
- package/dist/channels/telegram/telegram_channel_adapter.js +21 -0
- package/dist/channels/telegram/telegram_messaging_port.d.ts +25 -0
- package/dist/channels/telegram/telegram_messaging_port.js +51 -0
- package/dist/channels/weixin/account_store.d.ts +15 -0
- package/dist/channels/weixin/account_store.js +54 -0
- package/dist/channels/weixin/ilink/aes_ecb.d.ts +3 -0
- package/dist/channels/weixin/ilink/aes_ecb.js +12 -0
- package/dist/channels/weixin/ilink/api.d.ts +44 -0
- package/dist/channels/weixin/ilink/api.js +187 -0
- package/dist/channels/weixin/ilink/cdn_upload.d.ts +11 -0
- package/dist/channels/weixin/ilink/cdn_upload.js +60 -0
- package/dist/channels/weixin/ilink/cdn_url.d.ts +7 -0
- package/dist/channels/weixin/ilink/cdn_url.js +7 -0
- package/dist/channels/weixin/ilink/constants.d.ts +7 -0
- package/dist/channels/weixin/ilink/constants.js +27 -0
- package/dist/channels/weixin/ilink/context.d.ts +13 -0
- package/dist/channels/weixin/ilink/context.js +13 -0
- package/dist/channels/weixin/ilink/login_qr.d.ts +34 -0
- package/dist/channels/weixin/ilink/login_qr.js +233 -0
- package/dist/channels/weixin/ilink/media_image.d.ts +11 -0
- package/dist/channels/weixin/ilink/media_image.js +44 -0
- package/dist/channels/weixin/ilink/mime.d.ts +3 -0
- package/dist/channels/weixin/ilink/mime.js +36 -0
- package/dist/channels/weixin/ilink/pic_decrypt.d.ts +2 -0
- package/dist/channels/weixin/ilink/pic_decrypt.js +56 -0
- package/dist/channels/weixin/ilink/random.d.ts +2 -0
- package/dist/channels/weixin/ilink/random.js +7 -0
- package/dist/channels/weixin/ilink/redact.d.ts +4 -0
- package/dist/channels/weixin/ilink/redact.js +34 -0
- package/dist/channels/weixin/ilink/runtime_attach.d.ts +3 -0
- package/dist/channels/weixin/ilink/runtime_attach.js +13 -0
- package/dist/channels/weixin/ilink/send.d.ts +21 -0
- package/dist/channels/weixin/ilink/send.js +108 -0
- package/dist/channels/weixin/ilink/session_guard.d.ts +6 -0
- package/dist/channels/weixin/ilink/session_guard.js +39 -0
- package/dist/channels/weixin/ilink/types.d.ts +155 -0
- package/dist/channels/weixin/ilink/types.js +10 -0
- package/dist/channels/weixin/ilink/upload.d.ts +15 -0
- package/dist/channels/weixin/ilink/upload.js +75 -0
- package/dist/channels/weixin/sync_buf_store.d.ts +3 -0
- package/dist/channels/weixin/sync_buf_store.js +19 -0
- package/dist/channels/weixin/weixin_channel_adapter.d.ts +18 -0
- package/dist/channels/weixin/weixin_channel_adapter.js +273 -0
- package/dist/channels/weixin/weixin_messaging_port.d.ts +29 -0
- package/dist/channels/weixin/weixin_messaging_port.js +113 -0
- package/dist/codex_app/client.d.ts +176 -0
- package/dist/codex_app/client.js +1230 -0
- package/dist/codex_app/deeplink.d.ts +7 -0
- package/dist/codex_app/deeplink.js +29 -0
- package/dist/codex_app/local_usage.d.ts +16 -0
- package/dist/codex_app/local_usage.js +123 -0
- package/dist/config.d.ts +44 -0
- package/dist/config.js +131 -0
- package/dist/controller/access.d.ts +11 -0
- package/dist/controller/access.js +33 -0
- package/dist/controller/activity.d.ts +62 -0
- package/dist/controller/activity.js +330 -0
- package/dist/controller/commands.d.ts +6 -0
- package/dist/controller/commands.js +17 -0
- package/dist/controller/controller.d.ts +326 -0
- package/dist/controller/controller.js +7503 -0
- package/dist/controller/observer.d.ts +16 -0
- package/dist/controller/observer.js +98 -0
- package/dist/controller/presentation.d.ts +80 -0
- package/dist/controller/presentation.js +568 -0
- package/dist/controller/service_tier.d.ts +9 -0
- package/dist/controller/service_tier.js +32 -0
- package/dist/controller/session_observer.d.ts +22 -0
- package/dist/controller/session_observer.js +259 -0
- package/dist/controller/status.d.ts +10 -0
- package/dist/controller/status.js +28 -0
- package/dist/core/bridge_scope.d.ts +18 -0
- package/dist/core/bridge_scope.js +46 -0
- package/dist/core/channel_port.d.ts +15 -0
- package/dist/core/channel_port.js +1 -0
- package/dist/i18n.d.ts +1108 -0
- package/dist/i18n.js +1154 -0
- package/dist/lock.d.ts +7 -0
- package/dist/lock.js +80 -0
- package/dist/logger.d.ts +12 -0
- package/dist/logger.js +57 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +236 -0
- package/dist/runtime.d.ts +3 -0
- package/dist/runtime.js +14 -0
- package/dist/store/database.d.ts +79 -0
- package/dist/store/database.js +489 -0
- package/dist/store/migrate_bridge_scope.d.ts +6 -0
- package/dist/store/migrate_bridge_scope.js +59 -0
- package/dist/telegram/addressing.d.ts +33 -0
- package/dist/telegram/addressing.js +57 -0
- package/dist/telegram/api.d.ts +14 -0
- package/dist/telegram/api.js +89 -0
- package/dist/telegram/gateway.d.ts +76 -0
- package/dist/telegram/gateway.js +383 -0
- package/dist/telegram/media.d.ts +34 -0
- package/dist/telegram/media.js +180 -0
- package/dist/telegram/rendering.d.ts +10 -0
- package/dist/telegram/rendering.js +21 -0
- package/dist/telegram/scope.d.ts +6 -0
- package/dist/telegram/scope.js +24 -0
- package/dist/telegram/text.d.ts +7 -0
- package/dist/telegram/text.js +47 -0
- package/dist/types.d.ts +343 -0
- package/dist/types.js +1 -0
- package/docs/agent-assisted-install.md +84 -0
- package/docs/install-for-beginners.md +287 -0
- package/docs/troubleshooting.md +239 -0
- package/package.json +62 -0
- package/scripts/doctor.sh +3 -0
- package/scripts/launchd/install.sh +54 -0
- package/scripts/status.sh +3 -0
- package/scripts/systemd/install.sh +83 -0
- package/scripts/systemd/uninstall.sh +15 -0
- package/skills/foxclaw/SKILL.md +167 -0
- package/skills/foxclaw/agents/openai.yaml +4 -0
- package/skills/foxclaw/references/telegram-setup.md +93 -0
- package/skills/foxclaw/scripts/bootstrap_host.py +350 -0
- package/skills/foxclaw/scripts/bootstrap_remote.py +67 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram channel: inbound subscription + transport startup ordering for {@link BridgeSessionCore}.
|
|
3
|
+
* Additional channels (e.g. Weixin) can compose the same core with their own adapters.
|
|
4
|
+
*/
|
|
5
|
+
export class TelegramChannelAdapter {
|
|
6
|
+
core;
|
|
7
|
+
constructor(core) {
|
|
8
|
+
this.core = core;
|
|
9
|
+
}
|
|
10
|
+
async start() {
|
|
11
|
+
this.core.registerTelegramInboundHandlers();
|
|
12
|
+
await this.core.startCodexApp();
|
|
13
|
+
await this.core.startTelegramPolling();
|
|
14
|
+
}
|
|
15
|
+
async stop() {
|
|
16
|
+
await this.core.stop();
|
|
17
|
+
}
|
|
18
|
+
get sessionCore() {
|
|
19
|
+
return this.core;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ChannelPort } from '../../core/channel_port.js';
|
|
2
|
+
import type { TelegramGateway } from '../../telegram/gateway.js';
|
|
3
|
+
import type { TelegramRemoteFile } from '../../telegram/api.js';
|
|
4
|
+
export type InlineKeyboard = Array<Array<{
|
|
5
|
+
text: string;
|
|
6
|
+
callback_data: string;
|
|
7
|
+
}>>;
|
|
8
|
+
/**
|
|
9
|
+
* Telegram outbound operations addressed by bridge scope id (`telegram:…`).
|
|
10
|
+
*/
|
|
11
|
+
export declare class TelegramMessagingPort implements ChannelPort {
|
|
12
|
+
private readonly gateway;
|
|
13
|
+
constructor(gateway: TelegramGateway);
|
|
14
|
+
sendPlain(bridgeScopeId: string, text: string, inlineKeyboard?: InlineKeyboard): Promise<number>;
|
|
15
|
+
sendHtml(bridgeScopeId: string, text: string, inlineKeyboard?: InlineKeyboard): Promise<number>;
|
|
16
|
+
editPlain(bridgeScopeId: string, messageId: number, text: string, inlineKeyboard?: InlineKeyboard): Promise<void>;
|
|
17
|
+
editHtml(bridgeScopeId: string, messageId: number, text: string, inlineKeyboard?: InlineKeyboard): Promise<void>;
|
|
18
|
+
deleteMessage(bridgeScopeId: string, messageId: number): Promise<void>;
|
|
19
|
+
sendTypingInScope(bridgeScopeId: string): Promise<void>;
|
|
20
|
+
clearInlineKeyboard(bridgeScopeId: string, messageId: number): Promise<void>;
|
|
21
|
+
sendDraft(bridgeScopeId: string, draftId: number, text: string): Promise<void>;
|
|
22
|
+
answerCallback(callbackQueryId: string, text: string): Promise<void>;
|
|
23
|
+
getFile(fileId: string): Promise<TelegramRemoteFile>;
|
|
24
|
+
downloadResolvedFile(remoteFilePath: string, destinationPath: string): Promise<number>;
|
|
25
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { parseTelegramTargetFromBridgeScope } from '../../core/bridge_scope.js';
|
|
2
|
+
/**
|
|
3
|
+
* Telegram outbound operations addressed by bridge scope id (`telegram:…`).
|
|
4
|
+
*/
|
|
5
|
+
export class TelegramMessagingPort {
|
|
6
|
+
gateway;
|
|
7
|
+
constructor(gateway) {
|
|
8
|
+
this.gateway = gateway;
|
|
9
|
+
}
|
|
10
|
+
async sendPlain(bridgeScopeId, text, inlineKeyboard) {
|
|
11
|
+
const target = parseTelegramTargetFromBridgeScope(bridgeScopeId);
|
|
12
|
+
return this.gateway.sendMessage(target.chatId, text, inlineKeyboard, target.topicId);
|
|
13
|
+
}
|
|
14
|
+
async sendHtml(bridgeScopeId, text, inlineKeyboard) {
|
|
15
|
+
const target = parseTelegramTargetFromBridgeScope(bridgeScopeId);
|
|
16
|
+
return this.gateway.sendHtmlMessage(target.chatId, text, inlineKeyboard, target.topicId);
|
|
17
|
+
}
|
|
18
|
+
async editPlain(bridgeScopeId, messageId, text, inlineKeyboard) {
|
|
19
|
+
const target = parseTelegramTargetFromBridgeScope(bridgeScopeId);
|
|
20
|
+
await this.gateway.editMessage(target.chatId, messageId, text, inlineKeyboard);
|
|
21
|
+
}
|
|
22
|
+
async editHtml(bridgeScopeId, messageId, text, inlineKeyboard) {
|
|
23
|
+
const target = parseTelegramTargetFromBridgeScope(bridgeScopeId);
|
|
24
|
+
await this.gateway.editHtmlMessage(target.chatId, messageId, text, inlineKeyboard);
|
|
25
|
+
}
|
|
26
|
+
async deleteMessage(bridgeScopeId, messageId) {
|
|
27
|
+
const target = parseTelegramTargetFromBridgeScope(bridgeScopeId);
|
|
28
|
+
await this.gateway.deleteMessage(target.chatId, messageId);
|
|
29
|
+
}
|
|
30
|
+
async sendTypingInScope(bridgeScopeId) {
|
|
31
|
+
const target = parseTelegramTargetFromBridgeScope(bridgeScopeId);
|
|
32
|
+
await this.gateway.sendTypingInThread(target.chatId, target.topicId);
|
|
33
|
+
}
|
|
34
|
+
async clearInlineKeyboard(bridgeScopeId, messageId) {
|
|
35
|
+
const target = parseTelegramTargetFromBridgeScope(bridgeScopeId);
|
|
36
|
+
await this.gateway.clearMessageInlineKeyboard(target.chatId, messageId);
|
|
37
|
+
}
|
|
38
|
+
async sendDraft(bridgeScopeId, draftId, text) {
|
|
39
|
+
const target = parseTelegramTargetFromBridgeScope(bridgeScopeId);
|
|
40
|
+
await this.gateway.sendMessageDraft(target.chatId, draftId, text, target.topicId);
|
|
41
|
+
}
|
|
42
|
+
answerCallback(callbackQueryId, text) {
|
|
43
|
+
return this.gateway.answerCallback(callbackQueryId, text);
|
|
44
|
+
}
|
|
45
|
+
getFile(fileId) {
|
|
46
|
+
return this.gateway.getFile(fileId);
|
|
47
|
+
}
|
|
48
|
+
downloadResolvedFile(remoteFilePath, destinationPath) {
|
|
49
|
+
return this.gateway.downloadResolvedFile(remoteFilePath, destinationPath);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Persisted after QR login; `accountId` is ilink_bot_id from iLink. */
|
|
2
|
+
export interface WeixinSavedAccount {
|
|
3
|
+
accountId: string;
|
|
4
|
+
botToken: string;
|
|
5
|
+
baseUrl: string;
|
|
6
|
+
/** User who scanned QR (reference only). */
|
|
7
|
+
linkedIlinkUserId?: string;
|
|
8
|
+
savedAt: number;
|
|
9
|
+
}
|
|
10
|
+
export declare function defaultWeixinAccountsDir(appHome: string): string;
|
|
11
|
+
export declare function accountFilePath(accountsDir: string, accountId: string): string;
|
|
12
|
+
export declare function saveWeixinAccount(accountsDir: string, record: WeixinSavedAccount): void;
|
|
13
|
+
export declare function loadWeixinAccount(accountsDir: string, accountId: string): WeixinSavedAccount | null;
|
|
14
|
+
export declare function listWeixinAccountIds(accountsDir: string): string[];
|
|
15
|
+
export declare function listWeixinAccounts(accountsDir: string): WeixinSavedAccount[];
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
export function defaultWeixinAccountsDir(appHome) {
|
|
4
|
+
return path.join(appHome, 'weixin', 'accounts');
|
|
5
|
+
}
|
|
6
|
+
export function accountFilePath(accountsDir, accountId) {
|
|
7
|
+
const safe = accountId.replace(/[^\w.-]+/g, '-');
|
|
8
|
+
return path.join(accountsDir, `${safe}.json`);
|
|
9
|
+
}
|
|
10
|
+
export function saveWeixinAccount(accountsDir, record) {
|
|
11
|
+
fs.mkdirSync(accountsDir, { recursive: true });
|
|
12
|
+
const filePath = accountFilePath(accountsDir, record.accountId);
|
|
13
|
+
fs.writeFileSync(filePath, JSON.stringify(record, null, 2), 'utf-8');
|
|
14
|
+
}
|
|
15
|
+
export function loadWeixinAccount(accountsDir, accountId) {
|
|
16
|
+
const filePath = accountFilePath(accountsDir, accountId);
|
|
17
|
+
try {
|
|
18
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
19
|
+
const parsed = JSON.parse(raw);
|
|
20
|
+
if (!parsed.accountId || !parsed.botToken || !parsed.baseUrl) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
return parsed;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function listWeixinAccountIds(accountsDir) {
|
|
30
|
+
if (!fs.existsSync(accountsDir)) {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
const ids = [];
|
|
34
|
+
for (const name of fs.readdirSync(accountsDir)) {
|
|
35
|
+
if (!name.endsWith('.json'))
|
|
36
|
+
continue;
|
|
37
|
+
try {
|
|
38
|
+
const raw = fs.readFileSync(path.join(accountsDir, name), 'utf-8');
|
|
39
|
+
const parsed = JSON.parse(raw);
|
|
40
|
+
if (parsed.accountId) {
|
|
41
|
+
ids.push(parsed.accountId);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// skip invalid
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return ids;
|
|
49
|
+
}
|
|
50
|
+
export function listWeixinAccounts(accountsDir) {
|
|
51
|
+
return listWeixinAccountIds(accountsDir)
|
|
52
|
+
.map((id) => loadWeixinAccount(accountsDir, id))
|
|
53
|
+
.filter((a) => a !== null);
|
|
54
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv } from 'node:crypto';
|
|
2
|
+
export function encryptAesEcb(plaintext, key) {
|
|
3
|
+
const cipher = createCipheriv('aes-128-ecb', key, null);
|
|
4
|
+
return Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
5
|
+
}
|
|
6
|
+
export function decryptAesEcb(ciphertext, key) {
|
|
7
|
+
const decipher = createDecipheriv('aes-128-ecb', key, null);
|
|
8
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
9
|
+
}
|
|
10
|
+
export function aesEcbPaddedSize(plaintextSize) {
|
|
11
|
+
return Math.ceil((plaintextSize + 1) / 16) * 16;
|
|
12
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { ILINK_APP_ID } from './constants.js';
|
|
2
|
+
import type { BaseInfo, GetUpdatesReq, GetUpdatesResp, SendMessageReq, SendTypingReq, GetConfigResp } from './types.js';
|
|
3
|
+
export type WeixinApiOptions = {
|
|
4
|
+
baseUrl: string;
|
|
5
|
+
token?: string;
|
|
6
|
+
timeoutMs?: number;
|
|
7
|
+
longPollTimeoutMs?: number;
|
|
8
|
+
};
|
|
9
|
+
export declare function buildBaseInfo(): BaseInfo;
|
|
10
|
+
export declare function apiGetFetch(params: {
|
|
11
|
+
baseUrl: string;
|
|
12
|
+
endpoint: string;
|
|
13
|
+
timeoutMs?: number;
|
|
14
|
+
label: string;
|
|
15
|
+
}): Promise<string>;
|
|
16
|
+
export declare function getUpdates(params: GetUpdatesReq & {
|
|
17
|
+
baseUrl: string;
|
|
18
|
+
token?: string;
|
|
19
|
+
timeoutMs?: number;
|
|
20
|
+
}): Promise<GetUpdatesResp>;
|
|
21
|
+
export declare function getUploadUrl(params: WeixinApiOptions & {
|
|
22
|
+
filekey?: string;
|
|
23
|
+
media_type?: number;
|
|
24
|
+
to_user_id?: string;
|
|
25
|
+
rawsize?: number;
|
|
26
|
+
rawfilemd5?: string;
|
|
27
|
+
filesize?: number;
|
|
28
|
+
thumb_rawsize?: number;
|
|
29
|
+
thumb_rawfilemd5?: string;
|
|
30
|
+
thumb_filesize?: number;
|
|
31
|
+
no_need_thumb?: boolean;
|
|
32
|
+
aeskey?: string;
|
|
33
|
+
}): Promise<import('./types.js').GetUploadUrlResp>;
|
|
34
|
+
export declare function sendMessage(params: WeixinApiOptions & {
|
|
35
|
+
body: SendMessageReq;
|
|
36
|
+
}): Promise<void>;
|
|
37
|
+
export declare function getConfig(params: WeixinApiOptions & {
|
|
38
|
+
ilinkUserId: string;
|
|
39
|
+
contextToken?: string;
|
|
40
|
+
}): Promise<GetConfigResp>;
|
|
41
|
+
export declare function sendTyping(params: WeixinApiOptions & {
|
|
42
|
+
body: SendTypingReq;
|
|
43
|
+
}): Promise<void>;
|
|
44
|
+
export { ILINK_APP_ID };
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { BRIDGE_PACKAGE_VERSION, ILINK_APP_ID } from './constants.js';
|
|
3
|
+
import { getIlinkRuntimeContext } from './context.js';
|
|
4
|
+
import { redactBody, redactUrl } from './redact.js';
|
|
5
|
+
export function buildBaseInfo() {
|
|
6
|
+
return { channel_version: BRIDGE_PACKAGE_VERSION };
|
|
7
|
+
}
|
|
8
|
+
const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000;
|
|
9
|
+
const DEFAULT_API_TIMEOUT_MS = 15_000;
|
|
10
|
+
const DEFAULT_CONFIG_TIMEOUT_MS = 10_000;
|
|
11
|
+
function ensureTrailingSlash(url) {
|
|
12
|
+
return url.endsWith('/') ? url : `${url}/`;
|
|
13
|
+
}
|
|
14
|
+
function randomWechatUin() {
|
|
15
|
+
const uint32 = crypto.randomBytes(4).readUInt32BE(0);
|
|
16
|
+
return Buffer.from(String(uint32), 'utf-8').toString('base64');
|
|
17
|
+
}
|
|
18
|
+
function buildCommonHeaders() {
|
|
19
|
+
const rt = getIlinkRuntimeContext();
|
|
20
|
+
const headers = {
|
|
21
|
+
'iLink-App-Id': rt.ilinkAppId,
|
|
22
|
+
'iLink-App-ClientVersion': String(rt.ilinkAppClientVersion),
|
|
23
|
+
};
|
|
24
|
+
if (rt.routeTag?.trim()) {
|
|
25
|
+
headers.SKRouteTag = rt.routeTag.trim();
|
|
26
|
+
}
|
|
27
|
+
return headers;
|
|
28
|
+
}
|
|
29
|
+
function buildHeaders(opts) {
|
|
30
|
+
const headers = {
|
|
31
|
+
'Content-Type': 'application/json',
|
|
32
|
+
AuthorizationType: 'ilink_bot_token',
|
|
33
|
+
'Content-Length': String(Buffer.byteLength(opts.body, 'utf-8')),
|
|
34
|
+
'X-WECHAT-UIN': randomWechatUin(),
|
|
35
|
+
...buildCommonHeaders(),
|
|
36
|
+
};
|
|
37
|
+
if (opts.token?.trim()) {
|
|
38
|
+
headers.Authorization = `Bearer ${opts.token.trim()}`;
|
|
39
|
+
}
|
|
40
|
+
return headers;
|
|
41
|
+
}
|
|
42
|
+
export async function apiGetFetch(params) {
|
|
43
|
+
const log = getIlinkRuntimeContext().logger;
|
|
44
|
+
const base = ensureTrailingSlash(params.baseUrl);
|
|
45
|
+
const url = new URL(params.endpoint, base);
|
|
46
|
+
const hdrs = buildCommonHeaders();
|
|
47
|
+
log.debug(`GET ${redactUrl(url.toString())}`);
|
|
48
|
+
const timeoutMs = params.timeoutMs;
|
|
49
|
+
const controller = timeoutMs != null && timeoutMs > 0 ? new AbortController() : undefined;
|
|
50
|
+
const t = controller != null && timeoutMs != null ? setTimeout(() => controller.abort(), timeoutMs) : undefined;
|
|
51
|
+
try {
|
|
52
|
+
const res = await fetch(url.toString(), {
|
|
53
|
+
method: 'GET',
|
|
54
|
+
headers: hdrs,
|
|
55
|
+
...(controller ? { signal: controller.signal } : {}),
|
|
56
|
+
});
|
|
57
|
+
if (t !== undefined)
|
|
58
|
+
clearTimeout(t);
|
|
59
|
+
const rawText = await res.text();
|
|
60
|
+
log.debug(`${params.label} status=${res.status} raw=${redactBody(rawText)}`);
|
|
61
|
+
if (!res.ok) {
|
|
62
|
+
throw new Error(`${params.label} ${res.status}: ${rawText}`);
|
|
63
|
+
}
|
|
64
|
+
return rawText;
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
if (t !== undefined)
|
|
68
|
+
clearTimeout(t);
|
|
69
|
+
throw err;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async function apiPostFetch(params) {
|
|
73
|
+
const log = getIlinkRuntimeContext().logger;
|
|
74
|
+
const base = ensureTrailingSlash(params.baseUrl);
|
|
75
|
+
const url = new URL(params.endpoint, base);
|
|
76
|
+
const hdrs = buildHeaders({
|
|
77
|
+
body: params.body,
|
|
78
|
+
...(params.token !== undefined && params.token !== '' ? { token: params.token } : {}),
|
|
79
|
+
});
|
|
80
|
+
log.debug(`POST ${redactUrl(url.toString())} body=${redactBody(params.body)}`);
|
|
81
|
+
const controller = new AbortController();
|
|
82
|
+
const t = setTimeout(() => controller.abort(), params.timeoutMs);
|
|
83
|
+
try {
|
|
84
|
+
const res = await fetch(url.toString(), {
|
|
85
|
+
method: 'POST',
|
|
86
|
+
headers: hdrs,
|
|
87
|
+
body: params.body,
|
|
88
|
+
signal: controller.signal,
|
|
89
|
+
});
|
|
90
|
+
clearTimeout(t);
|
|
91
|
+
const rawText = await res.text();
|
|
92
|
+
log.debug(`${params.label} status=${res.status} raw=${redactBody(rawText)}`);
|
|
93
|
+
if (!res.ok) {
|
|
94
|
+
throw new Error(`${params.label} ${res.status}: ${rawText}`);
|
|
95
|
+
}
|
|
96
|
+
return rawText;
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
clearTimeout(t);
|
|
100
|
+
throw err;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
export async function getUpdates(params) {
|
|
104
|
+
const log = getIlinkRuntimeContext().logger;
|
|
105
|
+
const timeout = params.timeoutMs ?? DEFAULT_LONG_POLL_TIMEOUT_MS;
|
|
106
|
+
try {
|
|
107
|
+
const rawText = await apiPostFetch({
|
|
108
|
+
baseUrl: params.baseUrl,
|
|
109
|
+
endpoint: 'ilink/bot/getupdates',
|
|
110
|
+
body: JSON.stringify({
|
|
111
|
+
get_updates_buf: params.get_updates_buf ?? '',
|
|
112
|
+
base_info: buildBaseInfo(),
|
|
113
|
+
}),
|
|
114
|
+
timeoutMs: timeout,
|
|
115
|
+
label: 'getUpdates',
|
|
116
|
+
...(params.token !== undefined && params.token !== '' ? { token: params.token } : {}),
|
|
117
|
+
});
|
|
118
|
+
return JSON.parse(rawText);
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
if (err instanceof Error && err.name === 'AbortError') {
|
|
122
|
+
log.debug(`getUpdates: client-side timeout after ${timeout}ms, returning empty response`);
|
|
123
|
+
return { ret: 0, msgs: [], get_updates_buf: params.get_updates_buf ?? '' };
|
|
124
|
+
}
|
|
125
|
+
throw err;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
export async function getUploadUrl(params) {
|
|
129
|
+
const rawText = await apiPostFetch({
|
|
130
|
+
baseUrl: params.baseUrl,
|
|
131
|
+
endpoint: 'ilink/bot/getuploadurl',
|
|
132
|
+
body: JSON.stringify({
|
|
133
|
+
filekey: params.filekey,
|
|
134
|
+
media_type: params.media_type,
|
|
135
|
+
to_user_id: params.to_user_id,
|
|
136
|
+
rawsize: params.rawsize,
|
|
137
|
+
rawfilemd5: params.rawfilemd5,
|
|
138
|
+
filesize: params.filesize,
|
|
139
|
+
thumb_rawsize: params.thumb_rawsize,
|
|
140
|
+
thumb_rawfilemd5: params.thumb_rawfilemd5,
|
|
141
|
+
thumb_filesize: params.thumb_filesize,
|
|
142
|
+
no_need_thumb: params.no_need_thumb,
|
|
143
|
+
aeskey: params.aeskey,
|
|
144
|
+
base_info: buildBaseInfo(),
|
|
145
|
+
}),
|
|
146
|
+
timeoutMs: params.timeoutMs ?? DEFAULT_API_TIMEOUT_MS,
|
|
147
|
+
label: 'getUploadUrl',
|
|
148
|
+
...(params.token !== undefined && params.token !== '' ? { token: params.token } : {}),
|
|
149
|
+
});
|
|
150
|
+
return JSON.parse(rawText);
|
|
151
|
+
}
|
|
152
|
+
export async function sendMessage(params) {
|
|
153
|
+
await apiPostFetch({
|
|
154
|
+
baseUrl: params.baseUrl,
|
|
155
|
+
endpoint: 'ilink/bot/sendmessage',
|
|
156
|
+
body: JSON.stringify({ ...params.body, base_info: buildBaseInfo() }),
|
|
157
|
+
timeoutMs: params.timeoutMs ?? DEFAULT_API_TIMEOUT_MS,
|
|
158
|
+
label: 'sendMessage',
|
|
159
|
+
...(params.token !== undefined && params.token !== '' ? { token: params.token } : {}),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
export async function getConfig(params) {
|
|
163
|
+
const rawText = await apiPostFetch({
|
|
164
|
+
baseUrl: params.baseUrl,
|
|
165
|
+
endpoint: 'ilink/bot/getconfig',
|
|
166
|
+
body: JSON.stringify({
|
|
167
|
+
ilink_user_id: params.ilinkUserId,
|
|
168
|
+
context_token: params.contextToken,
|
|
169
|
+
base_info: buildBaseInfo(),
|
|
170
|
+
}),
|
|
171
|
+
timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS,
|
|
172
|
+
label: 'getConfig',
|
|
173
|
+
...(params.token !== undefined && params.token !== '' ? { token: params.token } : {}),
|
|
174
|
+
});
|
|
175
|
+
return JSON.parse(rawText);
|
|
176
|
+
}
|
|
177
|
+
export async function sendTyping(params) {
|
|
178
|
+
await apiPostFetch({
|
|
179
|
+
baseUrl: params.baseUrl,
|
|
180
|
+
endpoint: 'ilink/bot/sendtyping',
|
|
181
|
+
body: JSON.stringify({ ...params.body, base_info: buildBaseInfo() }),
|
|
182
|
+
timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS,
|
|
183
|
+
label: 'sendTyping',
|
|
184
|
+
...(params.token !== undefined && params.token !== '' ? { token: params.token } : {}),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
export { ILINK_APP_ID };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { encryptAesEcb } from './aes_ecb.js';
|
|
2
|
+
import { buildCdnUploadUrl } from './cdn_url.js';
|
|
3
|
+
import { getIlinkRuntimeContext } from './context.js';
|
|
4
|
+
import { redactUrl } from './redact.js';
|
|
5
|
+
const UPLOAD_MAX_RETRIES = 3;
|
|
6
|
+
export async function uploadBufferToCdn(params) {
|
|
7
|
+
const log = getIlinkRuntimeContext().logger;
|
|
8
|
+
const { buf, uploadFullUrl, uploadParam, filekey, cdnBaseUrl, label, aeskey } = params;
|
|
9
|
+
const ciphertext = encryptAesEcb(buf, aeskey);
|
|
10
|
+
const trimmedFull = uploadFullUrl?.trim();
|
|
11
|
+
let cdnUrl;
|
|
12
|
+
if (trimmedFull) {
|
|
13
|
+
cdnUrl = trimmedFull;
|
|
14
|
+
}
|
|
15
|
+
else if (uploadParam) {
|
|
16
|
+
cdnUrl = buildCdnUploadUrl({ cdnBaseUrl, uploadParam, filekey });
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
throw new Error(`${label}: CDN upload URL missing (need upload_full_url or upload_param)`);
|
|
20
|
+
}
|
|
21
|
+
log.debug(`${label}: CDN POST url=${redactUrl(cdnUrl)} ciphertextSize=${ciphertext.length}`);
|
|
22
|
+
let downloadParam;
|
|
23
|
+
let lastError;
|
|
24
|
+
for (let attempt = 1; attempt <= UPLOAD_MAX_RETRIES; attempt++) {
|
|
25
|
+
try {
|
|
26
|
+
const res = await fetch(cdnUrl, {
|
|
27
|
+
method: 'POST',
|
|
28
|
+
headers: { 'Content-Type': 'application/octet-stream' },
|
|
29
|
+
body: new Uint8Array(ciphertext),
|
|
30
|
+
});
|
|
31
|
+
if (res.status >= 400 && res.status < 500) {
|
|
32
|
+
const errMsg = res.headers.get('x-error-message') ?? (await res.text());
|
|
33
|
+
throw new Error(`CDN upload client error ${res.status}: ${errMsg}`);
|
|
34
|
+
}
|
|
35
|
+
if (res.status !== 200) {
|
|
36
|
+
const errMsg = res.headers.get('x-error-message') ?? `status ${res.status}`;
|
|
37
|
+
throw new Error(`CDN upload server error: ${errMsg}`);
|
|
38
|
+
}
|
|
39
|
+
downloadParam = res.headers.get('x-encrypted-param') ?? undefined;
|
|
40
|
+
if (!downloadParam) {
|
|
41
|
+
throw new Error('CDN upload response missing x-encrypted-param header');
|
|
42
|
+
}
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
lastError = err;
|
|
47
|
+
if (err instanceof Error && err.message.includes('client error'))
|
|
48
|
+
throw err;
|
|
49
|
+
if (attempt >= UPLOAD_MAX_RETRIES) {
|
|
50
|
+
log.error(`${label}: all ${UPLOAD_MAX_RETRIES} attempts failed err=${String(err)}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (!downloadParam) {
|
|
55
|
+
throw lastError instanceof Error
|
|
56
|
+
? lastError
|
|
57
|
+
: new Error(`CDN upload failed after ${UPLOAD_MAX_RETRIES} attempts`);
|
|
58
|
+
}
|
|
59
|
+
return { downloadParam };
|
|
60
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare const ENABLE_CDN_URL_FALLBACK = true;
|
|
2
|
+
export declare function buildCdnDownloadUrl(encryptedQueryParam: string, cdnBaseUrl: string): string;
|
|
3
|
+
export declare function buildCdnUploadUrl(params: {
|
|
4
|
+
cdnBaseUrl: string;
|
|
5
|
+
uploadParam: string;
|
|
6
|
+
filekey: string;
|
|
7
|
+
}): string;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export const ENABLE_CDN_URL_FALLBACK = true;
|
|
2
|
+
export function buildCdnDownloadUrl(encryptedQueryParam, cdnBaseUrl) {
|
|
3
|
+
return `${cdnBaseUrl}/download?encrypted_query_param=${encodeURIComponent(encryptedQueryParam)}`;
|
|
4
|
+
}
|
|
5
|
+
export function buildCdnUploadUrl(params) {
|
|
6
|
+
return `${params.cdnBaseUrl}/upload?encrypted_query_param=${encodeURIComponent(params.uploadParam)}&filekey=${encodeURIComponent(params.filekey)}`;
|
|
7
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** iLink app id (matches openclaw-weixin package.json ilink_appid). */
|
|
2
|
+
export declare const ILINK_APP_ID = "bot";
|
|
3
|
+
export declare const FIXED_QR_BASE_URL = "https://ilinkai.weixin.qq.com";
|
|
4
|
+
export declare const DEFAULT_CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c";
|
|
5
|
+
export declare const BRIDGE_PACKAGE_VERSION: string;
|
|
6
|
+
export declare function buildClientVersion(version: string): number;
|
|
7
|
+
export declare const ILINK_APP_CLIENT_VERSION: number;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
/** iLink app id (matches openclaw-weixin package.json ilink_appid). */
|
|
5
|
+
export const ILINK_APP_ID = 'bot';
|
|
6
|
+
export const FIXED_QR_BASE_URL = 'https://ilinkai.weixin.qq.com';
|
|
7
|
+
export const DEFAULT_CDN_BASE_URL = 'https://novac2c.cdn.weixin.qq.com/c2c';
|
|
8
|
+
function readBridgeVersion() {
|
|
9
|
+
try {
|
|
10
|
+
const dir = path.dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const pkgPath = path.resolve(dir, '../../../..', 'package.json');
|
|
12
|
+
const j = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
13
|
+
return j.version ?? '0.0.0';
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return '0.0.0';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export const BRIDGE_PACKAGE_VERSION = readBridgeVersion();
|
|
20
|
+
export function buildClientVersion(version) {
|
|
21
|
+
const parts = version.split('.').map((p) => parseInt(p, 10));
|
|
22
|
+
const major = parts[0] ?? 0;
|
|
23
|
+
const minor = parts[1] ?? 0;
|
|
24
|
+
const patch = parts[2] ?? 0;
|
|
25
|
+
return ((major & 0xff) << 16) | ((minor & 0xff) << 8) | (patch & 0xff);
|
|
26
|
+
}
|
|
27
|
+
export const ILINK_APP_CLIENT_VERSION = buildClientVersion(BRIDGE_PACKAGE_VERSION);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Logger } from '../../../logger.js';
|
|
2
|
+
export type IlinkLogger = Pick<Logger, 'debug' | 'info' | 'warn' | 'error'>;
|
|
3
|
+
export interface IlinkRuntimeContext {
|
|
4
|
+
logger: IlinkLogger;
|
|
5
|
+
/** Optional routing tag for some IDC deployments. */
|
|
6
|
+
routeTag?: string;
|
|
7
|
+
channelVersion: string;
|
|
8
|
+
ilinkAppId: string;
|
|
9
|
+
ilinkAppClientVersion: number;
|
|
10
|
+
}
|
|
11
|
+
export declare function setIlinkRuntimeContext(next: IlinkRuntimeContext | null): void;
|
|
12
|
+
export declare function getIlinkRuntimeContext(): IlinkRuntimeContext;
|
|
13
|
+
export declare function tryGetIlinkRuntimeContext(): IlinkRuntimeContext | null;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
let ctx = null;
|
|
2
|
+
export function setIlinkRuntimeContext(next) {
|
|
3
|
+
ctx = next;
|
|
4
|
+
}
|
|
5
|
+
export function getIlinkRuntimeContext() {
|
|
6
|
+
if (!ctx) {
|
|
7
|
+
throw new Error('Weixin iLink runtime not initialized (setIlinkRuntimeContext)');
|
|
8
|
+
}
|
|
9
|
+
return ctx;
|
|
10
|
+
}
|
|
11
|
+
export function tryGetIlinkRuntimeContext() {
|
|
12
|
+
return ctx;
|
|
13
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export declare const DEFAULT_ILINK_BOT_TYPE = "3";
|
|
2
|
+
export type WeixinQrStartResult = {
|
|
3
|
+
qrcodeUrl?: string;
|
|
4
|
+
message: string;
|
|
5
|
+
sessionKey: string;
|
|
6
|
+
};
|
|
7
|
+
export type WeixinQrWaitResult = {
|
|
8
|
+
connected: boolean;
|
|
9
|
+
botToken?: string;
|
|
10
|
+
accountId?: string;
|
|
11
|
+
baseUrl?: string;
|
|
12
|
+
userId?: string;
|
|
13
|
+
message: string;
|
|
14
|
+
};
|
|
15
|
+
export declare function startWeixinLoginWithQr(opts: {
|
|
16
|
+
verbose?: boolean;
|
|
17
|
+
timeoutMs?: number;
|
|
18
|
+
force?: boolean;
|
|
19
|
+
accountId?: string;
|
|
20
|
+
apiBaseUrl: string;
|
|
21
|
+
botType?: string;
|
|
22
|
+
}): Promise<WeixinQrStartResult>;
|
|
23
|
+
export type WeixinLoginNotify = (line: string) => void;
|
|
24
|
+
export declare function waitForWeixinLogin(opts: {
|
|
25
|
+
timeoutMs?: number;
|
|
26
|
+
verbose?: boolean;
|
|
27
|
+
sessionKey: string;
|
|
28
|
+
apiBaseUrl: string;
|
|
29
|
+
botType?: string;
|
|
30
|
+
/** Optional user-facing lines (e.g. process.stdout.write). */
|
|
31
|
+
notify?: WeixinLoginNotify;
|
|
32
|
+
/** Called when QR was refreshed after expiry so CLI can re-render terminal QR. */
|
|
33
|
+
onQrRefreshed?: (qrcodeUrl: string) => void;
|
|
34
|
+
}): Promise<WeixinQrWaitResult>;
|