@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,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Weixin iLink protocol types (from openclaw-weixin, MIT).
|
|
3
|
+
*/
|
|
4
|
+
export interface BaseInfo {
|
|
5
|
+
channel_version?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare const UploadMediaType: {
|
|
8
|
+
readonly IMAGE: 1;
|
|
9
|
+
readonly VIDEO: 2;
|
|
10
|
+
readonly FILE: 3;
|
|
11
|
+
readonly VOICE: 4;
|
|
12
|
+
};
|
|
13
|
+
export interface GetUploadUrlReq {
|
|
14
|
+
filekey?: string;
|
|
15
|
+
media_type?: number;
|
|
16
|
+
to_user_id?: string;
|
|
17
|
+
rawsize?: number;
|
|
18
|
+
rawfilemd5?: string;
|
|
19
|
+
filesize?: number;
|
|
20
|
+
thumb_rawsize?: number;
|
|
21
|
+
thumb_rawfilemd5?: string;
|
|
22
|
+
thumb_filesize?: number;
|
|
23
|
+
no_need_thumb?: boolean;
|
|
24
|
+
aeskey?: string;
|
|
25
|
+
}
|
|
26
|
+
export interface GetUploadUrlResp {
|
|
27
|
+
upload_param?: string;
|
|
28
|
+
thumb_upload_param?: string;
|
|
29
|
+
upload_full_url?: string;
|
|
30
|
+
}
|
|
31
|
+
export declare const MessageType: {
|
|
32
|
+
readonly NONE: 0;
|
|
33
|
+
readonly USER: 1;
|
|
34
|
+
readonly BOT: 2;
|
|
35
|
+
};
|
|
36
|
+
export declare const MessageItemType: {
|
|
37
|
+
readonly NONE: 0;
|
|
38
|
+
readonly TEXT: 1;
|
|
39
|
+
readonly IMAGE: 2;
|
|
40
|
+
readonly VOICE: 3;
|
|
41
|
+
readonly FILE: 4;
|
|
42
|
+
readonly VIDEO: 5;
|
|
43
|
+
};
|
|
44
|
+
export declare const MessageState: {
|
|
45
|
+
readonly NEW: 0;
|
|
46
|
+
readonly GENERATING: 1;
|
|
47
|
+
readonly FINISH: 2;
|
|
48
|
+
};
|
|
49
|
+
export interface TextItem {
|
|
50
|
+
text?: string;
|
|
51
|
+
}
|
|
52
|
+
export interface CDNMedia {
|
|
53
|
+
encrypt_query_param?: string;
|
|
54
|
+
aes_key?: string;
|
|
55
|
+
encrypt_type?: number;
|
|
56
|
+
full_url?: string;
|
|
57
|
+
}
|
|
58
|
+
export interface ImageItem {
|
|
59
|
+
media?: CDNMedia;
|
|
60
|
+
thumb_media?: CDNMedia;
|
|
61
|
+
aeskey?: string;
|
|
62
|
+
url?: string;
|
|
63
|
+
mid_size?: number;
|
|
64
|
+
thumb_size?: number;
|
|
65
|
+
thumb_height?: number;
|
|
66
|
+
thumb_width?: number;
|
|
67
|
+
hd_size?: number;
|
|
68
|
+
}
|
|
69
|
+
export interface VoiceItem {
|
|
70
|
+
media?: CDNMedia;
|
|
71
|
+
encode_type?: number;
|
|
72
|
+
bits_per_sample?: number;
|
|
73
|
+
sample_rate?: number;
|
|
74
|
+
playtime?: number;
|
|
75
|
+
text?: string;
|
|
76
|
+
}
|
|
77
|
+
export interface FileItem {
|
|
78
|
+
media?: CDNMedia;
|
|
79
|
+
file_name?: string;
|
|
80
|
+
md5?: string;
|
|
81
|
+
len?: string;
|
|
82
|
+
}
|
|
83
|
+
export interface VideoItem {
|
|
84
|
+
media?: CDNMedia;
|
|
85
|
+
video_size?: number;
|
|
86
|
+
play_length?: number;
|
|
87
|
+
video_md5?: string;
|
|
88
|
+
thumb_media?: CDNMedia;
|
|
89
|
+
thumb_size?: number;
|
|
90
|
+
thumb_height?: number;
|
|
91
|
+
thumb_width?: number;
|
|
92
|
+
}
|
|
93
|
+
export interface RefMessage {
|
|
94
|
+
message_item?: MessageItem;
|
|
95
|
+
title?: string;
|
|
96
|
+
}
|
|
97
|
+
export interface MessageItem {
|
|
98
|
+
type?: number;
|
|
99
|
+
create_time_ms?: number;
|
|
100
|
+
update_time_ms?: number;
|
|
101
|
+
is_completed?: boolean;
|
|
102
|
+
msg_id?: string;
|
|
103
|
+
ref_msg?: RefMessage;
|
|
104
|
+
text_item?: TextItem;
|
|
105
|
+
image_item?: ImageItem;
|
|
106
|
+
voice_item?: VoiceItem;
|
|
107
|
+
file_item?: FileItem;
|
|
108
|
+
video_item?: VideoItem;
|
|
109
|
+
}
|
|
110
|
+
export interface WeixinMessage {
|
|
111
|
+
seq?: number;
|
|
112
|
+
message_id?: number;
|
|
113
|
+
from_user_id?: string;
|
|
114
|
+
to_user_id?: string;
|
|
115
|
+
client_id?: string;
|
|
116
|
+
create_time_ms?: number;
|
|
117
|
+
update_time_ms?: number;
|
|
118
|
+
delete_time_ms?: number;
|
|
119
|
+
session_id?: string;
|
|
120
|
+
group_id?: string;
|
|
121
|
+
message_type?: number;
|
|
122
|
+
message_state?: number;
|
|
123
|
+
item_list?: MessageItem[];
|
|
124
|
+
context_token?: string;
|
|
125
|
+
}
|
|
126
|
+
export interface GetUpdatesReq {
|
|
127
|
+
sync_buf?: string;
|
|
128
|
+
get_updates_buf?: string;
|
|
129
|
+
}
|
|
130
|
+
export interface GetUpdatesResp {
|
|
131
|
+
ret?: number;
|
|
132
|
+
errcode?: number;
|
|
133
|
+
errmsg?: string;
|
|
134
|
+
msgs?: WeixinMessage[];
|
|
135
|
+
sync_buf?: string;
|
|
136
|
+
get_updates_buf?: string;
|
|
137
|
+
longpolling_timeout_ms?: number;
|
|
138
|
+
}
|
|
139
|
+
export interface SendMessageReq {
|
|
140
|
+
msg?: WeixinMessage;
|
|
141
|
+
}
|
|
142
|
+
export declare const TypingStatus: {
|
|
143
|
+
readonly TYPING: 1;
|
|
144
|
+
readonly CANCEL: 2;
|
|
145
|
+
};
|
|
146
|
+
export interface SendTypingReq {
|
|
147
|
+
ilink_user_id?: string;
|
|
148
|
+
typing_ticket?: string;
|
|
149
|
+
status?: number;
|
|
150
|
+
}
|
|
151
|
+
export interface GetConfigResp {
|
|
152
|
+
ret?: number;
|
|
153
|
+
errmsg?: string;
|
|
154
|
+
typing_ticket?: string;
|
|
155
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export const UploadMediaType = {
|
|
2
|
+
IMAGE: 1,
|
|
3
|
+
VIDEO: 2,
|
|
4
|
+
FILE: 3,
|
|
5
|
+
VOICE: 4,
|
|
6
|
+
};
|
|
7
|
+
export const MessageType = { NONE: 0, USER: 1, BOT: 2 };
|
|
8
|
+
export const MessageItemType = { NONE: 0, TEXT: 1, IMAGE: 2, VOICE: 3, FILE: 4, VIDEO: 5 };
|
|
9
|
+
export const MessageState = { NEW: 0, GENERATING: 1, FINISH: 2 };
|
|
10
|
+
export const TypingStatus = { TYPING: 1, CANCEL: 2 };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { WeixinApiOptions } from './api.js';
|
|
2
|
+
export type UploadedFileInfo = {
|
|
3
|
+
filekey: string;
|
|
4
|
+
downloadEncryptedQueryParam: string;
|
|
5
|
+
aeskey: string;
|
|
6
|
+
fileSize: number;
|
|
7
|
+
fileSizeCiphertext: number;
|
|
8
|
+
};
|
|
9
|
+
export declare function uploadFileToWeixin(params: {
|
|
10
|
+
filePath: string;
|
|
11
|
+
toUserId: string;
|
|
12
|
+
opts: WeixinApiOptions;
|
|
13
|
+
cdnBaseUrl: string;
|
|
14
|
+
}): Promise<UploadedFileInfo>;
|
|
15
|
+
export declare function downloadRemoteImageToTemp(url: string, destDir: string): Promise<string>;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { getUploadUrl } from './api.js';
|
|
5
|
+
import { aesEcbPaddedSize } from './aes_ecb.js';
|
|
6
|
+
import { uploadBufferToCdn } from './cdn_upload.js';
|
|
7
|
+
import { getIlinkRuntimeContext } from './context.js';
|
|
8
|
+
import { getExtensionFromContentTypeOrUrl } from './mime.js';
|
|
9
|
+
import { tempFileName } from './random.js';
|
|
10
|
+
import { UploadMediaType } from './types.js';
|
|
11
|
+
async function uploadMediaToCdn(params) {
|
|
12
|
+
const log = getIlinkRuntimeContext().logger;
|
|
13
|
+
const { filePath, toUserId, opts, cdnBaseUrl, mediaType, label } = params;
|
|
14
|
+
const plaintext = await fs.readFile(filePath);
|
|
15
|
+
const rawsize = plaintext.length;
|
|
16
|
+
const rawfilemd5 = crypto.createHash('md5').update(plaintext).digest('hex');
|
|
17
|
+
const filesize = aesEcbPaddedSize(rawsize);
|
|
18
|
+
const filekey = crypto.randomBytes(16).toString('hex');
|
|
19
|
+
const aeskey = crypto.randomBytes(16);
|
|
20
|
+
log.debug(`${label}: file=${filePath} rawsize=${rawsize} filekey=${filekey}`);
|
|
21
|
+
const uploadUrlResp = await getUploadUrl({
|
|
22
|
+
...opts,
|
|
23
|
+
filekey,
|
|
24
|
+
media_type: mediaType,
|
|
25
|
+
to_user_id: toUserId,
|
|
26
|
+
rawsize,
|
|
27
|
+
rawfilemd5,
|
|
28
|
+
filesize,
|
|
29
|
+
no_need_thumb: true,
|
|
30
|
+
aeskey: aeskey.toString('hex'),
|
|
31
|
+
});
|
|
32
|
+
const uploadFullUrl = uploadUrlResp.upload_full_url?.trim();
|
|
33
|
+
const uploadParam = uploadUrlResp.upload_param;
|
|
34
|
+
if (!uploadFullUrl && !uploadParam) {
|
|
35
|
+
throw new Error(`${label}: getUploadUrl returned no upload URL`);
|
|
36
|
+
}
|
|
37
|
+
const { downloadParam } = await uploadBufferToCdn({
|
|
38
|
+
buf: plaintext,
|
|
39
|
+
filekey,
|
|
40
|
+
cdnBaseUrl,
|
|
41
|
+
aeskey,
|
|
42
|
+
label: `${label}[filekey=${filekey}]`,
|
|
43
|
+
...(uploadFullUrl ? { uploadFullUrl } : {}),
|
|
44
|
+
...(uploadParam ? { uploadParam } : {}),
|
|
45
|
+
});
|
|
46
|
+
return {
|
|
47
|
+
filekey,
|
|
48
|
+
downloadEncryptedQueryParam: downloadParam,
|
|
49
|
+
aeskey: aeskey.toString('hex'),
|
|
50
|
+
fileSize: rawsize,
|
|
51
|
+
fileSizeCiphertext: filesize,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
export async function uploadFileToWeixin(params) {
|
|
55
|
+
return uploadMediaToCdn({
|
|
56
|
+
...params,
|
|
57
|
+
mediaType: UploadMediaType.IMAGE,
|
|
58
|
+
label: 'uploadFileToWeixin',
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
export async function downloadRemoteImageToTemp(url, destDir) {
|
|
62
|
+
const log = getIlinkRuntimeContext().logger;
|
|
63
|
+
log.debug(`downloadRemoteImageToTemp: fetching url=${url}`);
|
|
64
|
+
const res = await fetch(url);
|
|
65
|
+
if (!res.ok) {
|
|
66
|
+
throw new Error(`remote media download failed: ${res.status} url=${url}`);
|
|
67
|
+
}
|
|
68
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
69
|
+
await fs.mkdir(destDir, { recursive: true });
|
|
70
|
+
const ext = getExtensionFromContentTypeOrUrl(res.headers.get('content-type'), url);
|
|
71
|
+
const name = tempFileName('weixin-remote', ext);
|
|
72
|
+
const filePath = path.join(destDir, name);
|
|
73
|
+
await fs.writeFile(filePath, buf);
|
|
74
|
+
return filePath;
|
|
75
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
export function getWeixinSyncBufPath(syncBufDir, accountId) {
|
|
4
|
+
const safe = accountId.replace(/[^\w.-]+/g, '-');
|
|
5
|
+
return path.join(syncBufDir, `${safe}.buf`);
|
|
6
|
+
}
|
|
7
|
+
export function loadGetUpdatesBuf(filePath) {
|
|
8
|
+
try {
|
|
9
|
+
const raw = fs.readFileSync(filePath, 'utf-8').trim();
|
|
10
|
+
return raw.length ? raw : null;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export function saveGetUpdatesBuf(filePath, buf) {
|
|
17
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
18
|
+
fs.writeFileSync(filePath, buf, 'utf-8');
|
|
19
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { AppConfig } from '../../config.js';
|
|
2
|
+
import type { BridgeSessionCore } from '../../controller/controller.js';
|
|
3
|
+
import type { Logger } from '../../logger.js';
|
|
4
|
+
import type { BridgeStore } from '../../store/database.js';
|
|
5
|
+
export declare class WeixinChannelAdapter {
|
|
6
|
+
private readonly core;
|
|
7
|
+
private readonly store;
|
|
8
|
+
private readonly config;
|
|
9
|
+
private readonly logger;
|
|
10
|
+
private readonly abort;
|
|
11
|
+
private loops;
|
|
12
|
+
constructor(core: BridgeSessionCore, store: BridgeStore, config: AppConfig, logger: Logger);
|
|
13
|
+
start(): Promise<void>;
|
|
14
|
+
stop(): Promise<void>;
|
|
15
|
+
private listAccounts;
|
|
16
|
+
private runAccountLoop;
|
|
17
|
+
private dispatchOneMessage;
|
|
18
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { toWeixinBridgeScopeId } from '../../core/bridge_scope.js';
|
|
4
|
+
import { getUpdates } from './ilink/api.js';
|
|
5
|
+
import { DEFAULT_CDN_BASE_URL } from './ilink/constants.js';
|
|
6
|
+
import { downloadWeixinImageItemToFile } from './ilink/media_image.js';
|
|
7
|
+
import { SESSION_EXPIRED_ERRCODE, getRemainingPauseMs, pauseSession, } from './ilink/session_guard.js';
|
|
8
|
+
import { MessageItemType, MessageType } from './ilink/types.js';
|
|
9
|
+
import { getWeixinSyncBufPath, loadGetUpdatesBuf, saveGetUpdatesBuf } from './sync_buf_store.js';
|
|
10
|
+
const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000;
|
|
11
|
+
const MAX_CONSECUTIVE_FAILURES = 3;
|
|
12
|
+
const BACKOFF_DELAY_MS = 30_000;
|
|
13
|
+
const RETRY_DELAY_MS = 2_000;
|
|
14
|
+
function sleep(ms, signal) {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
const t = setTimeout(resolve, ms);
|
|
17
|
+
signal.addEventListener('abort', () => {
|
|
18
|
+
clearTimeout(t);
|
|
19
|
+
reject(new Error('aborted'));
|
|
20
|
+
}, { once: true });
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
function isAllowedWeixinUser(config, fromUserId) {
|
|
24
|
+
if (config.wxAllowedIlinkUserIds.length === 0) {
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
return config.wxAllowedIlinkUserIds.includes(fromUserId);
|
|
28
|
+
}
|
|
29
|
+
function buildTelegramShapedEvent(params) {
|
|
30
|
+
return {
|
|
31
|
+
chatId: params.fromUserId,
|
|
32
|
+
topicId: null,
|
|
33
|
+
scopeId: params.scopeId,
|
|
34
|
+
chatType: 'private',
|
|
35
|
+
userId: params.fromUserId,
|
|
36
|
+
text: params.text,
|
|
37
|
+
messageId: params.messageId,
|
|
38
|
+
attachments: params.attachments,
|
|
39
|
+
entities: [],
|
|
40
|
+
replyToBot: false,
|
|
41
|
+
languageCode: 'zh',
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function normalizeInboundBaseUrl(account) {
|
|
45
|
+
const raw = account.baseUrl.trim();
|
|
46
|
+
if (raw.startsWith('http://') || raw.startsWith('https://')) {
|
|
47
|
+
return raw;
|
|
48
|
+
}
|
|
49
|
+
return `https://${raw}`;
|
|
50
|
+
}
|
|
51
|
+
export class WeixinChannelAdapter {
|
|
52
|
+
core;
|
|
53
|
+
store;
|
|
54
|
+
config;
|
|
55
|
+
logger;
|
|
56
|
+
abort = new AbortController();
|
|
57
|
+
loops = [];
|
|
58
|
+
constructor(core, store, config, logger) {
|
|
59
|
+
this.core = core;
|
|
60
|
+
this.store = store;
|
|
61
|
+
this.config = config;
|
|
62
|
+
this.logger = logger;
|
|
63
|
+
}
|
|
64
|
+
async start() {
|
|
65
|
+
const accounts = this.listAccounts();
|
|
66
|
+
if (accounts.length === 0) {
|
|
67
|
+
this.logger.warn('weixin.start_no_accounts', { dir: this.config.weixinAccountsDir });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
for (const account of accounts) {
|
|
71
|
+
this.loops.push(this.runAccountLoop(account));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
async stop() {
|
|
75
|
+
this.abort.abort();
|
|
76
|
+
await Promise.allSettled(this.loops);
|
|
77
|
+
this.loops = [];
|
|
78
|
+
}
|
|
79
|
+
listAccounts() {
|
|
80
|
+
if (!this.config.wxEnabled) {
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
if (!fs.existsSync(this.config.weixinAccountsDir)) {
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
const out = [];
|
|
87
|
+
for (const name of fs.readdirSync(this.config.weixinAccountsDir)) {
|
|
88
|
+
if (!name.endsWith('.json'))
|
|
89
|
+
continue;
|
|
90
|
+
try {
|
|
91
|
+
const raw = fs.readFileSync(path.join(this.config.weixinAccountsDir, name), 'utf-8');
|
|
92
|
+
const parsed = JSON.parse(raw);
|
|
93
|
+
if (parsed.accountId && parsed.botToken && parsed.baseUrl) {
|
|
94
|
+
out.push(parsed);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// skip invalid
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
async runAccountLoop(account) {
|
|
104
|
+
const accountId = account.accountId;
|
|
105
|
+
const baseUrl = normalizeInboundBaseUrl(account);
|
|
106
|
+
const cdnBaseUrl = DEFAULT_CDN_BASE_URL;
|
|
107
|
+
const syncPath = getWeixinSyncBufPath(this.config.weixinSyncBufDir, accountId);
|
|
108
|
+
let getUpdatesBuf = loadGetUpdatesBuf(syncPath) ?? '';
|
|
109
|
+
let nextTimeoutMs = DEFAULT_LONG_POLL_TIMEOUT_MS;
|
|
110
|
+
let consecutiveFailures = 0;
|
|
111
|
+
const signal = this.abort.signal;
|
|
112
|
+
const inboundTemp = path.join(this.config.weixinMediaDir, 'inbound-temp', accountId);
|
|
113
|
+
this.logger.info('weixin.monitor_started', { accountId, baseUrl });
|
|
114
|
+
while (!signal.aborted) {
|
|
115
|
+
try {
|
|
116
|
+
const resp = await getUpdates({
|
|
117
|
+
baseUrl,
|
|
118
|
+
token: account.botToken,
|
|
119
|
+
get_updates_buf: getUpdatesBuf,
|
|
120
|
+
timeoutMs: nextTimeoutMs,
|
|
121
|
+
});
|
|
122
|
+
if (resp.longpolling_timeout_ms != null && resp.longpolling_timeout_ms > 0) {
|
|
123
|
+
nextTimeoutMs = resp.longpolling_timeout_ms;
|
|
124
|
+
}
|
|
125
|
+
const isApiError = (resp.ret !== undefined && resp.ret !== 0) ||
|
|
126
|
+
(resp.errcode !== undefined && resp.errcode !== 0);
|
|
127
|
+
if (isApiError) {
|
|
128
|
+
const isSessionExpired = resp.errcode === SESSION_EXPIRED_ERRCODE || resp.ret === SESSION_EXPIRED_ERRCODE;
|
|
129
|
+
if (isSessionExpired) {
|
|
130
|
+
pauseSession(accountId);
|
|
131
|
+
const pauseMs = getRemainingPauseMs(accountId);
|
|
132
|
+
this.logger.error('weixin.session_expired', { accountId, pauseMs });
|
|
133
|
+
consecutiveFailures = 0;
|
|
134
|
+
try {
|
|
135
|
+
await sleep(pauseMs, signal);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
consecutiveFailures += 1;
|
|
143
|
+
this.logger.warn('weixin.getupdates_failed', {
|
|
144
|
+
accountId,
|
|
145
|
+
ret: resp.ret,
|
|
146
|
+
errcode: resp.errcode,
|
|
147
|
+
consecutiveFailures,
|
|
148
|
+
});
|
|
149
|
+
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
|
150
|
+
consecutiveFailures = 0;
|
|
151
|
+
try {
|
|
152
|
+
await sleep(BACKOFF_DELAY_MS, signal);
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
try {
|
|
160
|
+
await sleep(RETRY_DELAY_MS, signal);
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
consecutiveFailures = 0;
|
|
169
|
+
if (resp.get_updates_buf != null && resp.get_updates_buf !== '') {
|
|
170
|
+
saveGetUpdatesBuf(syncPath, resp.get_updates_buf);
|
|
171
|
+
getUpdatesBuf = resp.get_updates_buf;
|
|
172
|
+
}
|
|
173
|
+
for (const msg of resp.msgs ?? []) {
|
|
174
|
+
await this.dispatchOneMessage(msg, accountId, cdnBaseUrl, inboundTemp);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
if (signal.aborted) {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
consecutiveFailures += 1;
|
|
182
|
+
this.logger.error('weixin.poll_error', { accountId, error: String(err) });
|
|
183
|
+
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
|
184
|
+
consecutiveFailures = 0;
|
|
185
|
+
try {
|
|
186
|
+
await sleep(BACKOFF_DELAY_MS, signal);
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
try {
|
|
194
|
+
await sleep(RETRY_DELAY_MS, signal);
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
async dispatchOneMessage(msg, accountId, cdnBaseUrl, inboundTemp) {
|
|
204
|
+
if (msg.message_type === MessageType.BOT) {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
const fromUserId = msg.from_user_id?.trim() ?? '';
|
|
208
|
+
if (!fromUserId) {
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (!isAllowedWeixinUser(this.config, fromUserId)) {
|
|
212
|
+
this.logger.debug('weixin.inbound_denied', { accountId, fromUserId });
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const scopeId = toWeixinBridgeScopeId(accountId, fromUserId);
|
|
216
|
+
if (msg.context_token) {
|
|
217
|
+
this.store.setWeixinContextToken(scopeId, msg.context_token);
|
|
218
|
+
}
|
|
219
|
+
let text = '';
|
|
220
|
+
const attachments = [];
|
|
221
|
+
const items = msg.item_list ?? [];
|
|
222
|
+
for (const item of items) {
|
|
223
|
+
if (item.type === MessageItemType.TEXT) {
|
|
224
|
+
text += item.text_item?.text ?? '';
|
|
225
|
+
}
|
|
226
|
+
else if (item.type === MessageItemType.IMAGE) {
|
|
227
|
+
const localPath = await downloadWeixinImageItemToFile({
|
|
228
|
+
item,
|
|
229
|
+
cdnBaseUrl,
|
|
230
|
+
destDir: inboundTemp,
|
|
231
|
+
label: `${scopeId}`,
|
|
232
|
+
});
|
|
233
|
+
if (localPath) {
|
|
234
|
+
attachments.push({
|
|
235
|
+
kind: 'document',
|
|
236
|
+
fileId: 'weixin-image',
|
|
237
|
+
fileUniqueId: `wx-${msg.message_id ?? Date.now()}-${attachments.length}`,
|
|
238
|
+
fileName: path.basename(localPath),
|
|
239
|
+
mimeType: 'image/jpeg',
|
|
240
|
+
fileSize: null,
|
|
241
|
+
width: null,
|
|
242
|
+
height: null,
|
|
243
|
+
durationSeconds: null,
|
|
244
|
+
isAnimated: false,
|
|
245
|
+
isVideo: false,
|
|
246
|
+
localPath,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const trimmed = text.trim();
|
|
252
|
+
if (!trimmed && attachments.length === 0) {
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const midRaw = Number(msg.message_id);
|
|
256
|
+
const messageId = Number.isFinite(midRaw) ? midRaw : Math.floor(Math.random() * 1_000_000_000);
|
|
257
|
+
this.logger.info('weixin.inbound', {
|
|
258
|
+
accountId,
|
|
259
|
+
fromUserId,
|
|
260
|
+
scopeId,
|
|
261
|
+
textLen: trimmed.length,
|
|
262
|
+
attachments: attachments.length,
|
|
263
|
+
});
|
|
264
|
+
const event = buildTelegramShapedEvent({
|
|
265
|
+
scopeId,
|
|
266
|
+
fromUserId,
|
|
267
|
+
text: trimmed,
|
|
268
|
+
messageId,
|
|
269
|
+
attachments,
|
|
270
|
+
});
|
|
271
|
+
this.core.dispatchInboundLikeTelegramText(event);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { BridgeStore } from '../../store/database.js';
|
|
2
|
+
import { getConfig, sendTyping } from './ilink/api.js';
|
|
3
|
+
import type { WeixinSavedAccount } from './account_store.js';
|
|
4
|
+
import type { ChannelInlineKeyboard } from '../../core/channel_port.js';
|
|
5
|
+
type WeixinTypingApi = {
|
|
6
|
+
getConfig: typeof getConfig;
|
|
7
|
+
sendTyping: typeof sendTyping;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Weixin (iLink) outbound: addressed by `weixin:<accountId>:<peerUserId>` scope ids.
|
|
11
|
+
* Editing/deleting Telegram messages is approximated as new plain text sends or no-ops.
|
|
12
|
+
*/
|
|
13
|
+
export declare class WeixinMessagingPort {
|
|
14
|
+
private readonly store;
|
|
15
|
+
private readonly loadAccount;
|
|
16
|
+
private readonly typingApi;
|
|
17
|
+
private nextSyntheticId;
|
|
18
|
+
constructor(store: BridgeStore, loadAccount: (accountId: string) => WeixinSavedAccount | null, typingApi?: WeixinTypingApi);
|
|
19
|
+
private allocMessageId;
|
|
20
|
+
sendPlain(scopeId: string, text: string, keyboard?: ChannelInlineKeyboard): Promise<number>;
|
|
21
|
+
sendHtml(scopeId: string, html: string, keyboard?: ChannelInlineKeyboard): Promise<number>;
|
|
22
|
+
editPlain(scopeId: string, _messageId: number, text: string, keyboard?: ChannelInlineKeyboard): Promise<void>;
|
|
23
|
+
editHtml(scopeId: string, messageId: number, html: string, keyboard?: ChannelInlineKeyboard): Promise<void>;
|
|
24
|
+
deleteMessage(scopeId: string, messageId: number): Promise<void>;
|
|
25
|
+
sendTypingInScope(scopeId: string): Promise<void>;
|
|
26
|
+
clearInlineKeyboard(scopeId: string, messageId: number): Promise<void>;
|
|
27
|
+
sendDraft(scopeId: string, _draftId: number, text: string): Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
export {};
|