@zhin.js/adapter-weixin-ilink 0.1.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/README.md +96 -0
- package/client/LoginAssistPanel.tsx +200 -0
- package/client/WeixinIlinkManagement.tsx +139 -0
- package/client/index.tsx +11 -0
- package/client/tsconfig.json +13 -0
- package/client/utils/api.ts +30 -0
- package/dist/index.js +29 -0
- package/lib/adapter.d.ts +11 -0
- package/lib/adapter.d.ts.map +1 -0
- package/lib/adapter.js +14 -0
- package/lib/adapter.js.map +1 -0
- package/lib/aes-ecb.d.ts +7 -0
- package/lib/aes-ecb.d.ts.map +1 -0
- package/lib/aes-ecb.js +19 -0
- package/lib/aes-ecb.js.map +1 -0
- package/lib/bot.d.ts +37 -0
- package/lib/bot.d.ts.map +1 -0
- package/lib/bot.js +383 -0
- package/lib/bot.js.map +1 -0
- package/lib/cdn-upload.d.ts +18 -0
- package/lib/cdn-upload.d.ts.map +1 -0
- package/lib/cdn-upload.js +73 -0
- package/lib/cdn-upload.js.map +1 -0
- package/lib/cdn-url.d.ts +14 -0
- package/lib/cdn-url.d.ts.map +1 -0
- package/lib/cdn-url.js +14 -0
- package/lib/cdn-url.js.map +1 -0
- package/lib/context-store.d.ts +2 -0
- package/lib/context-store.d.ts.map +1 -0
- package/lib/context-store.js +2 -0
- package/lib/context-store.js.map +1 -0
- package/lib/credentials.d.ts +15 -0
- package/lib/credentials.d.ts.map +1 -0
- package/lib/credentials.js +49 -0
- package/lib/credentials.js.map +1 -0
- package/lib/ilink-api.d.ts +81 -0
- package/lib/ilink-api.d.ts.map +1 -0
- package/lib/ilink-api.js +273 -0
- package/lib/ilink-api.js.map +1 -0
- package/lib/ilink-config-cache.d.ts +19 -0
- package/lib/ilink-config-cache.d.ts.map +1 -0
- package/lib/ilink-config-cache.js +64 -0
- package/lib/ilink-config-cache.js.map +1 -0
- package/lib/ilink-logger.d.ts +10 -0
- package/lib/ilink-logger.d.ts.map +1 -0
- package/lib/ilink-logger.js +27 -0
- package/lib/ilink-logger.js.map +1 -0
- package/lib/ilink-meta.d.ts +13 -0
- package/lib/ilink-meta.d.ts.map +1 -0
- package/lib/ilink-meta.js +57 -0
- package/lib/ilink-meta.js.map +1 -0
- package/lib/ilink-random.d.ts +11 -0
- package/lib/ilink-random.d.ts.map +1 -0
- package/lib/ilink-random.js +16 -0
- package/lib/ilink-random.js.map +1 -0
- package/lib/ilink-redact.d.ts +21 -0
- package/lib/ilink-redact.d.ts.map +1 -0
- package/lib/ilink-redact.js +54 -0
- package/lib/ilink-redact.js.map +1 -0
- package/lib/ilink-session-guard.d.ts +16 -0
- package/lib/ilink-session-guard.d.ts.map +1 -0
- package/lib/ilink-session-guard.js +49 -0
- package/lib/ilink-session-guard.js.map +1 -0
- package/lib/ilink-types.d.ts +233 -0
- package/lib/ilink-types.d.ts.map +1 -0
- package/lib/ilink-types.js +35 -0
- package/lib/ilink-types.js.map +1 -0
- package/lib/index.d.ts +20 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +38 -0
- package/lib/index.js.map +1 -0
- package/lib/login-assist-routes.d.ts +7 -0
- package/lib/login-assist-routes.d.ts.map +1 -0
- package/lib/login-assist-routes.js +29 -0
- package/lib/login-assist-routes.js.map +1 -0
- package/lib/login.d.ts +6 -0
- package/lib/login.d.ts.map +1 -0
- package/lib/login.js +121 -0
- package/lib/login.js.map +1 -0
- package/lib/media-download.d.ts +19 -0
- package/lib/media-download.d.ts.map +1 -0
- package/lib/media-download.js +95 -0
- package/lib/media-download.js.map +1 -0
- package/lib/mime.d.ts +9 -0
- package/lib/mime.d.ts.map +1 -0
- package/lib/mime.js +99 -0
- package/lib/mime.js.map +1 -0
- package/lib/outbound-media.d.ts +5 -0
- package/lib/outbound-media.d.ts.map +1 -0
- package/lib/outbound-media.js +122 -0
- package/lib/outbound-media.js.map +1 -0
- package/lib/pic-decrypt.d.ts +10 -0
- package/lib/pic-decrypt.d.ts.map +1 -0
- package/lib/pic-decrypt.js +89 -0
- package/lib/pic-decrypt.js.map +1 -0
- package/lib/routes.d.ts +8 -0
- package/lib/routes.d.ts.map +1 -0
- package/lib/routes.js +29 -0
- package/lib/routes.js.map +1 -0
- package/lib/silk-transcode.d.ts +9 -0
- package/lib/silk-transcode.d.ts.map +1 -0
- package/lib/silk-transcode.js +64 -0
- package/lib/silk-transcode.js.map +1 -0
- package/lib/types.d.ts +26 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +2 -0
- package/lib/types.js.map +1 -0
- package/lib/typing-indicator.d.ts +32 -0
- package/lib/typing-indicator.d.ts.map +1 -0
- package/lib/typing-indicator.js +95 -0
- package/lib/typing-indicator.js.map +1 -0
- package/lib/upload.d.ts +43 -0
- package/lib/upload.d.ts.map +1 -0
- package/lib/upload.js +106 -0
- package/lib/upload.js.map +1 -0
- package/lib/weixin-inbound.d.ts +71 -0
- package/lib/weixin-inbound.d.ts.map +1 -0
- package/lib/weixin-inbound.js +201 -0
- package/lib/weixin-inbound.js.map +1 -0
- package/lib/weixin-send-media.d.ts +22 -0
- package/lib/weixin-send-media.d.ts.map +1 -0
- package/lib/weixin-send-media.js +54 -0
- package/lib/weixin-send-media.js.map +1 -0
- package/lib/weixin-send.d.ts +65 -0
- package/lib/weixin-send.d.ts.map +1 -0
- package/lib/weixin-send.js +181 -0
- package/lib/weixin-send.js.map +1 -0
- package/package.json +91 -0
- package/src/adapter.ts +16 -0
- package/src/aes-ecb.ts +21 -0
- package/src/bot.ts +483 -0
- package/src/cdn-upload.test.ts +177 -0
- package/src/cdn-upload.ts +87 -0
- package/src/cdn-url.ts +20 -0
- package/src/context-store.ts +8 -0
- package/src/credentials.ts +64 -0
- package/src/ilink-api.ts +349 -0
- package/src/ilink-config-cache.ts +79 -0
- package/src/ilink-logger.ts +36 -0
- package/src/ilink-meta.ts +60 -0
- package/src/ilink-random.ts +17 -0
- package/src/ilink-redact.ts +54 -0
- package/src/ilink-session-guard.ts +58 -0
- package/src/ilink-types.ts +261 -0
- package/src/index.ts +60 -0
- package/src/login-assist-routes.ts +36 -0
- package/src/login.ts +166 -0
- package/src/media-download.ts +149 -0
- package/src/mime.test.ts +74 -0
- package/src/mime.ts +105 -0
- package/src/outbound-media.ts +141 -0
- package/src/pic-decrypt.ts +101 -0
- package/src/routes.ts +39 -0
- package/src/silk-transcode.test.ts +58 -0
- package/src/silk-transcode.ts +74 -0
- package/src/silk-wasm.d.ts +6 -0
- package/src/types.ts +26 -0
- package/src/typing-indicator.ts +139 -0
- package/src/upload.test.ts +305 -0
- package/src/upload.ts +157 -0
- package/src/weixin-inbound.ts +257 -0
- package/src/weixin-send-media.ts +72 -0
- package/src/weixin-send.ts +237 -0
package/src/aes-ecb.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared AES-128-ECB crypto utilities for CDN upload and download.
|
|
3
|
+
*/
|
|
4
|
+
import { createCipheriv, createDecipheriv } from "node:crypto";
|
|
5
|
+
|
|
6
|
+
/** Encrypt buffer with AES-128-ECB (PKCS7 padding is default). */
|
|
7
|
+
export function encryptAesEcb(plaintext: Buffer, key: Buffer): Buffer {
|
|
8
|
+
const cipher = createCipheriv("aes-128-ecb", key, null);
|
|
9
|
+
return Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Decrypt buffer with AES-128-ECB (PKCS7 padding). */
|
|
13
|
+
export function decryptAesEcb(ciphertext: Buffer, key: Buffer): Buffer {
|
|
14
|
+
const decipher = createDecipheriv("aes-128-ecb", key, null);
|
|
15
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Compute AES-128-ECB ciphertext size (PKCS7 padding to 16-byte boundary). */
|
|
19
|
+
export function aesEcbPaddedSize(plaintextSize: number): number {
|
|
20
|
+
return Math.ceil((plaintextSize + 1) / 16) * 16;
|
|
21
|
+
}
|
package/src/bot.ts
ADDED
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 微信 iLink Bot:长轮询入站 + CDN 媒体收发
|
|
3
|
+
*/
|
|
4
|
+
import crypto from "node:crypto";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import {
|
|
8
|
+
Bot,
|
|
9
|
+
Message,
|
|
10
|
+
type MessageSegment,
|
|
11
|
+
type SendContent,
|
|
12
|
+
type SendOptions,
|
|
13
|
+
segment,
|
|
14
|
+
} from "zhin.js";
|
|
15
|
+
import { getUpdates, notifyStart, notifyStop, sendTyping } from "./ilink-api.js";
|
|
16
|
+
import {
|
|
17
|
+
configureIlinkMeta,
|
|
18
|
+
DEFAULT_API_BASE_URL,
|
|
19
|
+
DEFAULT_CDN_BASE_URL,
|
|
20
|
+
} from "./ilink-meta.js";
|
|
21
|
+
import {
|
|
22
|
+
loadSyncBuf,
|
|
23
|
+
resolveStateDir,
|
|
24
|
+
saveSyncBuf,
|
|
25
|
+
type WeixinIlinkCredentials,
|
|
26
|
+
} from "./credentials.js";
|
|
27
|
+
import { resolveCredentials } from "./login.js";
|
|
28
|
+
import {
|
|
29
|
+
bodyFromItemList,
|
|
30
|
+
getContextToken,
|
|
31
|
+
isMediaItem,
|
|
32
|
+
restoreContextTokens,
|
|
33
|
+
setContextToken,
|
|
34
|
+
} from "./context-store.js";
|
|
35
|
+
import { WeixinConfigManager } from "./ilink-config-cache.js";
|
|
36
|
+
import {
|
|
37
|
+
getRemainingPauseMs,
|
|
38
|
+
isSessionPaused,
|
|
39
|
+
pauseSession,
|
|
40
|
+
SESSION_EXPIRED_ERRCODE,
|
|
41
|
+
} from "./ilink-session-guard.js";
|
|
42
|
+
import { downloadMediaFromItem } from "./media-download.js";
|
|
43
|
+
import { sendMessageWeixin } from "./weixin-send.js";
|
|
44
|
+
import { sendWeixinMediaFile } from "./weixin-send-media.js";
|
|
45
|
+
import { materializeOutboundMedia, segmentMediaRef } from "./outbound-media.js";
|
|
46
|
+
import type { MessageItem, WeixinMessage } from "./ilink-types.js";
|
|
47
|
+
import { MessageItemType } from "./ilink-types.js";
|
|
48
|
+
import { getExtensionFromMime, sniffMimeFromBuffer } from "./mime.js";
|
|
49
|
+
import type { WeixinInboundMediaOpts } from "./weixin-inbound.js";
|
|
50
|
+
import type { WeixinIlinkBotConfig } from "./types.js";
|
|
51
|
+
import type { WeixinIlinkAdapter } from "./adapter.js";
|
|
52
|
+
import {
|
|
53
|
+
WeixinIlinkTypingIndicatorManager,
|
|
54
|
+
type WeixinIlinkTypingIndicatorConfig,
|
|
55
|
+
} from "./typing-indicator.js";
|
|
56
|
+
|
|
57
|
+
const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000;
|
|
58
|
+
const MAX_CONSECUTIVE_FAILURES = 3;
|
|
59
|
+
const BACKOFF_DELAY_MS = 30_000;
|
|
60
|
+
const RETRY_DELAY_MS = 2_000;
|
|
61
|
+
const WEIXIN_MEDIA_MAX_BYTES = 100 * 1024 * 1024;
|
|
62
|
+
|
|
63
|
+
export type WeixinMessageWithMedia = WeixinMessage & {
|
|
64
|
+
_media?: WeixinInboundMediaOpts;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
68
|
+
return new Promise((resolve) => {
|
|
69
|
+
if (signal?.aborted) {
|
|
70
|
+
resolve();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const timer = setTimeout(resolve, ms);
|
|
74
|
+
signal?.addEventListener(
|
|
75
|
+
"abort",
|
|
76
|
+
() => {
|
|
77
|
+
clearTimeout(timer);
|
|
78
|
+
resolve();
|
|
79
|
+
},
|
|
80
|
+
{ once: true },
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function segmentLocalPath(seg: MessageSegment): string | undefined {
|
|
86
|
+
const ref = segmentMediaRef(seg);
|
|
87
|
+
if (!ref || ref.startsWith("base64://") || /^data:/.test(ref) || /^https?:\/\//i.test(ref)) {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
return ref.replace(/^file:\/\//, "");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export class WeixinIlinkBot implements Bot<WeixinIlinkBotConfig, WeixinMessage> {
|
|
94
|
+
$connected = false;
|
|
95
|
+
$typingIndicator?: WeixinIlinkTypingIndicatorManager;
|
|
96
|
+
|
|
97
|
+
private creds: WeixinIlinkCredentials | null = null;
|
|
98
|
+
private pollAbort?: AbortController;
|
|
99
|
+
private pollPromise?: Promise<void>;
|
|
100
|
+
private configManager?: WeixinConfigManager;
|
|
101
|
+
|
|
102
|
+
get $id(): string {
|
|
103
|
+
return this.$config.name;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
get hasCredentials(): boolean {
|
|
107
|
+
return Boolean(this.creds?.botToken);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
get pluginLogger() {
|
|
111
|
+
return this.adapter.plugin.logger;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
constructor(
|
|
115
|
+
public adapter: WeixinIlinkAdapter,
|
|
116
|
+
public $config: WeixinIlinkBotConfig,
|
|
117
|
+
) {}
|
|
118
|
+
|
|
119
|
+
apiBaseUrl(): string {
|
|
120
|
+
return this.$config.baseUrl ?? this.creds?.baseUrl ?? DEFAULT_API_BASE_URL;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
cdnBaseUrl(): string {
|
|
124
|
+
return this.$config.cdnBaseUrl ?? DEFAULT_CDN_BASE_URL;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async $connect(): Promise<void> {
|
|
128
|
+
configureIlinkMeta({ botAgent: this.$config.botAgent });
|
|
129
|
+
this.creds = await resolveCredentials(this.adapter.plugin, this.$config);
|
|
130
|
+
restoreContextTokens(this.$id);
|
|
131
|
+
|
|
132
|
+
await notifyStart({
|
|
133
|
+
baseUrl: this.apiBaseUrl(),
|
|
134
|
+
token: this.creds.botToken,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
this.configManager = new WeixinConfigManager(
|
|
138
|
+
{ baseUrl: this.apiBaseUrl(), token: this.creds.botToken },
|
|
139
|
+
(msg) => this.pluginLogger.debug(msg),
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
if (this.$config.typingIndicator?.enabled !== false) {
|
|
143
|
+
this.$typingIndicator = new WeixinIlinkTypingIndicatorManager(
|
|
144
|
+
this,
|
|
145
|
+
this.$config.typingIndicator as WeixinIlinkTypingIndicatorConfig | undefined,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
this.pollAbort = new AbortController();
|
|
150
|
+
this.pollPromise = this.pollLoop(this.pollAbort.signal);
|
|
151
|
+
this.$connected = true;
|
|
152
|
+
this.pluginLogger.info(`weixin-ilink bot ${this.$id} connected`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async $disconnect(): Promise<void> {
|
|
156
|
+
this.pollAbort?.abort();
|
|
157
|
+
try {
|
|
158
|
+
await this.pollPromise;
|
|
159
|
+
} catch {
|
|
160
|
+
/* poll loop exit */
|
|
161
|
+
}
|
|
162
|
+
if (this.creds?.botToken) {
|
|
163
|
+
try {
|
|
164
|
+
await notifyStop({ baseUrl: this.apiBaseUrl(), token: this.creds.botToken });
|
|
165
|
+
} catch (err) {
|
|
166
|
+
this.pluginLogger.warn(`notifyStop failed: ${String(err)}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
this.$connected = false;
|
|
170
|
+
this.pluginLogger.info(`weixin-ilink bot ${this.$id} disconnected`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async sendTypingToUser(userId: string, status: number): Promise<void> {
|
|
174
|
+
if (!this.creds?.botToken || !this.configManager) return;
|
|
175
|
+
const contextToken = getContextToken(this.$id, userId);
|
|
176
|
+
const cfg = await this.configManager.getForUser(userId, contextToken);
|
|
177
|
+
if (!cfg.typingTicket) {
|
|
178
|
+
this.pluginLogger.debug(`sendTyping: no typing_ticket for user=${userId}`);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
await sendTyping({
|
|
182
|
+
baseUrl: this.apiBaseUrl(),
|
|
183
|
+
token: this.creds.botToken,
|
|
184
|
+
body: {
|
|
185
|
+
ilink_user_id: userId,
|
|
186
|
+
typing_ticket: cfg.typingTicket,
|
|
187
|
+
status,
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async $recallMessage(_id: string): Promise<void> {
|
|
193
|
+
this.pluginLogger.debug("weixin-ilink does not support message recall");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
$formatMessage(msg: WeixinMessageWithMedia): Message<WeixinMessage> {
|
|
197
|
+
const userId = msg.from_user_id ?? "";
|
|
198
|
+
const content = this.buildInboundContent(msg);
|
|
199
|
+
const quoteId = Message.quoteIdFromContent(content);
|
|
200
|
+
Message.alignReplySegments(content, quoteId);
|
|
201
|
+
const msgId = String(msg.message_id ?? msg.client_id ?? msg.seq ?? Date.now());
|
|
202
|
+
|
|
203
|
+
return Message.from(msg, {
|
|
204
|
+
$id: msgId,
|
|
205
|
+
$adapter: "weixin-ilink",
|
|
206
|
+
$bot: this.$config.name,
|
|
207
|
+
$sender: {
|
|
208
|
+
id: userId,
|
|
209
|
+
name: userId,
|
|
210
|
+
},
|
|
211
|
+
$channel: {
|
|
212
|
+
id: userId,
|
|
213
|
+
type: "private",
|
|
214
|
+
},
|
|
215
|
+
$content: content,
|
|
216
|
+
$quote_id: quoteId,
|
|
217
|
+
$raw: bodyFromItemList(msg.item_list),
|
|
218
|
+
$timestamp: msg.create_time_ms ?? Date.now(),
|
|
219
|
+
$recall: async () => this.$recallMessage(msgId),
|
|
220
|
+
$reply: async (replyContent: SendContent, _quote?: boolean | string) => {
|
|
221
|
+
if (!Array.isArray(replyContent)) replyContent = [replyContent];
|
|
222
|
+
return this.adapter.sendMessage({
|
|
223
|
+
context: "weixin-ilink",
|
|
224
|
+
bot: this.$config.name,
|
|
225
|
+
id: userId,
|
|
226
|
+
type: "private",
|
|
227
|
+
content: replyContent,
|
|
228
|
+
});
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private buildInboundContent(msg: WeixinMessageWithMedia): MessageSegment[] {
|
|
234
|
+
const segments: MessageSegment[] = [];
|
|
235
|
+
const text = bodyFromItemList(msg.item_list);
|
|
236
|
+
if (text) segments.push(segment.text(text));
|
|
237
|
+
|
|
238
|
+
const media = msg._media;
|
|
239
|
+
if (media?.decryptedPicPath) {
|
|
240
|
+
segments.push(segment("image", { file: media.decryptedPicPath }));
|
|
241
|
+
} else if (media?.decryptedVideoPath) {
|
|
242
|
+
segments.push(segment("video", { file: media.decryptedVideoPath }));
|
|
243
|
+
} else if (media?.decryptedFilePath) {
|
|
244
|
+
segments.push(segment("file", {
|
|
245
|
+
file: media.decryptedFilePath,
|
|
246
|
+
name: path.basename(media.decryptedFilePath),
|
|
247
|
+
}));
|
|
248
|
+
} else if (media?.decryptedVoicePath) {
|
|
249
|
+
segments.push(segment("record", { file: media.decryptedVoicePath }));
|
|
250
|
+
} else if (!text && msg.item_list?.some((i) => isMediaItem(i))) {
|
|
251
|
+
segments.push(segment.text("[媒体消息]"));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return segments.length ? segments : [segment.text("")];
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async $sendMessage(options: SendOptions): Promise<string> {
|
|
258
|
+
if (!this.creds?.botToken) {
|
|
259
|
+
throw new Error("weixin-ilink bot not authenticated");
|
|
260
|
+
}
|
|
261
|
+
const peerId = options.id;
|
|
262
|
+
const contextToken = getContextToken(this.$id, peerId);
|
|
263
|
+
if (!contextToken) {
|
|
264
|
+
this.pluginLogger.warn(`missing context_token for peer ${peerId}, refusing send`);
|
|
265
|
+
throw new Error(`missing context_token for peer ${peerId}`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const apiOpts = {
|
|
269
|
+
baseUrl: this.apiBaseUrl(),
|
|
270
|
+
token: this.creds.botToken,
|
|
271
|
+
contextToken,
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const outboundDir = path.join(resolveStateDir(), "media", "outbound");
|
|
275
|
+
const materialized = await materializeOutboundMedia(options.content, outboundDir);
|
|
276
|
+
const segments = Array.isArray(materialized) ? materialized : [materialized];
|
|
277
|
+
let lastId = "";
|
|
278
|
+
let pendingText = "";
|
|
279
|
+
|
|
280
|
+
for (const raw of segments) {
|
|
281
|
+
if (typeof raw === "string") {
|
|
282
|
+
pendingText += raw;
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (typeof raw !== "object" || raw === null || !("type" in raw)) {
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
const seg = raw as MessageSegment;
|
|
289
|
+
if (seg.type === "text" || seg.type === "markdown") {
|
|
290
|
+
const t = (seg.data as { text?: string }).text ?? "";
|
|
291
|
+
pendingText += t;
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const filePath = segmentLocalPath(seg);
|
|
296
|
+
if (filePath && fs.existsSync(filePath)) {
|
|
297
|
+
// 微信限制:单条消息不能图文混排 → 先单独发文本,再发纯媒体(不带 caption)
|
|
298
|
+
if (pendingText.trim()) {
|
|
299
|
+
const textResult = await sendMessageWeixin({
|
|
300
|
+
to: peerId,
|
|
301
|
+
text: pendingText,
|
|
302
|
+
opts: apiOpts,
|
|
303
|
+
});
|
|
304
|
+
pendingText = "";
|
|
305
|
+
lastId = textResult.messageId;
|
|
306
|
+
}
|
|
307
|
+
const result = await sendWeixinMediaFile({
|
|
308
|
+
filePath,
|
|
309
|
+
to: peerId,
|
|
310
|
+
text: "",
|
|
311
|
+
opts: apiOpts,
|
|
312
|
+
cdnBaseUrl: this.cdnBaseUrl(),
|
|
313
|
+
});
|
|
314
|
+
lastId = result.messageId;
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (seg.type === "image" || seg.type === "video" || seg.type === "file" || seg.type === "record") {
|
|
319
|
+
this.pluginLogger.warn(`skip outbound ${seg.type}: no local file path`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (pendingText.trim()) {
|
|
324
|
+
const result = await sendMessageWeixin({
|
|
325
|
+
to: peerId,
|
|
326
|
+
text: pendingText,
|
|
327
|
+
opts: apiOpts,
|
|
328
|
+
});
|
|
329
|
+
lastId = result.messageId;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return lastId || `weixin-ilink-${Date.now()}`;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
private async pollLoop(abortSignal: AbortSignal): Promise<void> {
|
|
336
|
+
if (!this.creds?.botToken) return;
|
|
337
|
+
|
|
338
|
+
let getUpdatesBuf = loadSyncBuf(this.$id);
|
|
339
|
+
let nextTimeoutMs = this.$config.longPollTimeoutMs ?? DEFAULT_LONG_POLL_TIMEOUT_MS;
|
|
340
|
+
let consecutiveFailures = 0;
|
|
341
|
+
|
|
342
|
+
while (!abortSignal.aborted) {
|
|
343
|
+
if (isSessionPaused(this.$id)) {
|
|
344
|
+
await sleep(getRemainingPauseMs(this.$id), abortSignal);
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
try {
|
|
349
|
+
const resp = await getUpdates({
|
|
350
|
+
baseUrl: this.apiBaseUrl(),
|
|
351
|
+
token: this.creds.botToken,
|
|
352
|
+
get_updates_buf: getUpdatesBuf,
|
|
353
|
+
timeoutMs: nextTimeoutMs,
|
|
354
|
+
abortSignal,
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
if (resp.longpolling_timeout_ms != null && resp.longpolling_timeout_ms > 0) {
|
|
358
|
+
nextTimeoutMs = resp.longpolling_timeout_ms;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const isApiError =
|
|
362
|
+
(resp.ret !== undefined && resp.ret !== 0) ||
|
|
363
|
+
(resp.errcode !== undefined && resp.errcode !== 0);
|
|
364
|
+
|
|
365
|
+
if (isApiError) {
|
|
366
|
+
const sessionExpired =
|
|
367
|
+
resp.errcode === SESSION_EXPIRED_ERRCODE || resp.ret === SESSION_EXPIRED_ERRCODE;
|
|
368
|
+
if (sessionExpired) {
|
|
369
|
+
pauseSession(this.$id);
|
|
370
|
+
consecutiveFailures = 0;
|
|
371
|
+
await sleep(getRemainingPauseMs(this.$id), abortSignal);
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
consecutiveFailures += 1;
|
|
375
|
+
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
|
376
|
+
consecutiveFailures = 0;
|
|
377
|
+
await sleep(BACKOFF_DELAY_MS, abortSignal);
|
|
378
|
+
} else {
|
|
379
|
+
await sleep(RETRY_DELAY_MS, abortSignal);
|
|
380
|
+
}
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
consecutiveFailures = 0;
|
|
385
|
+
if (resp.get_updates_buf) {
|
|
386
|
+
getUpdatesBuf = resp.get_updates_buf;
|
|
387
|
+
saveSyncBuf(this.$id, getUpdatesBuf);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
for (const inbound of resp.msgs ?? []) {
|
|
391
|
+
await this.handleInboundMessage(inbound);
|
|
392
|
+
}
|
|
393
|
+
} catch (err) {
|
|
394
|
+
if (abortSignal.aborted) return;
|
|
395
|
+
consecutiveFailures += 1;
|
|
396
|
+
this.pluginLogger.error(`weixin-ilink poll error: ${String(err)}`);
|
|
397
|
+
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
|
398
|
+
consecutiveFailures = 0;
|
|
399
|
+
await sleep(BACKOFF_DELAY_MS, abortSignal);
|
|
400
|
+
} else {
|
|
401
|
+
await sleep(RETRY_DELAY_MS, abortSignal);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
private async handleInboundMessage(full: WeixinMessage): Promise<void> {
|
|
408
|
+
const fromUserId = full.from_user_id ?? "";
|
|
409
|
+
if (full.context_token && fromUserId) {
|
|
410
|
+
setContextToken(this.$id, fromUserId, full.context_token);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const mediaOpts = await this.downloadInboundMedia(full);
|
|
414
|
+
// 入站保持单条 message.receive(文字+媒体同条),供 AI context / im_transcripts / agent_messages 组装。
|
|
415
|
+
// 出站才按微信限制拆分图文(见 sendMessage)。
|
|
416
|
+
const message = this.$formatMessage({ ...full, _media: mediaOpts });
|
|
417
|
+
this.adapter.emit("message.receive", message);
|
|
418
|
+
this.pluginLogger.debug(
|
|
419
|
+
`${this.$id} recv private(${message.$channel.id}): ${segment.raw(message.$content)}`,
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
private async downloadInboundMedia(full: WeixinMessage): Promise<WeixinInboundMediaOpts> {
|
|
424
|
+
const hasDownloadableMedia = (m?: { encrypt_query_param?: string; full_url?: string }) =>
|
|
425
|
+
Boolean(m?.encrypt_query_param || m?.full_url);
|
|
426
|
+
|
|
427
|
+
const mediaItem =
|
|
428
|
+
full.item_list?.find(
|
|
429
|
+
(i) => i.type === MessageItemType.IMAGE && hasDownloadableMedia(i.image_item?.media),
|
|
430
|
+
) ??
|
|
431
|
+
full.item_list?.find(
|
|
432
|
+
(i) => i.type === MessageItemType.VIDEO && hasDownloadableMedia(i.video_item?.media),
|
|
433
|
+
) ??
|
|
434
|
+
full.item_list?.find(
|
|
435
|
+
(i) => i.type === MessageItemType.FILE && hasDownloadableMedia(i.file_item?.media),
|
|
436
|
+
) ??
|
|
437
|
+
full.item_list?.find(
|
|
438
|
+
(i) => i.type === MessageItemType.VOICE && hasDownloadableMedia(i.voice_item?.media),
|
|
439
|
+
);
|
|
440
|
+
|
|
441
|
+
if (!mediaItem) return {};
|
|
442
|
+
|
|
443
|
+
return downloadMediaFromItem(mediaItem, {
|
|
444
|
+
cdnBaseUrl: this.cdnBaseUrl(),
|
|
445
|
+
saveMedia: (buffer, contentType, subdir, maxBytes, originalFilename) =>
|
|
446
|
+
this.saveInboundMedia(buffer, contentType, subdir, maxBytes, originalFilename),
|
|
447
|
+
log: (msg) => this.pluginLogger.debug(msg),
|
|
448
|
+
errLog: (msg) => this.pluginLogger.warn(msg),
|
|
449
|
+
label: `inbound:${fromUserIdLabel(full)}`,
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
private async saveInboundMedia(
|
|
454
|
+
buffer: Buffer,
|
|
455
|
+
contentType?: string,
|
|
456
|
+
subdir = "inbound",
|
|
457
|
+
maxBytes = WEIXIN_MEDIA_MAX_BYTES,
|
|
458
|
+
originalFilename?: string,
|
|
459
|
+
): Promise<{ path: string }> {
|
|
460
|
+
if (buffer.length > maxBytes) {
|
|
461
|
+
throw new Error(`media exceeds max size ${maxBytes}`);
|
|
462
|
+
}
|
|
463
|
+
const dir = path.join(resolveStateDir(), "media", subdir);
|
|
464
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
465
|
+
const resolvedMime = contentType ?? sniffMimeFromBuffer(buffer);
|
|
466
|
+
const ext = originalFilename
|
|
467
|
+
? path.extname(originalFilename)
|
|
468
|
+
: resolvedMime
|
|
469
|
+
? getExtensionFromMime(resolvedMime)
|
|
470
|
+
: ".bin";
|
|
471
|
+
const base = originalFilename
|
|
472
|
+
? path.basename(originalFilename, ext)
|
|
473
|
+
: "media";
|
|
474
|
+
const fileName = `${base}-${Date.now()}-${crypto.randomBytes(4).toString("hex")}${ext}`;
|
|
475
|
+
const filePath = path.join(dir, fileName);
|
|
476
|
+
fs.writeFileSync(filePath, buffer);
|
|
477
|
+
return { path: filePath };
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function fromUserIdLabel(msg: WeixinMessage): string {
|
|
482
|
+
return msg.from_user_id ?? "unknown";
|
|
483
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
vi.mock("../util/logger.js", () => ({
|
|
4
|
+
logger: {
|
|
5
|
+
info: vi.fn(),
|
|
6
|
+
debug: vi.fn(),
|
|
7
|
+
warn: vi.fn(),
|
|
8
|
+
error: vi.fn(),
|
|
9
|
+
},
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
const { mockFetch } = vi.hoisted(() => ({
|
|
13
|
+
mockFetch: vi.fn(),
|
|
14
|
+
}));
|
|
15
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
16
|
+
|
|
17
|
+
import { encryptAesEcb, aesEcbPaddedSize } from "./aes-ecb.js";
|
|
18
|
+
import { uploadBufferToCdn } from "./cdn-upload.js";
|
|
19
|
+
import crypto from "node:crypto";
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
vi.clearAllMocks();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("aesEcbPaddedSize", () => {
|
|
26
|
+
it("pads to 16-byte boundary", () => {
|
|
27
|
+
expect(aesEcbPaddedSize(0)).toBe(16);
|
|
28
|
+
expect(aesEcbPaddedSize(1)).toBe(16);
|
|
29
|
+
expect(aesEcbPaddedSize(15)).toBe(16);
|
|
30
|
+
expect(aesEcbPaddedSize(16)).toBe(32);
|
|
31
|
+
expect(aesEcbPaddedSize(31)).toBe(32);
|
|
32
|
+
expect(aesEcbPaddedSize(32)).toBe(48);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("encryptAesEcb", () => {
|
|
37
|
+
it("encrypts and produces correct ciphertext length", () => {
|
|
38
|
+
const key = crypto.randomBytes(16);
|
|
39
|
+
const plaintext = Buffer.from("hello world 1234");
|
|
40
|
+
const ct = encryptAesEcb(plaintext, key);
|
|
41
|
+
// 16 bytes plaintext + PKCS7 padding = 32 bytes ciphertext
|
|
42
|
+
expect(ct.length).toBe(32);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("uploadBufferToCdn", () => {
|
|
47
|
+
const aeskey = crypto.randomBytes(16);
|
|
48
|
+
|
|
49
|
+
it("uploads successfully and returns downloadParam", async () => {
|
|
50
|
+
mockFetch.mockResolvedValueOnce({
|
|
51
|
+
ok: true,
|
|
52
|
+
status: 200,
|
|
53
|
+
headers: new Headers({ "x-encrypted-param": "dl-param" }),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const result = await uploadBufferToCdn({
|
|
57
|
+
buf: Buffer.from("data"),
|
|
58
|
+
uploadParam: "up",
|
|
59
|
+
filekey: "fk",
|
|
60
|
+
cdnBaseUrl: "https://cdn.com",
|
|
61
|
+
label: "test",
|
|
62
|
+
aeskey,
|
|
63
|
+
});
|
|
64
|
+
expect(result.downloadParam).toBe("dl-param");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("POSTs to uploadFullUrl when provided", async () => {
|
|
68
|
+
mockFetch.mockResolvedValueOnce({
|
|
69
|
+
ok: true,
|
|
70
|
+
status: 200,
|
|
71
|
+
headers: new Headers({ "x-encrypted-param": "dl-full" }),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const fullUrl = "http://host/c2c/upload?q=1";
|
|
75
|
+
const result = await uploadBufferToCdn({
|
|
76
|
+
buf: Buffer.from("data"),
|
|
77
|
+
uploadFullUrl: fullUrl,
|
|
78
|
+
filekey: "fk",
|
|
79
|
+
cdnBaseUrl: "https://unused.example",
|
|
80
|
+
label: "test",
|
|
81
|
+
aeskey,
|
|
82
|
+
});
|
|
83
|
+
expect(result.downloadParam).toBe("dl-full");
|
|
84
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
85
|
+
fullUrl,
|
|
86
|
+
expect.objectContaining({ method: "POST" }),
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("retries on server error then succeeds", async () => {
|
|
91
|
+
mockFetch
|
|
92
|
+
.mockResolvedValueOnce({
|
|
93
|
+
ok: false,
|
|
94
|
+
status: 500,
|
|
95
|
+
headers: new Headers({ "x-error-message": "busy" }),
|
|
96
|
+
text: () => Promise.resolve("busy"),
|
|
97
|
+
})
|
|
98
|
+
.mockResolvedValueOnce({
|
|
99
|
+
ok: true,
|
|
100
|
+
status: 200,
|
|
101
|
+
headers: new Headers({ "x-encrypted-param": "dl" }),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const result = await uploadBufferToCdn({
|
|
105
|
+
buf: Buffer.from("data"),
|
|
106
|
+
uploadParam: "up",
|
|
107
|
+
filekey: "fk",
|
|
108
|
+
cdnBaseUrl: "https://cdn.com",
|
|
109
|
+
label: "test",
|
|
110
|
+
aeskey,
|
|
111
|
+
});
|
|
112
|
+
expect(result.downloadParam).toBe("dl");
|
|
113
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("throws immediately on 4xx client error", async () => {
|
|
117
|
+
mockFetch.mockResolvedValueOnce({
|
|
118
|
+
ok: false,
|
|
119
|
+
status: 403,
|
|
120
|
+
headers: new Headers({}),
|
|
121
|
+
text: () => Promise.resolve("forbidden"),
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
await expect(
|
|
125
|
+
uploadBufferToCdn({
|
|
126
|
+
buf: Buffer.from("data"),
|
|
127
|
+
uploadParam: "up",
|
|
128
|
+
filekey: "fk",
|
|
129
|
+
cdnBaseUrl: "https://cdn.com",
|
|
130
|
+
label: "test",
|
|
131
|
+
aeskey,
|
|
132
|
+
}),
|
|
133
|
+
).rejects.toThrow("client error");
|
|
134
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("throws when x-encrypted-param header is missing after all retries", async () => {
|
|
138
|
+
for (let i = 0; i < 3; i++) {
|
|
139
|
+
mockFetch.mockResolvedValueOnce({
|
|
140
|
+
ok: true,
|
|
141
|
+
status: 200,
|
|
142
|
+
headers: new Headers({}),
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
await expect(
|
|
147
|
+
uploadBufferToCdn({
|
|
148
|
+
buf: Buffer.from("data"),
|
|
149
|
+
uploadParam: "up",
|
|
150
|
+
filekey: "fk",
|
|
151
|
+
cdnBaseUrl: "https://cdn.com",
|
|
152
|
+
label: "test",
|
|
153
|
+
aeskey,
|
|
154
|
+
}),
|
|
155
|
+
).rejects.toThrow("x-encrypted-param");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("uses x-error-message header for 4xx when available", async () => {
|
|
159
|
+
mockFetch.mockResolvedValueOnce({
|
|
160
|
+
ok: false,
|
|
161
|
+
status: 400,
|
|
162
|
+
headers: new Headers({ "x-error-message": "bad request detail" }),
|
|
163
|
+
text: () => Promise.resolve("fallback text"),
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
await expect(
|
|
167
|
+
uploadBufferToCdn({
|
|
168
|
+
buf: Buffer.from("data"),
|
|
169
|
+
uploadParam: "up",
|
|
170
|
+
filekey: "fk",
|
|
171
|
+
cdnBaseUrl: "https://cdn.com",
|
|
172
|
+
label: "test",
|
|
173
|
+
aeskey,
|
|
174
|
+
}),
|
|
175
|
+
).rejects.toThrow("bad request detail");
|
|
176
|
+
});
|
|
177
|
+
});
|