@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.
- package/LICENSE +21 -0
- package/README.md +172 -0
- package/index.ts +16 -0
- package/openclaw.plugin.json +58 -0
- package/package.json +73 -0
- package/proto/PbBoxPullProto.proto +43 -0
- package/proto/PbChatAudioContent.proto +23 -0
- package/proto/PbChatDeliverMsg.proto +38 -0
- package/proto/PbChatFileMeta.proto +34 -0
- package/proto/PbChatMsg.proto +93 -0
- package/proto/PbChatRichMediaContent.proto +31 -0
- package/proto/PbChatTextContent.proto +38 -0
- package/proto/PbMarkdownContent.proto +18 -0
- package/proto/PbMsgReadStampContent.proto +11 -0
- package/proto/PbPacket.proto +61 -0
- package/proto/PbSingleChatMsg.proto +60 -0
- package/setup-entry.ts +17 -0
- package/src/accounts.ts +109 -0
- package/src/api-client.ts +740 -0
- package/src/bot-info-cache.ts +49 -0
- package/src/channel.runtime.ts +29 -0
- package/src/channel.ts +456 -0
- package/src/config-schema.ts +26 -0
- package/src/e2ee/api.ts +261 -0
- package/src/e2ee/canonical.ts +59 -0
- package/src/e2ee/errors.ts +103 -0
- package/src/e2ee/index.ts +8 -0
- package/src/e2ee/proper-lockfile.d.ts +61 -0
- package/src/e2ee/service.ts +1273 -0
- package/src/e2ee/store.ts +174 -0
- package/src/e2ee/types.ts +113 -0
- package/src/e2ee/vodozemac.ts +373 -0
- package/src/file-transfer/api.ts +364 -0
- package/src/file-transfer/concurrency.ts +77 -0
- package/src/file-transfer/download.ts +261 -0
- package/src/file-transfer/file-crypto.ts +93 -0
- package/src/file-transfer/index.ts +18 -0
- package/src/file-transfer/scheduler.ts +185 -0
- package/src/file-transfer/types.ts +195 -0
- package/src/file-transfer/upload.ts +656 -0
- package/src/markdown-detect.ts +119 -0
- package/src/media-upload.ts +338 -0
- package/src/media-utils.ts +110 -0
- package/src/monitor.ts +838 -0
- package/src/proto/codec.ts +54 -0
- package/src/proto/inbound.codec.ts +624 -0
- package/src/proto/proto-types.ts +291 -0
- package/src/proto/registry.ts +226 -0
- package/src/proto/send.codec.ts +535 -0
- package/src/recent-message-cache.ts +350 -0
- package/src/send.ts +792 -0
- package/src/setup-core.ts +62 -0
- package/src/types.ts +153 -0
- package/src/vodozemackit/index.ts +297 -0
- package/src/vodozemackit/pkg/vodozemackit_wasm.d.ts +138 -0
- package/src/vodozemackit/pkg/vodozemackit_wasm.js +24 -0
- package/src/vodozemackit/pkg/vodozemackit_wasm_bg.js +1172 -0
- package/src/vodozemackit/pkg/vodozemackit_wasm_bg.wasm +0 -0
- 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
|
+
}
|