@imweapp/openclaw-imwe 2026.4.12-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +172 -0
  3. package/index.ts +16 -0
  4. package/openclaw.plugin.json +58 -0
  5. package/package.json +73 -0
  6. package/proto/PbBoxPullProto.proto +43 -0
  7. package/proto/PbChatAudioContent.proto +23 -0
  8. package/proto/PbChatDeliverMsg.proto +38 -0
  9. package/proto/PbChatFileMeta.proto +34 -0
  10. package/proto/PbChatMsg.proto +93 -0
  11. package/proto/PbChatRichMediaContent.proto +31 -0
  12. package/proto/PbChatTextContent.proto +38 -0
  13. package/proto/PbMarkdownContent.proto +18 -0
  14. package/proto/PbMsgReadStampContent.proto +11 -0
  15. package/proto/PbPacket.proto +61 -0
  16. package/proto/PbSingleChatMsg.proto +60 -0
  17. package/setup-entry.ts +17 -0
  18. package/src/accounts.ts +109 -0
  19. package/src/api-client.ts +740 -0
  20. package/src/bot-info-cache.ts +49 -0
  21. package/src/channel.runtime.ts +29 -0
  22. package/src/channel.ts +456 -0
  23. package/src/config-schema.ts +26 -0
  24. package/src/e2ee/api.ts +261 -0
  25. package/src/e2ee/canonical.ts +59 -0
  26. package/src/e2ee/errors.ts +103 -0
  27. package/src/e2ee/index.ts +8 -0
  28. package/src/e2ee/proper-lockfile.d.ts +61 -0
  29. package/src/e2ee/service.ts +1273 -0
  30. package/src/e2ee/store.ts +174 -0
  31. package/src/e2ee/types.ts +113 -0
  32. package/src/e2ee/vodozemac.ts +373 -0
  33. package/src/file-transfer/api.ts +364 -0
  34. package/src/file-transfer/concurrency.ts +77 -0
  35. package/src/file-transfer/download.ts +261 -0
  36. package/src/file-transfer/file-crypto.ts +93 -0
  37. package/src/file-transfer/index.ts +18 -0
  38. package/src/file-transfer/scheduler.ts +185 -0
  39. package/src/file-transfer/types.ts +195 -0
  40. package/src/file-transfer/upload.ts +656 -0
  41. package/src/markdown-detect.ts +119 -0
  42. package/src/media-upload.ts +338 -0
  43. package/src/media-utils.ts +110 -0
  44. package/src/monitor.ts +838 -0
  45. package/src/proto/codec.ts +54 -0
  46. package/src/proto/inbound.codec.ts +624 -0
  47. package/src/proto/proto-types.ts +291 -0
  48. package/src/proto/registry.ts +226 -0
  49. package/src/proto/send.codec.ts +535 -0
  50. package/src/recent-message-cache.ts +350 -0
  51. package/src/send.ts +792 -0
  52. package/src/setup-core.ts +62 -0
  53. package/src/types.ts +153 -0
  54. package/src/vodozemackit/index.ts +297 -0
  55. package/src/vodozemackit/pkg/vodozemackit_wasm.d.ts +138 -0
  56. package/src/vodozemackit/pkg/vodozemackit_wasm.js +24 -0
  57. package/src/vodozemackit/pkg/vodozemackit_wasm_bg.js +1172 -0
  58. package/src/vodozemackit/pkg/vodozemackit_wasm_bg.wasm +0 -0
  59. package/src/vodozemackit/pkg/vodozemackit_wasm_bg.wasm.d.ts +109 -0
