@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
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { logger } from "./ilink-logger.js";
|
|
5
|
+
import { generateId } from "./ilink-random.js";
|
|
6
|
+
import type { WeixinMessage, MessageItem } from "./ilink-types.js";
|
|
7
|
+
import { MessageItemType } from "./ilink-types.js";
|
|
8
|
+
import { resolveStateDir } from "./credentials.js";
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Context token store (in-process cache + disk persistence)
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* contextToken is issued per-message by the Weixin getupdates API and must
|
|
16
|
+
* be echoed verbatim in every outbound send. The in-memory map is the primary
|
|
17
|
+
* lookup; a disk-backed file per account ensures tokens survive gateway restarts.
|
|
18
|
+
*/
|
|
19
|
+
const contextTokenStore = new Map<string, string>();
|
|
20
|
+
|
|
21
|
+
function contextTokenKey(accountId: string, userId: string): string {
|
|
22
|
+
return `${accountId}:${userId}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Disk persistence helpers
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
function resolveContextTokenFilePath(accountId: string): string {
|
|
30
|
+
return path.join(resolveStateDir(), "context-tokens", `${accountId}.context-tokens.json`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Persist all context tokens for a given account to disk. */
|
|
34
|
+
function persistContextTokens(accountId: string): void {
|
|
35
|
+
const prefix = `${accountId}:`;
|
|
36
|
+
const tokens: Record<string, string> = {};
|
|
37
|
+
for (const [k, v] of contextTokenStore) {
|
|
38
|
+
if (k.startsWith(prefix)) {
|
|
39
|
+
tokens[k.slice(prefix.length)] = v;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const filePath = resolveContextTokenFilePath(accountId);
|
|
43
|
+
try {
|
|
44
|
+
const dir = path.dirname(filePath);
|
|
45
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
46
|
+
fs.writeFileSync(filePath, JSON.stringify(tokens, null, 0), "utf-8");
|
|
47
|
+
} catch (err) {
|
|
48
|
+
logger.warn(`persistContextTokens: failed to write ${filePath}: ${String(err)}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Restore persisted context tokens for an account into the in-memory map.
|
|
54
|
+
* Called once during gateway startAccount to survive restarts.
|
|
55
|
+
*/
|
|
56
|
+
export function restoreContextTokens(accountId: string): void {
|
|
57
|
+
const filePath = resolveContextTokenFilePath(accountId);
|
|
58
|
+
try {
|
|
59
|
+
if (!fs.existsSync(filePath)) return;
|
|
60
|
+
const raw = fs.readFileSync(filePath, "utf-8");
|
|
61
|
+
const tokens = JSON.parse(raw) as Record<string, string>;
|
|
62
|
+
let count = 0;
|
|
63
|
+
for (const [userId, token] of Object.entries(tokens)) {
|
|
64
|
+
if (typeof token === "string" && token) {
|
|
65
|
+
contextTokenStore.set(contextTokenKey(accountId, userId), token);
|
|
66
|
+
count++;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
logger.info(`restoreContextTokens: restored ${count} tokens for account=${accountId}`);
|
|
70
|
+
} catch (err) {
|
|
71
|
+
logger.warn(`restoreContextTokens: failed to read ${filePath}: ${String(err)}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Remove all context tokens for a given account (memory + disk). */
|
|
76
|
+
export function clearContextTokensForAccount(accountId: string): void {
|
|
77
|
+
const prefix = `${accountId}:`;
|
|
78
|
+
for (const k of [...contextTokenStore.keys()]) {
|
|
79
|
+
if (k.startsWith(prefix)) {
|
|
80
|
+
contextTokenStore.delete(k);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const filePath = resolveContextTokenFilePath(accountId);
|
|
84
|
+
try {
|
|
85
|
+
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
|
86
|
+
} catch (err) {
|
|
87
|
+
logger.warn(`clearContextTokensForAccount: failed to remove ${filePath}: ${String(err)}`);
|
|
88
|
+
}
|
|
89
|
+
logger.info(`clearContextTokensForAccount: cleared tokens for account=${accountId}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Store a context token for a given account+user pair (memory + disk). */
|
|
93
|
+
export function setContextToken(accountId: string, userId: string, token: string): void {
|
|
94
|
+
const k = contextTokenKey(accountId, userId);
|
|
95
|
+
logger.debug(`setContextToken: key=${k}`);
|
|
96
|
+
contextTokenStore.set(k, token);
|
|
97
|
+
persistContextTokens(accountId);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Retrieve the cached context token for a given account+user pair. */
|
|
101
|
+
export function getContextToken(accountId: string, userId: string): string | undefined {
|
|
102
|
+
const k = contextTokenKey(accountId, userId);
|
|
103
|
+
const val = contextTokenStore.get(k);
|
|
104
|
+
logger.debug(
|
|
105
|
+
`getContextToken: key=${k} found=${val !== undefined} storeSize=${contextTokenStore.size}`,
|
|
106
|
+
);
|
|
107
|
+
return val;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Find all accountIds that have an active contextToken for the given userId.
|
|
112
|
+
* Used to infer the sending bot account from the recipient address when
|
|
113
|
+
* accountId is not explicitly provided (e.g. cron delivery).
|
|
114
|
+
*
|
|
115
|
+
* Returns all matching accountIds (not just the first) so the caller can
|
|
116
|
+
* detect ambiguity when multiple accounts have sessions with the same user.
|
|
117
|
+
*/
|
|
118
|
+
export function findAccountIdsByContextToken(
|
|
119
|
+
accountIds: string[],
|
|
120
|
+
userId: string,
|
|
121
|
+
): string[] {
|
|
122
|
+
return accountIds.filter((id) => contextTokenStore.has(contextTokenKey(id, userId)));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// Message ID generation
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
function generateMessageSid(): string {
|
|
130
|
+
return generateId("openclaw-weixin");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Inbound context passed to the OpenClaw core pipeline (matches MsgContext shape). */
|
|
134
|
+
export type WeixinMsgContext = {
|
|
135
|
+
Body: string;
|
|
136
|
+
From: string;
|
|
137
|
+
To: string;
|
|
138
|
+
AccountId: string;
|
|
139
|
+
OriginatingChannel: "openclaw-weixin";
|
|
140
|
+
OriginatingTo: string;
|
|
141
|
+
MessageSid: string;
|
|
142
|
+
Timestamp?: number;
|
|
143
|
+
Provider: "openclaw-weixin";
|
|
144
|
+
ChatType: "direct";
|
|
145
|
+
/** Set by monitor after resolveAgentRoute so dispatchReplyFromConfig uses the correct session. */
|
|
146
|
+
SessionKey?: string;
|
|
147
|
+
context_token?: string;
|
|
148
|
+
MediaUrl?: string;
|
|
149
|
+
MediaPath?: string;
|
|
150
|
+
MediaType?: string;
|
|
151
|
+
/** Raw message body for framework command authorization. */
|
|
152
|
+
CommandBody?: string;
|
|
153
|
+
/** Whether the sender is authorized to execute slash commands. */
|
|
154
|
+
CommandAuthorized?: boolean;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/** Returns true if the message item is a media type (image, video, file, or voice). */
|
|
158
|
+
export function isMediaItem(item: MessageItem): boolean {
|
|
159
|
+
return (
|
|
160
|
+
item.type === MessageItemType.IMAGE ||
|
|
161
|
+
item.type === MessageItemType.VIDEO ||
|
|
162
|
+
item.type === MessageItemType.FILE ||
|
|
163
|
+
item.type === MessageItemType.VOICE
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function bodyFromItemList(itemList?: MessageItem[]): string {
|
|
168
|
+
if (!itemList?.length) return "";
|
|
169
|
+
for (const item of itemList) {
|
|
170
|
+
if (item.type === MessageItemType.TEXT && item.text_item?.text != null) {
|
|
171
|
+
const text = String(item.text_item.text);
|
|
172
|
+
const ref = item.ref_msg;
|
|
173
|
+
if (!ref) return text;
|
|
174
|
+
// Quoted media is passed as MediaPath; only include the current text as body.
|
|
175
|
+
if (ref.message_item && isMediaItem(ref.message_item)) return text;
|
|
176
|
+
// Build quoted context from both title and message_item content.
|
|
177
|
+
const parts: string[] = [];
|
|
178
|
+
if (ref.title) parts.push(ref.title);
|
|
179
|
+
if (ref.message_item) {
|
|
180
|
+
const refBody = bodyFromItemList([ref.message_item]);
|
|
181
|
+
if (refBody) parts.push(refBody);
|
|
182
|
+
}
|
|
183
|
+
if (!parts.length) return text;
|
|
184
|
+
return `[引用: ${parts.join(" | ")}]\n${text}`;
|
|
185
|
+
}
|
|
186
|
+
// 语音转文字:如果语音消息有 text 字段,直接使用文字内容
|
|
187
|
+
if (item.type === MessageItemType.VOICE && item.voice_item?.text) {
|
|
188
|
+
return item.voice_item.text;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return "";
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export type WeixinInboundMediaOpts = {
|
|
195
|
+
/** Local path to decrypted image file. */
|
|
196
|
+
decryptedPicPath?: string;
|
|
197
|
+
/** Local path to transcoded/raw voice file (.wav or .silk). */
|
|
198
|
+
decryptedVoicePath?: string;
|
|
199
|
+
/** MIME type for the voice file (e.g. "audio/wav" or "audio/silk"). */
|
|
200
|
+
voiceMediaType?: string;
|
|
201
|
+
/** Local path to decrypted file attachment. */
|
|
202
|
+
decryptedFilePath?: string;
|
|
203
|
+
/** MIME type for the file attachment (guessed from file_name). */
|
|
204
|
+
fileMediaType?: string;
|
|
205
|
+
/** Local path to decrypted video file. */
|
|
206
|
+
decryptedVideoPath?: string;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Convert a WeixinMessage from getUpdates to the inbound MsgContext for the core pipeline.
|
|
211
|
+
* Media: only pass MediaPath (local file, after CDN download + decrypt).
|
|
212
|
+
* We never pass MediaUrl — the upstream CDN URL is encrypted/auth-only.
|
|
213
|
+
* Priority when multiple media types present: image > video > file > voice.
|
|
214
|
+
*/
|
|
215
|
+
export function weixinMessageToMsgContext(
|
|
216
|
+
msg: WeixinMessage,
|
|
217
|
+
accountId: string,
|
|
218
|
+
opts?: WeixinInboundMediaOpts,
|
|
219
|
+
): WeixinMsgContext {
|
|
220
|
+
const from_user_id = msg.from_user_id ?? "";
|
|
221
|
+
const ctx: WeixinMsgContext = {
|
|
222
|
+
Body: bodyFromItemList(msg.item_list),
|
|
223
|
+
From: from_user_id,
|
|
224
|
+
To: from_user_id,
|
|
225
|
+
AccountId: accountId,
|
|
226
|
+
OriginatingChannel: "openclaw-weixin",
|
|
227
|
+
OriginatingTo: from_user_id,
|
|
228
|
+
MessageSid: generateMessageSid(),
|
|
229
|
+
Timestamp: msg.create_time_ms,
|
|
230
|
+
Provider: "openclaw-weixin",
|
|
231
|
+
ChatType: "direct",
|
|
232
|
+
};
|
|
233
|
+
if (msg.context_token) {
|
|
234
|
+
ctx.context_token = msg.context_token;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (opts?.decryptedPicPath) {
|
|
238
|
+
ctx.MediaPath = opts.decryptedPicPath;
|
|
239
|
+
ctx.MediaType = "image/*";
|
|
240
|
+
} else if (opts?.decryptedVideoPath) {
|
|
241
|
+
ctx.MediaPath = opts.decryptedVideoPath;
|
|
242
|
+
ctx.MediaType = "video/mp4";
|
|
243
|
+
} else if (opts?.decryptedFilePath) {
|
|
244
|
+
ctx.MediaPath = opts.decryptedFilePath;
|
|
245
|
+
ctx.MediaType = opts.fileMediaType ?? "application/octet-stream";
|
|
246
|
+
} else if (opts?.decryptedVoicePath) {
|
|
247
|
+
ctx.MediaPath = opts.decryptedVoicePath;
|
|
248
|
+
ctx.MediaType = opts.voiceMediaType ?? "audio/wav";
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return ctx;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Extract the context_token from an inbound WeixinMsgContext. */
|
|
255
|
+
export function getContextTokenFromMsgContext(ctx: WeixinMsgContext): string | undefined {
|
|
256
|
+
return ctx.context_token;
|
|
257
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import type { WeixinApiOptions } from "./ilink-api.js";
|
|
3
|
+
import { logger } from "./ilink-logger.js";
|
|
4
|
+
import { getMimeFromFilename } from "./mime.js";
|
|
5
|
+
import { sendFileMessageWeixin, sendImageMessageWeixin, sendVideoMessageWeixin } from "./weixin-send.js";
|
|
6
|
+
import { uploadFileAttachmentToWeixin, uploadFileToWeixin, uploadVideoToWeixin } from "./upload.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Upload a local file and send it as a weixin message, routing by MIME type:
|
|
10
|
+
* video/* → uploadVideoToWeixin + sendVideoMessageWeixin
|
|
11
|
+
* image/* → uploadFileToWeixin + sendImageMessageWeixin
|
|
12
|
+
* else → uploadFileAttachmentToWeixin + sendFileMessageWeixin
|
|
13
|
+
*
|
|
14
|
+
* Used by both the auto-reply deliver path (monitor.ts) and the outbound
|
|
15
|
+
* sendMedia path (channel.ts) so they stay in sync.
|
|
16
|
+
*/
|
|
17
|
+
export async function sendWeixinMediaFile(params: {
|
|
18
|
+
filePath: string;
|
|
19
|
+
to: string;
|
|
20
|
+
text: string;
|
|
21
|
+
opts: WeixinApiOptions & { contextToken?: string };
|
|
22
|
+
cdnBaseUrl: string;
|
|
23
|
+
}): Promise<{ messageId: string }> {
|
|
24
|
+
const { filePath, to, text, opts, cdnBaseUrl } = params;
|
|
25
|
+
const mime = getMimeFromFilename(filePath);
|
|
26
|
+
const uploadOpts: WeixinApiOptions = { baseUrl: opts.baseUrl, token: opts.token };
|
|
27
|
+
|
|
28
|
+
if (mime.startsWith("video/")) {
|
|
29
|
+
logger.info(`[weixin] sendWeixinMediaFile: uploading video filePath=${filePath} to=${to}`);
|
|
30
|
+
const uploaded = await uploadVideoToWeixin({
|
|
31
|
+
filePath,
|
|
32
|
+
toUserId: to,
|
|
33
|
+
opts: uploadOpts,
|
|
34
|
+
cdnBaseUrl,
|
|
35
|
+
});
|
|
36
|
+
logger.info(
|
|
37
|
+
`[weixin] sendWeixinMediaFile: video upload done filekey=${uploaded.filekey} size=${uploaded.fileSize}`,
|
|
38
|
+
);
|
|
39
|
+
return sendVideoMessageWeixin({ to, text, uploaded, opts });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (mime.startsWith("image/")) {
|
|
43
|
+
logger.info(`[weixin] sendWeixinMediaFile: uploading image filePath=${filePath} to=${to}`);
|
|
44
|
+
const uploaded = await uploadFileToWeixin({
|
|
45
|
+
filePath,
|
|
46
|
+
toUserId: to,
|
|
47
|
+
opts: uploadOpts,
|
|
48
|
+
cdnBaseUrl,
|
|
49
|
+
});
|
|
50
|
+
logger.info(
|
|
51
|
+
`[weixin] sendWeixinMediaFile: image upload done filekey=${uploaded.filekey} size=${uploaded.fileSize}`,
|
|
52
|
+
);
|
|
53
|
+
return sendImageMessageWeixin({ to, text, uploaded, opts });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// File attachment: pdf, doc, zip, etc.
|
|
57
|
+
const fileName = path.basename(filePath);
|
|
58
|
+
logger.info(
|
|
59
|
+
`[weixin] sendWeixinMediaFile: uploading file attachment filePath=${filePath} name=${fileName} to=${to}`,
|
|
60
|
+
);
|
|
61
|
+
const uploaded = await uploadFileAttachmentToWeixin({
|
|
62
|
+
filePath,
|
|
63
|
+
fileName,
|
|
64
|
+
toUserId: to,
|
|
65
|
+
opts: uploadOpts,
|
|
66
|
+
cdnBaseUrl,
|
|
67
|
+
});
|
|
68
|
+
logger.info(
|
|
69
|
+
`[weixin] sendWeixinMediaFile: file upload done filekey=${uploaded.filekey} size=${uploaded.fileSize}`,
|
|
70
|
+
);
|
|
71
|
+
return sendFileMessageWeixin({ to, text, fileName, uploaded, opts });
|
|
72
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { sendMessage as sendMessageApi } from "./ilink-api.js";
|
|
2
|
+
import type { WeixinApiOptions } from "./ilink-api.js";
|
|
3
|
+
import { logger } from "./ilink-logger.js";
|
|
4
|
+
import { generateId } from "./ilink-random.js";
|
|
5
|
+
import type { MessageItem, SendMessageReq } from "./ilink-types.js";
|
|
6
|
+
import { MessageItemType, MessageState, MessageType } from "./ilink-types.js";
|
|
7
|
+
import type { UploadedFileInfo } from "./upload.js";
|
|
8
|
+
|
|
9
|
+
function generateClientId(): string {
|
|
10
|
+
return generateId("weixin-ilink");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Build a SendMessageReq containing a single text message. */
|
|
14
|
+
function buildTextMessageReq(params: {
|
|
15
|
+
to: string;
|
|
16
|
+
text: string;
|
|
17
|
+
contextToken?: string;
|
|
18
|
+
clientId: string;
|
|
19
|
+
}): SendMessageReq {
|
|
20
|
+
const { to, text, contextToken, clientId } = params;
|
|
21
|
+
const item_list: MessageItem[] = text
|
|
22
|
+
? [{ type: MessageItemType.TEXT, text_item: { text } }]
|
|
23
|
+
: [];
|
|
24
|
+
return {
|
|
25
|
+
msg: {
|
|
26
|
+
from_user_id: "",
|
|
27
|
+
to_user_id: to,
|
|
28
|
+
client_id: clientId,
|
|
29
|
+
message_type: MessageType.BOT,
|
|
30
|
+
message_state: MessageState.FINISH,
|
|
31
|
+
item_list: item_list.length ? item_list : undefined,
|
|
32
|
+
context_token: contextToken ?? undefined,
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Build a SendMessageReq from a reply payload (text only; image send uses sendImageMessageWeixin). */
|
|
38
|
+
function buildSendMessageReq(params: {
|
|
39
|
+
to: string;
|
|
40
|
+
contextToken?: string;
|
|
41
|
+
payload: { text?: string };
|
|
42
|
+
clientId: string;
|
|
43
|
+
}): SendMessageReq {
|
|
44
|
+
const { to, contextToken, payload, clientId } = params;
|
|
45
|
+
return buildTextMessageReq({
|
|
46
|
+
to,
|
|
47
|
+
text: payload.text ?? "",
|
|
48
|
+
contextToken,
|
|
49
|
+
clientId,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Send a plain text message downstream.
|
|
55
|
+
*/
|
|
56
|
+
export async function sendMessageWeixin(params: {
|
|
57
|
+
to: string;
|
|
58
|
+
text: string;
|
|
59
|
+
opts: WeixinApiOptions & { contextToken?: string };
|
|
60
|
+
}): Promise<{ messageId: string }> {
|
|
61
|
+
const { to, text, opts } = params;
|
|
62
|
+
if (!opts.contextToken) {
|
|
63
|
+
logger.warn(`sendMessageWeixin: contextToken missing for to=${to}, sending without context`);
|
|
64
|
+
}
|
|
65
|
+
const clientId = generateClientId();
|
|
66
|
+
const req = buildSendMessageReq({
|
|
67
|
+
to,
|
|
68
|
+
contextToken: opts.contextToken,
|
|
69
|
+
payload: { text },
|
|
70
|
+
clientId,
|
|
71
|
+
});
|
|
72
|
+
try {
|
|
73
|
+
await sendMessageApi({
|
|
74
|
+
baseUrl: opts.baseUrl,
|
|
75
|
+
token: opts.token,
|
|
76
|
+
timeoutMs: opts.timeoutMs,
|
|
77
|
+
body: req,
|
|
78
|
+
});
|
|
79
|
+
} catch (err) {
|
|
80
|
+
logger.error(`sendMessageWeixin: failed to=${to} clientId=${clientId} err=${String(err)}`);
|
|
81
|
+
throw err;
|
|
82
|
+
}
|
|
83
|
+
return { messageId: clientId };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Send one or more MessageItems (optionally preceded by a text caption) downstream.
|
|
88
|
+
* Each item is sent as its own request so that item_list always has exactly one entry.
|
|
89
|
+
*/
|
|
90
|
+
async function sendMediaItems(params: {
|
|
91
|
+
to: string;
|
|
92
|
+
text: string;
|
|
93
|
+
mediaItem: MessageItem;
|
|
94
|
+
opts: WeixinApiOptions & { contextToken?: string };
|
|
95
|
+
label: string;
|
|
96
|
+
}): Promise<{ messageId: string }> {
|
|
97
|
+
const { to, text, mediaItem, opts, label } = params;
|
|
98
|
+
|
|
99
|
+
const items: MessageItem[] = [];
|
|
100
|
+
if (text) {
|
|
101
|
+
items.push({ type: MessageItemType.TEXT, text_item: { text } });
|
|
102
|
+
}
|
|
103
|
+
items.push(mediaItem);
|
|
104
|
+
|
|
105
|
+
let lastClientId = "";
|
|
106
|
+
for (const item of items) {
|
|
107
|
+
lastClientId = generateClientId();
|
|
108
|
+
const req: SendMessageReq = {
|
|
109
|
+
msg: {
|
|
110
|
+
from_user_id: "",
|
|
111
|
+
to_user_id: to,
|
|
112
|
+
client_id: lastClientId,
|
|
113
|
+
message_type: MessageType.BOT,
|
|
114
|
+
message_state: MessageState.FINISH,
|
|
115
|
+
item_list: [item],
|
|
116
|
+
context_token: opts.contextToken ?? undefined,
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
try {
|
|
120
|
+
await sendMessageApi({
|
|
121
|
+
baseUrl: opts.baseUrl,
|
|
122
|
+
token: opts.token,
|
|
123
|
+
timeoutMs: opts.timeoutMs,
|
|
124
|
+
body: req,
|
|
125
|
+
});
|
|
126
|
+
} catch (err) {
|
|
127
|
+
logger.error(
|
|
128
|
+
`${label}: failed to=${to} clientId=${lastClientId} err=${String(err)}`,
|
|
129
|
+
);
|
|
130
|
+
throw err;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
logger.info(`${label}: success to=${to} clientId=${lastClientId}`);
|
|
135
|
+
return { messageId: lastClientId };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Send an image message downstream using a previously uploaded file.
|
|
140
|
+
* Optionally include a text caption as a separate TEXT item before the image.
|
|
141
|
+
*
|
|
142
|
+
* ImageItem fields:
|
|
143
|
+
* - media.encrypt_query_param: CDN download param
|
|
144
|
+
* - media.aes_key: AES key, base64-encoded
|
|
145
|
+
* - mid_size: original ciphertext file size
|
|
146
|
+
*/
|
|
147
|
+
export async function sendImageMessageWeixin(params: {
|
|
148
|
+
to: string;
|
|
149
|
+
text: string;
|
|
150
|
+
uploaded: UploadedFileInfo;
|
|
151
|
+
opts: WeixinApiOptions & { contextToken?: string };
|
|
152
|
+
}): Promise<{ messageId: string }> {
|
|
153
|
+
const { to, text, uploaded, opts } = params;
|
|
154
|
+
if (!opts.contextToken) {
|
|
155
|
+
logger.warn(`sendImageMessageWeixin: contextToken missing for to=${to}, sending without context`);
|
|
156
|
+
}
|
|
157
|
+
logger.info(
|
|
158
|
+
`sendImageMessageWeixin: to=${to} filekey=${uploaded.filekey} fileSize=${uploaded.fileSize} aeskey=present`,
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const imageItem: MessageItem = {
|
|
162
|
+
type: MessageItemType.IMAGE,
|
|
163
|
+
image_item: {
|
|
164
|
+
media: {
|
|
165
|
+
encrypt_query_param: uploaded.downloadEncryptedQueryParam,
|
|
166
|
+
aes_key: Buffer.from(uploaded.aeskey).toString("base64"),
|
|
167
|
+
encrypt_type: 1,
|
|
168
|
+
},
|
|
169
|
+
mid_size: uploaded.fileSizeCiphertext,
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
return sendMediaItems({ to, text, mediaItem: imageItem, opts, label: "sendImageMessageWeixin" });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Send a video message downstream using a previously uploaded file.
|
|
178
|
+
* VideoItem: media (CDN ref), video_size (ciphertext bytes).
|
|
179
|
+
* Includes an optional text caption sent as a separate TEXT item first.
|
|
180
|
+
*/
|
|
181
|
+
export async function sendVideoMessageWeixin(params: {
|
|
182
|
+
to: string;
|
|
183
|
+
text: string;
|
|
184
|
+
uploaded: UploadedFileInfo;
|
|
185
|
+
opts: WeixinApiOptions & { contextToken?: string };
|
|
186
|
+
}): Promise<{ messageId: string }> {
|
|
187
|
+
const { to, text, uploaded, opts } = params;
|
|
188
|
+
if (!opts.contextToken) {
|
|
189
|
+
logger.warn(`sendVideoMessageWeixin: contextToken missing for to=${to}, sending without context`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const videoItem: MessageItem = {
|
|
193
|
+
type: MessageItemType.VIDEO,
|
|
194
|
+
video_item: {
|
|
195
|
+
media: {
|
|
196
|
+
encrypt_query_param: uploaded.downloadEncryptedQueryParam,
|
|
197
|
+
aes_key: Buffer.from(uploaded.aeskey).toString("base64"),
|
|
198
|
+
encrypt_type: 1,
|
|
199
|
+
},
|
|
200
|
+
video_size: uploaded.fileSizeCiphertext,
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
return sendMediaItems({ to, text, mediaItem: videoItem, opts, label: "sendVideoMessageWeixin" });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Send a file attachment downstream using a previously uploaded file.
|
|
209
|
+
* FileItem: media (CDN ref), file_name, len (plaintext bytes as string).
|
|
210
|
+
* Includes an optional text caption sent as a separate TEXT item first.
|
|
211
|
+
*/
|
|
212
|
+
export async function sendFileMessageWeixin(params: {
|
|
213
|
+
to: string;
|
|
214
|
+
text: string;
|
|
215
|
+
fileName: string;
|
|
216
|
+
uploaded: UploadedFileInfo;
|
|
217
|
+
opts: WeixinApiOptions & { contextToken?: string };
|
|
218
|
+
}): Promise<{ messageId: string }> {
|
|
219
|
+
const { to, text, fileName, uploaded, opts } = params;
|
|
220
|
+
if (!opts.contextToken) {
|
|
221
|
+
logger.warn(`sendFileMessageWeixin: contextToken missing for to=${to}, sending without context`);
|
|
222
|
+
}
|
|
223
|
+
const fileItem: MessageItem = {
|
|
224
|
+
type: MessageItemType.FILE,
|
|
225
|
+
file_item: {
|
|
226
|
+
media: {
|
|
227
|
+
encrypt_query_param: uploaded.downloadEncryptedQueryParam,
|
|
228
|
+
aes_key: Buffer.from(uploaded.aeskey).toString("base64"),
|
|
229
|
+
encrypt_type: 1,
|
|
230
|
+
},
|
|
231
|
+
file_name: fileName,
|
|
232
|
+
len: String(uploaded.fileSize),
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
return sendMediaItems({ to, text, mediaItem: fileItem, opts, label: "sendFileMessageWeixin" });
|
|
237
|
+
}
|