@@ -0,0 +1,740 @@
1
+ /**
2
+ * api-client.ts — imwe Open API HTTP 客户端
3
+ *
4
+ * 对应接口文档:docs/02-OpenAPI接口设计.md
5
+ * Base URL:/api/im/open/bot
6
+ *
7
+ * ── 传输格式 ──────────────────────────────────────────────────────────────────
8
+ * sendMessage / pullMessages:application/x-protobuf
9
+ * getMe 等 JSON 接口: application/json
10
+ *
11
+ * ── 鉴权机制(文档 §1) ───────────────────────────────────────────────────────
12
+ * 每个请求携带三个 Header:
13
+ * X-Api-Key: API Key(ak_ 前缀)
14
+ * X-Timestamp: 毫秒级时间戳字符串
15
+ * X-Signature: Base64(HMAC-SHA256(apiSecret, signString))
16
+ *
17
+ * 签名原文(signString):
18
+ * {METHOD}&{PATH}&{TIMESTAMP}&{BODY_HASH}
19
+ * BODY_HASH = hex(SHA-256(body)),body 为空时对空字符串 "" 哈希
20
+ *
21
+ * ── 设计原则 ──────────────────────────────────────────────────────────────────
22
+ * - 纯函数,不持有状态
23
+ * - Protobuf encode/decode 集中在 src/proto/codec.ts,此文件只负责 HTTP 传输
24
+ * - 请求失败时抛出带 HTTP 状态码的 Error,由上层决定是否重试
25
+ */
26
+
27
+ import { createHmac, createHash, randomUUID } from 'node:crypto';
28
+ import {
29
+ decodeSendResponse,
30
+ encodeSendRequest,
31
+ encodeMediaSendRequest,
32
+ encodeMarkdownSendRequest,
33
+ encodeTypingSignalRequest,
34
+ decodeInboundPacket,
35
+ genClientMsgId,
36
+ } from './proto/codec.js';
37
+ import { decodeInboundPacketWithE2ee } from './proto/inbound.codec.js';
38
+ import type { E2eeDecryptFn } from './proto/inbound.codec.js';
39
+ import type { OutboundMediaParams } from './proto/codec.js';
40
+ import { getRegistry } from './proto/registry.js';
41
+ import type { ImweInboundMessage, ImweSendResponse } from './types.js';
42
+ import { uploadMediaToStorage } from './media-upload.js';
43
+ import { inferMediaType, inferMediaTypeFromMime } from './media-utils.js';
44
+
45
+ // ─────────────────────────────────────────────────────────────────────────────
46
+ // 签名工具(文档 §1.2)
47
+ // ─────────────────────────────────────────────────────────────────────────────
48
+
49
+ /**
50
+ * 计算请求签名。
51
+ *
52
+ * 签名原文:{METHOD}&{PATH}&{TIMESTAMP}&{BODY_HASH}
53
+ * - METHOD:HTTP 方法大写,固定为 "POST"
54
+ * - PATH:请求路径,如 "/api/im/open/bot/sendMessage"
55
+ * - TIMESTAMP:与 X-Timestamp Header 相同的毫秒时间戳字符串
56
+ * - BODY_HASH:hex(SHA-256(body)),body 为空时对空字符串 "" 哈希
57
+ *
58
+ * 签名算法:Base64(HMAC-SHA256(apiSecret, signString))
59
+ *
60
+ * @param params.apiSecret API Secret(创建机器人时返回,明文仅展示一次)
61
+ * @param params.method HTTP 方法(大写),通常为 "POST"
62
+ * @param params.path 请求路径,如 "/api/im/open/bot/sendMessage"
63
+ * @param params.timestamp 毫秒时间戳字符串,与 X-Timestamp Header 一致
64
+ * @param params.body 请求体字节(Protobuf 二进制或 JSON 字符串的 UTF-8 编码)
65
+ * @returns Base64 编码的 HMAC-SHA256 签名字符串
66
+ */
67
+ export function buildSignature(params: {
68
+ apiSecret: string;
69
+ method: string;
70
+ path: string;
71
+ timestamp: string;
72
+ body: Uint8Array;
73
+ }): string {
74
+ // 1. 计算请求体的 SHA-256 哈希(小写 hex)
75
+ // body 为空时对空字符串 "" 哈希(SHA-256("") 是固定值)
76
+ const bodyHash = createHash('sha256').update(params.body).digest('hex');
77
+
78
+ // 2. 拼接签名原文
79
+ const signString = [params.method, params.path, params.timestamp, bodyHash].join('&');
80
+
81
+ // 3. HMAC-SHA256 签名,输出 Base64
82
+ return createHmac('sha256', params.apiSecret).update(signString, 'utf8').digest('base64');
83
+ }
84
+
85
+ /**
86
+ * 构造鉴权 Header 集合。
87
+ *
88
+ * @param apiKey API Key(ak_ 前缀)
89
+ * @param apiSecret API Secret
90
+ * @param method HTTP 方法(大写)
91
+ * @param path 请求路径(含前导 /)
92
+ * @param body 请求体字节
93
+ * @returns 包含 X-Api-Key / X-Timestamp / X-Signature 的 Header 对象
94
+ */
95
+ function buildAuthHeaders(
96
+ apiKey: string,
97
+ apiSecret: string,
98
+ method: string,
99
+ path: string,
100
+ body: Uint8Array,
101
+ ): Record<string, string> {
102
+ // 毫秒级时间戳(文档要求毫秒,服务端校验 5 分钟窗口)
103
+ const timestamp = String(Date.now());
104
+ const signature = buildSignature({ apiSecret, method, path, timestamp, body });
105
+ return {
106
+ 'X-Api-Key': apiKey,
107
+ 'X-Timestamp': timestamp,
108
+ 'X-Signature': signature,
109
+ reqid: randomUUID().replaceAll('-', ''),
110
+ };
111
+ }
112
+
113
+ // ─────────────────────────────────────────────────────────────────────────────
114
+ // 内部工具:统一 HTTP 请求
115
+ // ─────────────────────────────────────────────────────────────────────────────
116
+
117
+ /**
118
+ * 发送 Protobuf 二进制请求,返回响应体字节。
119
+ *
120
+ * Content-Type: application/x-protobuf
121
+ * Accept: application/x-protobuf
122
+ *
123
+ * @param apiBaseUrl API 基础地址,如 https://api.imwe.example.com
124
+ * @param path 请求路径,如 /api/im/open/bot/sendMessage
125
+ * @param body Protobuf 编码的请求体
126
+ * @param auth 鉴权凭证(apiKey + apiSecret)
127
+ * @param opts.timeoutMs 请求超时(毫秒),默认不超时
128
+ * @throws 非 2xx 响应时抛出包含状态码的 Error
129
+ */
130
+ export async function postProto(
131
+ apiBaseUrl: string,
132
+ path: string,
133
+ body: Uint8Array,
134
+ auth: { apiKey: string; apiSecret: string },
135
+ opts?: { timeoutMs?: number },
136
+ ): Promise<Uint8Array> {
137
+ const url = `${apiBaseUrl.replace(/\/$/, '')}${path}`;
138
+
139
+ const controller = new AbortController();
140
+ const timer = opts?.timeoutMs ? setTimeout(() => controller.abort(), opts.timeoutMs) : null;
141
+
142
+ try {
143
+ const res = await fetch(url, {
144
+ method: 'POST',
145
+ headers: {
146
+ 'Content-Type': 'application/x-protobuf',
147
+ Accept: 'application/x-protobuf',
148
+ ...buildAuthHeaders(auth.apiKey, auth.apiSecret, 'POST', path, body),
149
+ },
150
+ body: body as BodyInit,
151
+ signal: controller.signal,
152
+ });
153
+
154
+ if (!res.ok) {
155
+ const errText = await res.text().catch(() => '');
156
+ throw new Error(`imwe API ${path} 返回 ${res.status}: ${errText}`);
157
+ }
158
+
159
+ const buf = await res.arrayBuffer();
160
+ return new Uint8Array(buf);
161
+ } finally {
162
+ if (timer) {
163
+ clearTimeout(timer);
164
+ }
165
+ }
166
+ }
167
+
168
+ /**
169
+ * 发送 JSON 请求,返回解析后的 JSON 对象。
170
+ *
171
+ * Content-Type: application/json
172
+ *
173
+ * @param apiBaseUrl API 基础地址
174
+ * @param path 请求路径
175
+ * @param body 请求体对象(会被 JSON.stringify)
176
+ * @param auth 鉴权凭证
177
+ * @param opts.timeoutMs 请求超时(毫秒)
178
+ * @throws 非 2xx 响应时抛出包含状态码的 Error
179
+ */
180
+ export async function postJson<T>(
181
+ apiBaseUrl: string,
182
+ path: string,
183
+ body: Record<string, unknown>,
184
+ auth: { apiKey: string; apiSecret: string },
185
+ opts?: { timeoutMs?: number },
186
+ ): Promise<T> {
187
+ const url = `${apiBaseUrl.replace(/\/$/, '')}${path}`;
188
+ const bodyStr = JSON.stringify(body);
189
+ const bodyBytes = new TextEncoder().encode(bodyStr);
190
+
191
+ const controller = new AbortController();
192
+ const timer = opts?.timeoutMs ? setTimeout(() => controller.abort(), opts.timeoutMs) : null;
193
+
194
+ try {
195
+ const res = await fetch(url, {
196
+ method: 'POST',
197
+ headers: {
198
+ 'Content-Type': 'application/json',
199
+ Accept: 'application/json',
200
+ ...buildAuthHeaders(auth.apiKey, auth.apiSecret, 'POST', path, bodyBytes),
201
+ },
202
+ body: bodyStr,
203
+ signal: controller.signal,
204
+ });
205
+
206
+ if (!res.ok) {
207
+ const errText = await res.text().catch(() => '');
208
+ throw new Error(`imwe API ${path} 返回 ${res.status}: ${errText}`);
209
+ }
210
+
211
+ return res.json() as Promise<T>;
212
+ } finally {
213
+ if (timer) {
214
+ clearTimeout(timer);
215
+ }
216
+ }
217
+ }
218
+
219
+ // ─────────────────────────────────────────────────────────────────────────────
220
+ // getMe — 获取当前机器人信息(文档 §三)
221
+ // ─────────────────────────────────────────────────────────────────────────────
222
+
223
+ /**
224
+ * 机器人信息(对应 getMe 接口响应)
225
+ */
226
+ export type BotInfo = {
227
+ /** 机器人记录 UUID */
228
+ id: string;
229
+ /** 机器人用户 ID,如 "bot_1776166649" */
230
+ botUserId: string;
231
+ /** 机器人 IM 账户 ID(mainAcctId),如 "bot_AC_bot_1776166649" */
232
+ botMainAcctId: string;
233
+ /**
234
+ * 机器人 IM 账户 ID(mainAcctId),如 "bot_AC_bot_1776166649"。
235
+ * pullMessages 时作为 PbBoxPullReq.boxId 使用。
236
+ */
237
+ botAcctId: string;
238
+ /** 创建者用户 ID */
239
+ creatorUserId: string;
240
+ /** 机器人名称 */
241
+ botName: string;
242
+ /** 机器人描述 */
243
+ description?: string;
244
+ /** 头像 URL */
245
+ avatarUrl?: string;
246
+ /** 状态:1=启用 */
247
+ status: number;
248
+ /** 是否启用 E2EE;未启用时服务端可能省略该字段 */
249
+ e2eeEnabled?: boolean;
250
+ /** 创建时间(毫秒时间戳) */
251
+ createTime: number;
252
+ /** 更新时间(毫秒时间戳) */
253
+ updateTime: number;
254
+ };
255
+
256
+ /**
257
+ * 获取当前机器人信息。
258
+ *
259
+ * 对应接口:POST /api/im/open/bot/getMe
260
+ * 返回的 botAcctId 是机器人的 mainAcctId,pullMessages 时作为 boxId 使用。
261
+ *
262
+ * @param apiBaseUrl API 基础地址
263
+ * @param auth 鉴权凭证
264
+ * @returns 机器人信息
265
+ * @throws 鉴权失败或网络错误时抛出 Error
266
+ */
267
+ export async function getMe(
268
+ apiBaseUrl: string,
269
+ auth: { apiKey: string; apiSecret: string },
270
+ ): Promise<BotInfo> {
271
+ return postJson<BotInfo>(apiBaseUrl, '/api/im/open/bot/getMe', {}, auth);
272
+ }
273
+
274
+ // ─────────────────────────────────────────────────────────────────────────────
275
+ // pullMessages — 事件箱拉取消息(文档 §二)
276
+ // ─────────────────────────────────────────────────────────────────────────────
277
+
278
+ /**
279
+ * 事件箱拉取状态(用于增量同步)
280
+ */
281
+ export type PullState = {
282
+ /**
283
+ * 下次拉取的起始序列号。
284
+ * 初始值为 0(从头同步),每次收到 milestone=true 的响应后更新为 endSeq+1。
285
+ *
286
+ * 使用 number 而非 bigint:protobufjs 的 uint64 字段期望 number 或 Long 对象,
287
+ * 传入原生 bigint 会触发 "Do not know how to serialize a BigInt" 错误。
288
+ * uint64 的安全整数范围(< 2^53)对事件箱序列号完全够用。
289
+ */
290
+ nextStartSeq: number;
291
+ };
292
+
293
+ /**
294
+ * 通过事件箱模式拉取机器人待处理消息。
295
+ *
296
+ * 对应接口:POST /api/im/open/bot/pullMessages
297
+ * 请求体:PbBoxPullReq(直接序列化,不包装在 PbPacket 中)
298
+ * 响应体:PbBoxPullRsp
299
+ *
300
+ * 增量拉取流程:
301
+ * 1. 首次调用:state.nextStartSeq=0,拉取全部历史消息
302
+ * 2. 响应 milestone=true:将 endSeq 落库,下次从 endSeq+1 开始
303
+ * 3. 响应 hasMore=true:立即继续拉取(调用方负责循环)
304
+ * 4. 响应 endSeq=0:无新消息
305
+ *
306
+ * @param apiBaseUrl API 基础地址
307
+ * @param auth 鉴权凭证
308
+ * @param boxId 事件箱 ID(机器人的 botAcctId/mainAcctId,来自 getMe 响应)
309
+ * @param state 拉取状态(含 nextStartSeq,调用方负责持久化)
310
+ * @returns 解包后的消息列表、更新后的状态、是否还有更多数据
311
+ */
312
+ export async function pullMessages(
313
+ apiBaseUrl: string,
314
+ auth: { apiKey: string; apiSecret: string },
315
+ boxId: string,
316
+ state: PullState,
317
+ opts?: { e2eeDecrypt?: E2eeDecryptFn },
318
+ ): Promise<{ messages: ImweInboundMessage[]; state: PullState; hasMore: boolean }> {
319
+ const reg = await getRegistry();
320
+
321
+ // 构造 PbBoxPullReq
322
+ const reqMsg = reg.PbBoxPullReq.create({
323
+ boxId,
324
+ startSeq: state.nextStartSeq,
325
+ });
326
+ const requestBody = reg.PbBoxPullReq.encode(reqMsg).finish();
327
+
328
+ const responseBytes = await postProto(
329
+ apiBaseUrl,
330
+ '/api/im/open/bot/pullMessages',
331
+ requestBody,
332
+ auth,
333
+ );
334
+
335
+ // 解码 PbBoxPullRsp
336
+ // protobufjs 将 uint64 字段解码为 Long 对象(来自 long 包),用 Number() 转换为 number
337
+ const rsp = reg.PbBoxPullRsp.decode(responseBytes) as unknown as {
338
+ boxId: string;
339
+ startSeq: { toNumber(): number } | number;
340
+ endSeq: { toNumber(): number } | number;
341
+ milestone: boolean;
342
+ hasMore: boolean;
343
+ items: Array<{
344
+ path?: string;
345
+ reqId?: string;
346
+ reqStamp?: { toNumber(): number } | number;
347
+ body?: Uint8Array;
348
+ }>;
349
+ };
350
+
351
+ // Long 对象用 toNumber(),普通 number 直接用,统一转为 number
352
+ const toNum = (v: { toNumber(): number } | number | undefined): number =>
353
+ v == null ? 0 : typeof v === 'number' ? v : v.toNumber();
354
+
355
+ const endSeq = toNum(rsp.endSeq);
356
+ const hasMore = rsp.hasMore ?? false;
357
+
358
+ // milestone=true 且 endSeq>0 时更新游标,下次从 endSeq+1 开始
359
+ const nextStartSeq = rsp.milestone && endSeq > 0 ? endSeq + 1 : state.nextStartSeq;
360
+
361
+ // 解包每条消息:item.body → PbChatMsgDeliverBody → PbChatMsgEnvelope → text
362
+ const messages: ImweInboundMessage[] = [];
363
+ for (const item of rsp.items ?? []) {
364
+ if (!item.body || item.body.length === 0) {
365
+ continue;
366
+ }
367
+
368
+ // item.body 是 PbChatMsgDeliverBody 的字节,需要包装成 PbPacket(SERVER_REQ) 再走四层解包
369
+ // 按文档:items[].request.body 为 PbChatMsgDeliverBody
370
+ // 构造一个虚拟的 PbPacket(SERVER_REQ) 供 decodeInboundPacket 使用
371
+ const requestMsg = reg.PbRequest.create({
372
+ path: '/chat/deliver',
373
+ reqId: item.reqId ?? '',
374
+ reqStamp: toNum(item.reqStamp),
375
+ body: item.body,
376
+ });
377
+ const requestBytes = reg.PbRequest.encode(requestMsg).finish();
378
+
379
+ const packetMsg = reg.PbPacket.create({
380
+ type: 2, // SERVER_REQ
381
+ request: reg.PbRequest.decode(requestBytes),
382
+ });
383
+ const packetBytes = reg.PbPacket.encode(packetMsg).finish();
384
+
385
+ const result = opts?.e2eeDecrypt
386
+ ? await decodeInboundPacketWithE2ee(packetBytes, { e2eeDecrypt: opts.e2eeDecrypt })
387
+ : await decodeInboundPacket(packetBytes);
388
+ if (result.ok) {
389
+ messages.push(result.message);
390
+ }
391
+ // result.ok=false 时静默跳过(操作消息、非文本消息、decrypt-failed、operation-consumed 等)
392
+ }
393
+
394
+ return {
395
+ messages,
396
+ state: { nextStartSeq },
397
+ hasMore,
398
+ };
399
+ }
400
+
401
+ // ─────────────────────────────────────────────────────────────────────────────
402
+ // sendMessage — 发送单聊消息(文档 §一)
403
+ // ─────────────────────────────────────────────────────────────────────────────
404
+
405
+ /**
406
+ * 向用户发送单聊文字消息。
407
+ *
408
+ * 对应接口:POST /api/im/open/bot/sendMessage
409
+ * 请求体:PbPacket(CLIENT_REQ) 四层包装(由 encodeSendRequest 生成)
410
+ * 响应体:PbResponse 的 protobuf 二进制(statusCode + body),body 为 PbMsgRspBody
411
+ *
412
+ * 四层包装链路(由 send.codec.ts 负责):
413
+ * PbChatTextContent(text)
414
+ * → PbChatMsgEnvelope(textContent)
415
+ * → PbSingleChatMsgReqBody(fromId, toId, envelopeType=1, e2eeFlag=false, envelope)
416
+ * → PbRequest(path="/api/im/chat/bot", body)
417
+ * → PbPacket(type=CLIENT_REQ=0, request)
418
+ *
419
+ * @param apiBaseUrl API 基础地址
420
+ * @param auth 鉴权凭证
421
+ * @param fromId 发送方 ID(机器人的 botAcctId,来自 getMe 响应)
422
+ * @param to 接收方用户 ID
423
+ * @param text 消息文本内容
424
+ * @returns 发送结果
425
+ */
426
+ export async function sendTextMessage(
427
+ apiBaseUrl: string,
428
+ auth: { apiKey: string; apiSecret: string },
429
+ fromId: string,
430
+ to: string,
431
+ text: string,
432
+ options?: { replyToId?: string; clientMsgId?: string },
433
+ ): Promise<ImweSendResponse> {
434
+ const clientMsgId = options?.clientMsgId ?? genClientMsgId();
435
+ // encodeSendRequest 负责四层包装,fromId 传入供 PbSingleChatMsgReqBody 使用
436
+ const requestBody = await encodeSendRequest(to, text, fromId, {
437
+ referenceClientMsgId: options?.replyToId,
438
+ clientMsgId,
439
+ });
440
+
441
+ const responseBytes = await postProto(
442
+ apiBaseUrl,
443
+ '/api/im/open/bot/sendMessage',
444
+ requestBody,
445
+ auth,
446
+ );
447
+
448
+ return {
449
+ ...(await decodeSendResponse(responseBytes)),
450
+ clientMsgId,
451
+ };
452
+ }
453
+
454
+ // ─────────────────────────────────────────────────────────────────────────────
455
+ // sendMediaMessage — 发送多媒体消息(文档 §一)
456
+ // ─────────────────────────────────────────────────────────────────────────────
457
+
458
+ /**
459
+ * 向用户发送多媒体消息(图片/视频/音频/文件)。
460
+ *
461
+ * 使用与 sendTextMessage 相同的 HTTP 接口和鉴权机制,
462
+ * 区别在于 PbChatMsgEnvelope 的 oneof 字段为 richMediaContent/fileContent/audioContent。
463
+ *
464
+ * @param apiBaseUrl API 基础地址
465
+ * @param auth 鉴权凭证
466
+ * @param media 多媒体消息参数(含 to、url、mediaType 等)
467
+ * @returns 发送结果
468
+ */
469
+ export async function sendMediaMessage(
470
+ apiBaseUrl: string,
471
+ auth: { apiKey: string; apiSecret: string },
472
+ media: OutboundMediaParams,
473
+ ): Promise<ImweSendResponse> {
474
+ const clientMsgId = media.clientMsgId ?? genClientMsgId();
475
+ const requestBody = await encodeMediaSendRequest({
476
+ ...media,
477
+ clientMsgId,
478
+ });
479
+
480
+ const responseBytes = await postProto(
481
+ apiBaseUrl,
482
+ '/api/im/open/bot/sendMessage',
483
+ requestBody,
484
+ auth,
485
+ );
486
+
487
+ return {
488
+ ...(await decodeSendResponse(responseBytes)),
489
+ clientMsgId,
490
+ };
491
+ }
492
+
493
+ // ─────────────────────────────────────────────────────────────────────────────
494
+ // sendMarkdownMessage — 发送 markdown 消息(文档 §一)
495
+ // ─────────────────────────────────────────────────────────────────────────────
496
+
497
+ /**
498
+ * 向用户发送 markdown 格式消息。
499
+ *
500
+ * 使用与 sendTextMessage / sendMediaMessage 相同的 HTTP 接口和鉴权机制,
501
+ * 区别在于 PbChatMsgEnvelope 的 oneof 字段为 markdownContent(字段 16)。
502
+ *
503
+ * 四层包装链路(由 send.codec.ts::encodeMarkdownSendRequest 负责):
504
+ * PbMarkdownContent(digest, markdown)
505
+ * → PbChatMsgEnvelope(markdownContent=...)
506
+ * → PbSingleChatMsgReqBody(envelopeType=1, e2eeFlag=false)
507
+ * → PbRequest(path="/api/im/chat/bot")
508
+ * → PbPacket(type=CLIENT_REQ=0)
509
+ *
510
+ * @param apiBaseUrl API 基础地址
511
+ * @param auth 鉴权凭证
512
+ * @param params markdown 消息参数:fromId / to / markdown / digest? / clientMsgId?
513
+ * @returns 发送结果
514
+ */
515
+ export async function sendMarkdownMessage(
516
+ apiBaseUrl: string,
517
+ auth: { apiKey: string; apiSecret: string },
518
+ params: {
519
+ fromId: string;
520
+ to: string;
521
+ markdown: string;
522
+ digest?: string;
523
+ clientMsgId?: string;
524
+ },
525
+ ): Promise<ImweSendResponse> {
526
+ const clientMsgId = params.clientMsgId ?? genClientMsgId();
527
+ const requestBody = await encodeMarkdownSendRequest({
528
+ to: params.to,
529
+ fromId: params.fromId,
530
+ markdown: params.markdown,
531
+ digest: params.digest,
532
+ clientMsgId,
533
+ });
534
+
535
+ const responseBytes = await postProto(
536
+ apiBaseUrl,
537
+ '/api/im/open/bot/sendMessage',
538
+ requestBody,
539
+ auth,
540
+ );
541
+
542
+ return {
543
+ ...(await decodeSendResponse(responseBytes)),
544
+ clientMsgId,
545
+ };
546
+ }
547
+
548
+ // ─────────────────────────────────────────────────────────────────────────────
549
+ // sendMediaMessageWithUpload — 上传并发送多媒体消息(deliver 回调专用)
550
+ // ─────────────────────────────────────────────────────────────────────────────
551
+
552
+ /**
553
+ * 上传并发送多媒体消息。
554
+ *
555
+ * 与 sendMediaMessage 的区别:包含 uploadMediaToStorage 上传步骤。
556
+ * 与 sendImweMedia(send.ts)的区别:不走账号解析,直接接收已解析的连接参数。
557
+ * 适用于 monitor.ts 的 deliver 回调,此时账号信息已在上下文中。
558
+ *
559
+ * @param apiBaseUrl API 基础地址
560
+ * @param auth 鉴权凭证
561
+ * @param fromId 发送方 ID(机器人的 botAcctId)
562
+ * @param to 接收方用户 ID
563
+ * @param mediaUrl 媒体文件 URL(本地路径或远程 URL)
564
+ * @param options 可选参数(caption 等)
565
+ * @returns 发送结果
566
+ */
567
+ export async function sendMediaMessageWithUpload(
568
+ apiBaseUrl: string,
569
+ auth: { apiKey: string; apiSecret: string },
570
+ fromId: string,
571
+ to: string,
572
+ mediaUrl: string,
573
+ options?: {
574
+ caption?: string;
575
+ /** 日志接口(可选) */
576
+ log?: { info?: (...args: unknown[]) => void; warn?: (...args: unknown[]) => void };
577
+ },
578
+ ): Promise<ImweSendResponse> {
579
+ const log = options?.log;
580
+
581
+ // 上传到平台存储
582
+ log?.info?.(`[sendMediaMessageWithUpload] 开始上传: mediaUrl=${mediaUrl.slice(0, 80)}, to=${to}`);
583
+ const uploadResult = await uploadMediaToStorage(mediaUrl, auth, apiBaseUrl, {
584
+ imMainAccId: to,
585
+ log,
586
+ });
587
+ log?.info?.(
588
+ `[sendMediaMessageWithUpload] 上传完成: url=${uploadResult.url.slice(0, 80)}, contentType=${uploadResult.contentType}, fileSize=${uploadResult.fileSize}`,
589
+ );
590
+
591
+ // 优先从上传结果的 contentType 推断媒体类型,回退到原始 URL 后缀推断
592
+ // 注意:不能用 uploadResult.url(CDN URL 可能无扩展名,会永远回退为 'file')
593
+ const mediaType = uploadResult.contentType
594
+ ? inferMediaTypeFromMime(uploadResult.contentType)
595
+ : inferMediaType(mediaUrl);
596
+ log?.info?.(`[sendMediaMessageWithUpload] 推断 mediaType=${mediaType}, 准备发送`);
597
+
598
+ return sendMediaMessage(apiBaseUrl, auth, {
599
+ to,
600
+ fromId,
601
+ url: uploadResult.url,
602
+ mediaType,
603
+ mimeType: uploadResult.contentType,
604
+ fileSize: uploadResult.fileSize,
605
+ caption: options?.caption,
606
+ blurHash: uploadResult.blurHash,
607
+ width: uploadResult.width,
608
+ height: uploadResult.height,
609
+ });
610
+ }
611
+
612
+ // ─────────────────────────────────────────────────────────────────────────────
613
+ // uploadPreSignedUrl — 获取文件上传预签名地址(文档 §六)
614
+ // ─────────────────────────────────────────────────────────────────────────────
615
+
616
+ /** 预签名上传地址响应 */
617
+ export type PreSignedUrlResult = {
618
+ fileId: string;
619
+ preSignedUrl: string;
620
+ expireTime: number;
621
+ };
622
+
623
+ /**
624
+ * 获取文件上传的预签名 URL。
625
+ * 第三方使用该 URL 直传文件到对象存储(无需经过服务端中转)。
626
+ *
627
+ * @param apiBaseUrl API 基础地址
628
+ * @param auth 鉴权凭证
629
+ * @param params 上传参数
630
+ */
631
+ export async function uploadPreSignedUrl(
632
+ apiBaseUrl: string,
633
+ auth: { apiKey: string; apiSecret: string },
634
+ params: {
635
+ imMainAccId: string;
636
+ fileName: string;
637
+ fileSize: number;
638
+ mimeType: string;
639
+ scene?: string;
640
+ },
641
+ ): Promise<PreSignedUrlResult> {
642
+ return postJson<PreSignedUrlResult>(
643
+ apiBaseUrl,
644
+ '/api/im/open/bot/uploadPreSignedUrl',
645
+ {
646
+ imMainAccId: params.imMainAccId,
647
+ fileName: params.fileName,
648
+ fileSize: params.fileSize,
649
+ mimeType: params.mimeType,
650
+ scene: params.scene ?? 'AI_GC',
651
+ },
652
+ auth,
653
+ );
654
+ }
655
+
656
+ // ─────────────────────────────────────────────────────────────────────────────
657
+ // uploadConfirm — 文件上传确认(文档 §七)
658
+ // ─────────────────────────────────────────────────────────────────────────────
659
+
660
+ /** 上传确认响应 */
661
+ export type UploadConfirmResult = {
662
+ fileUrl: string;
663
+ downloadUrl?: string;
664
+ expireTime?: number;
665
+ };
666
+
667
+ /**
668
+ * 文件上传到对象存储后,确认上传完成,获取文件访问地址。
669
+ *
670
+ * @param apiBaseUrl API 基础地址
671
+ * @param auth 鉴权凭证
672
+ * @param params 确认参数
673
+ */
674
+ export async function uploadConfirm(
675
+ apiBaseUrl: string,
676
+ auth: { apiKey: string; apiSecret: string },
677
+ params: {
678
+ imMainAccId: string;
679
+ fileId: string;
680
+ scene?: string;
681
+ },
682
+ ): Promise<UploadConfirmResult> {
683
+ return postJson<UploadConfirmResult>(
684
+ apiBaseUrl,
685
+ '/api/im/open/bot/uploadConfirm',
686
+ {
687
+ imMainAccId: params.imMainAccId,
688
+ fileId: params.fileId,
689
+ needDownloadUrl: false,
690
+ scene: params.scene ?? 'AI_GC',
691
+ },
692
+ auth,
693
+ );
694
+ }
695
+
696
+ // ─────────────────────────────────────────────────────────────────────────────
697
+ // sendTypingSignal — 发送 typing indicator 信号(文档 §一,envelopeType=2)
698
+ // ─────────────────────────────────────────────────────────────────────────────
699
+
700
+ /**
701
+ * 向用户发送 typing signal(正在输入指示器)。
702
+ *
703
+ * 使用与 sendTextMessage 相同的 HTTP 接口和鉴权机制,
704
+ * 区别在于 envelopeType=2 和 PbChatOperationEnvelope 信封。
705
+ *
706
+ * 四层包装链路(由 send.codec.ts 负责):
707
+ * PbBotThinkingSignal(status, botImAcctId)
708
+ * → PbChatOperationEnvelope(botThinking=...)
709
+ * → PbSingleChatMsgReqBody(fromId, toId, envelopeType=2, envelope=...)
710
+ * → PbRequest(path="/api/im/chat/bot", body=...)
711
+ * → PbPacket(type=CLIENT_REQ=0, request=...)
712
+ *
713
+ * @param apiBaseUrl API 基础地址
714
+ * @param auth 鉴权凭证
715
+ * @param fromId 发送方 ID(机器人的 botAcctId,来自 getMe 响应)
716
+ * @param to 接收方用户 ID
717
+ * @param status 1=begin(开始输入),2=end(结束输入)
718
+ * @param botImAcctId Bot 的 imAcctId(通常等于 fromId / botAcctId)
719
+ * @returns 发送结果
720
+ */
721
+ export async function sendTypingSignal(
722
+ apiBaseUrl: string,
723
+ auth: { apiKey: string; apiSecret: string },
724
+ fromId: string,
725
+ to: string,
726
+ status: 1 | 2,
727
+ botImAcctId: string,
728
+ ): Promise<ImweSendResponse> {
729
+ // encodeTypingSignalRequest 负责四层包装,fromId 传入供 PbSingleChatMsgReqBody 使用
730
+ const requestBody = await encodeTypingSignalRequest(to, status, botImAcctId, fromId);
731
+
732
+ const responseBytes = await postProto(
733
+ apiBaseUrl,
734
+ '/api/im/open/bot/sendMessage',
735
+ requestBody,
736
+ auth,
737
+ );
738
+
739
+ return decodeSendResponse(responseBytes);
740
+ }