@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
package/src/send.ts
ADDED
|
@@ -0,0 +1,792 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* send.ts — 发送消息(outbound / pairing / 主动投递 统一入口)
|
|
3
|
+
*
|
|
4
|
+
* 双层结构:
|
|
5
|
+
* - 基础层(sendTextToPeer / sendMarkdownToPeer / sendMediaToPeer / sendTypingSignalToPeer)
|
|
6
|
+
* 接收已解析的 OutboundContext,负责 E2EE 加密 + HTTP 发送 + DEVICE_NOT_MATCH 重试 +
|
|
7
|
+
* rememberRecentMessage 写入。
|
|
8
|
+
* - 顶层(sendImweText / sendImweMarkdown / sendImweMedia / sendImweTypingSignal)
|
|
9
|
+
* 保留既有签名({ ..., accountId?, cfg }),实现为 resolveImweAccount + resolveFromId +
|
|
10
|
+
* 账号未配置守卫 → 构造 OutboundContext → 委托基础层。顶层不再直接调 api-client.ts。
|
|
11
|
+
*
|
|
12
|
+
* fromId(机器人的 botAcctId)获取策略:
|
|
13
|
+
* 1. 优先从 bot-info-cache 读取(gateway 启动时 startAccount 已通过 getMe 缓存)
|
|
14
|
+
* 2. 缓存未命中时(如 gateway 未启动的主动投递场景),自动调用 getMe 获取并缓存
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import type { OpenClawConfig } from 'openclaw/plugin-sdk/config-runtime';
|
|
19
|
+
import { resolveStateDir } from 'openclaw/plugin-sdk/state-paths';
|
|
20
|
+
import { z } from 'openclaw/plugin-sdk/zod';
|
|
21
|
+
import { postProto } from './api-client.js';
|
|
22
|
+
import {
|
|
23
|
+
buildChatEnvelopeBytes,
|
|
24
|
+
buildMarkdownEnvelopeBytes,
|
|
25
|
+
buildMediaEnvelopeBytes,
|
|
26
|
+
buildMsgReadEnvelopeBytes,
|
|
27
|
+
encodeSingleChatReqPacket,
|
|
28
|
+
encodeTypingSignalRequest,
|
|
29
|
+
decodeSendResponse,
|
|
30
|
+
genClientMsgId,
|
|
31
|
+
} from './proto/send.codec.js';
|
|
32
|
+
import { resolveImweAccount } from './accounts.js';
|
|
33
|
+
import { getBotInfo, setBotInfo } from './bot-info-cache.js';
|
|
34
|
+
import { uploadEncryptedFile } from './file-transfer/index.js';
|
|
35
|
+
import { inferMediaType } from './media-utils.js';
|
|
36
|
+
import { init as initRecentMessageCache, rememberRecentMessage } from './recent-message-cache.js';
|
|
37
|
+
import type { E2eeService } from './e2ee/index.js';
|
|
38
|
+
import { DeviceNotMatchError } from './e2ee/errors.js';
|
|
39
|
+
import type { ImweSendResponse } from './types.js';
|
|
40
|
+
import { extractMarkdownDigest } from './markdown-detect.js';
|
|
41
|
+
|
|
42
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
43
|
+
// OutboundContext — 基础层入参
|
|
44
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
/** 基础层发送所需的已解析上下文 */
|
|
47
|
+
export type OutboundContext = {
|
|
48
|
+
apiBaseUrl: string;
|
|
49
|
+
auth: { apiKey: string; apiSecret: string };
|
|
50
|
+
accountId: string;
|
|
51
|
+
fromId: string;
|
|
52
|
+
e2eeService?: E2eeService;
|
|
53
|
+
log?: { info?: (...args: unknown[]) => void; warn?: (...args: unknown[]) => void };
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
57
|
+
// 内部工具
|
|
58
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
function buildCachedOutboundBody(text: string | undefined, fallback: string): string {
|
|
61
|
+
const normalized = text?.trim();
|
|
62
|
+
return normalized || fallback;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function ensureRecentMessageCachePersistence(): void {
|
|
66
|
+
// 主动投递可能发生在 gateway 未启动时,这里 lazy init 确保出站缓存也能落盘。
|
|
67
|
+
initRecentMessageCache(resolveStateDir());
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 获取机器人的 fromId(botAcctId)。
|
|
72
|
+
* 优先从缓存读取,未命中时 lazy 调用 getMe 获取并缓存。
|
|
73
|
+
* getMe 失败时返回空字符串,让平台尝试从 ApiKey 解析发送方身份。
|
|
74
|
+
*/
|
|
75
|
+
async function resolveFromId(
|
|
76
|
+
accountId: string,
|
|
77
|
+
apiBaseUrl: string,
|
|
78
|
+
auth: { apiKey: string; apiSecret: string },
|
|
79
|
+
): Promise<string> {
|
|
80
|
+
let botInfo = getBotInfo(accountId);
|
|
81
|
+
if (!botInfo) {
|
|
82
|
+
try {
|
|
83
|
+
const { getMe } = await import('./api-client.js');
|
|
84
|
+
botInfo = await getMe(apiBaseUrl, auth);
|
|
85
|
+
setBotInfo(accountId, botInfo);
|
|
86
|
+
} catch {
|
|
87
|
+
return '';
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return botInfo?.botAcctId ?? '';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 从 postProto 抛出的错误中检测 E2EE_DEVICE_NOT_MATCH 并转换为 DeviceNotMatchError。
|
|
95
|
+
* 服务端返回非 200 时,响应体为 JSON:{ code: "IM.IM_COM.E2EE_DEVICE_NOT_MATCH", data: {...} }
|
|
96
|
+
* postProto 会把响应文本拼入 Error.message,这里从中提取 JSON 并解析。
|
|
97
|
+
*/
|
|
98
|
+
function maybeThrowDeviceNotMatch(err: unknown): never {
|
|
99
|
+
if (!(err instanceof Error)) throw err;
|
|
100
|
+
|
|
101
|
+
const msg = err.message;
|
|
102
|
+
// postProto 格式:"imwe API /path 返回 {status}: {responseText}"
|
|
103
|
+
if (!msg.includes('E2EE_DEVICE_NOT_MATCH')) throw err;
|
|
104
|
+
|
|
105
|
+
// 尝试从错误消息中提取 JSON 部分
|
|
106
|
+
const jsonStart = msg.indexOf('{');
|
|
107
|
+
if (jsonStart < 0) throw err;
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const jsonStr = msg.slice(jsonStart);
|
|
111
|
+
const parsed = JSON.parse(jsonStr) as {
|
|
112
|
+
code?: string;
|
|
113
|
+
data?: {
|
|
114
|
+
extraDevices?: Array<{ imAcctId: string; e2eeId: string }>;
|
|
115
|
+
missDevices?: Array<{ imAcctId: string; e2eeId: string }>;
|
|
116
|
+
extraImAcctIds?: string[];
|
|
117
|
+
missImAcctIds?: string[];
|
|
118
|
+
};
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
if (parsed.code?.includes('E2EE_DEVICE_NOT_MATCH') && parsed.data) {
|
|
122
|
+
throw new DeviceNotMatchError('服务端返回 E2EE_DEVICE_NOT_MATCH', {
|
|
123
|
+
extraDevices: parsed.data.extraDevices,
|
|
124
|
+
missDevices: parsed.data.missDevices,
|
|
125
|
+
extraImAcctIds: parsed.data.extraImAcctIds,
|
|
126
|
+
missImAcctIds: parsed.data.missImAcctIds,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
} catch (parseErr) {
|
|
130
|
+
// 如果 parseErr 已经是 DeviceNotMatchError,直接抛出
|
|
131
|
+
if (parseErr instanceof DeviceNotMatchError) throw parseErr;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
throw err;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 通过 E2EE 加密 + 发送消息的统一内部流程。
|
|
139
|
+
* 包含 DEVICE_NOT_MATCH 单次重试逻辑。
|
|
140
|
+
*
|
|
141
|
+
* @param outbound 已解析的出站上下文
|
|
142
|
+
* @param to 接收方 userId
|
|
143
|
+
* @param envelopeBytes 明文 envelope bytes(第 4 层)
|
|
144
|
+
* @param clientMsgId 客户端消息 ID
|
|
145
|
+
* @returns 发送结果
|
|
146
|
+
*/
|
|
147
|
+
async function encryptAndSend(
|
|
148
|
+
outbound: OutboundContext,
|
|
149
|
+
to: string,
|
|
150
|
+
envelopeBytes: Uint8Array,
|
|
151
|
+
clientMsgId: string,
|
|
152
|
+
): Promise<ImweSendResponse> {
|
|
153
|
+
const { apiBaseUrl, auth, fromId, e2eeService } = outbound;
|
|
154
|
+
const prefix = `[send][${outbound.accountId}]`;
|
|
155
|
+
|
|
156
|
+
// E2EE 加密
|
|
157
|
+
outbound.log?.info?.(
|
|
158
|
+
`${prefix}: encryptAndSend E2EE 加密前, to=${to}, envelopeBytes.length=${envelopeBytes.length}`,
|
|
159
|
+
);
|
|
160
|
+
const { recipientsBytes, senderE2eeId } = await e2eeService!.encryptSingle({
|
|
161
|
+
to,
|
|
162
|
+
plainBytes: envelopeBytes,
|
|
163
|
+
});
|
|
164
|
+
outbound.log?.info?.(
|
|
165
|
+
`${prefix}: encryptAndSend E2EE 加密完成, recipientsBytes.length=${recipientsBytes.length}, senderE2eeId=${senderE2eeId}`,
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
// 包装为完整 PbPacket
|
|
169
|
+
const packetBytes = await encodeSingleChatReqPacket({
|
|
170
|
+
fromId,
|
|
171
|
+
to,
|
|
172
|
+
clientMsgId,
|
|
173
|
+
envelopeBytes: recipientsBytes,
|
|
174
|
+
e2eeFlag: true,
|
|
175
|
+
fromE2eeId: senderE2eeId,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// 发送(检测 E2EE_DEVICE_NOT_MATCH 并转换为 DeviceNotMatchError)
|
|
179
|
+
let responseBytes: Uint8Array;
|
|
180
|
+
try {
|
|
181
|
+
outbound.log?.info?.(
|
|
182
|
+
`${prefix}: encryptAndSend 发送请求, url=/api/im/open/bot/sendMessage, to=${to}, clientMsgId=${clientMsgId}`,
|
|
183
|
+
);
|
|
184
|
+
responseBytes = await postProto(apiBaseUrl, '/api/im/open/bot/sendMessage', packetBytes, auth);
|
|
185
|
+
outbound.log?.info?.(
|
|
186
|
+
`${prefix}: encryptAndSend 发送成功, to=${to}, clientMsgId=${clientMsgId}, responseBytes.length=${responseBytes.length}`,
|
|
187
|
+
);
|
|
188
|
+
} catch (err) {
|
|
189
|
+
maybeThrowDeviceNotMatch(err);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return { ...(await decodeSendResponse(responseBytes)), clientMsgId };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* E2EE 加密 + 发送,含 DEVICE_NOT_MATCH 单次重试。
|
|
197
|
+
* 捕获 DeviceNotMatchError → handleDeviceNotMatch → 用同 clientMsgId 重新 encryptSingle + send 恰好一次。
|
|
198
|
+
* 两次都失败上抛。
|
|
199
|
+
*/
|
|
200
|
+
async function encryptAndSendWithRetry(
|
|
201
|
+
outbound: OutboundContext,
|
|
202
|
+
to: string,
|
|
203
|
+
envelopeBytes: Uint8Array,
|
|
204
|
+
clientMsgId: string,
|
|
205
|
+
): Promise<ImweSendResponse> {
|
|
206
|
+
const prefix = `[send][${outbound.accountId}]`;
|
|
207
|
+
outbound.log?.info?.(
|
|
208
|
+
`${prefix}: encryptAndSendWithRetry 首次发送, to=${to}, clientMsgId=${clientMsgId}`,
|
|
209
|
+
);
|
|
210
|
+
try {
|
|
211
|
+
return await encryptAndSend(outbound, to, envelopeBytes, clientMsgId);
|
|
212
|
+
} catch (err) {
|
|
213
|
+
if (err instanceof DeviceNotMatchError && outbound.e2eeService) {
|
|
214
|
+
// DEVICE_NOT_MATCH 重试:handleDeviceNotMatch → 用同 clientMsgId 重发一次
|
|
215
|
+
outbound.log?.info?.(
|
|
216
|
+
`${prefix}: DEVICE_NOT_MATCH,执行单次重试, to=${to}, clientMsgId=${clientMsgId}, errData=${JSON.stringify(err.data)}`,
|
|
217
|
+
);
|
|
218
|
+
await outbound.e2eeService.handleDeviceNotMatch(err.data);
|
|
219
|
+
const result = await encryptAndSend(outbound, to, envelopeBytes, clientMsgId);
|
|
220
|
+
outbound.log?.info?.(
|
|
221
|
+
`${prefix}: DEVICE_NOT_MATCH 重试成功, to=${to}, clientMsgId=${clientMsgId}`,
|
|
222
|
+
);
|
|
223
|
+
return result;
|
|
224
|
+
}
|
|
225
|
+
throw err;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
230
|
+
// 基础层:sendTextToPeer / sendMarkdownToPeer / sendMediaToPeer / sendTypingSignalToPeer
|
|
231
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* 基础层:向对端发送文本消息。
|
|
235
|
+
* 有 e2eeService 时走 buildChatEnvelopeBytes → encryptSingle → encodeSingleChatReqPacket(e2eeFlag=true)。
|
|
236
|
+
* 无 e2eeService 时走明文路径(兼容 E2EE 未初始化场景)。
|
|
237
|
+
* rememberRecentMessage 仅在基础层写入。
|
|
238
|
+
*/
|
|
239
|
+
export async function sendTextToPeer(
|
|
240
|
+
outbound: OutboundContext,
|
|
241
|
+
params: { to: string; text: string; replyToId?: string; clientMsgId?: string },
|
|
242
|
+
): Promise<ImweSendResponse> {
|
|
243
|
+
const clientMsgId = params.clientMsgId ?? genClientMsgId();
|
|
244
|
+
const { apiBaseUrl, auth, fromId, e2eeService } = outbound;
|
|
245
|
+
|
|
246
|
+
let result: ImweSendResponse;
|
|
247
|
+
|
|
248
|
+
if (e2eeService) {
|
|
249
|
+
// E2EE 路径:构造 envelope → 加密 → 发送(含 DEVICE_NOT_MATCH 重试)
|
|
250
|
+
const envelopeBytes = await buildChatEnvelopeBytes(params.text, {
|
|
251
|
+
referenceClientMsgId: params.replyToId,
|
|
252
|
+
});
|
|
253
|
+
result = await encryptAndSendWithRetry(outbound, params.to, envelopeBytes, clientMsgId);
|
|
254
|
+
} else {
|
|
255
|
+
// 明文路径(E2EE 未初始化时的兼容回退)
|
|
256
|
+
const { sendTextMessage } = await import('./api-client.js');
|
|
257
|
+
result = await sendTextMessage(apiBaseUrl, auth, fromId, params.to, params.text, {
|
|
258
|
+
replyToId: params.replyToId,
|
|
259
|
+
clientMsgId,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// 成功时写入 recent-message-cache
|
|
264
|
+
if (result.ok && result.clientMsgId) {
|
|
265
|
+
ensureRecentMessageCachePersistence();
|
|
266
|
+
rememberRecentMessage(params.to, {
|
|
267
|
+
msgId: result.clientMsgId,
|
|
268
|
+
body: params.text,
|
|
269
|
+
senderId: outbound.fromId,
|
|
270
|
+
timestampMs: Date.now(),
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return result;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* 基础层:向对端发送 markdown 消息。
|
|
279
|
+
* 有 e2eeService 时走 buildMarkdownEnvelopeBytes → encryptSingle → encodeSingleChatReqPacket(e2eeFlag=true)。
|
|
280
|
+
* 无 e2eeService 时走明文路径(兼容 E2EE 未初始化场景)。
|
|
281
|
+
* rememberRecentMessage 仅在基础层写入。
|
|
282
|
+
*/
|
|
283
|
+
export async function sendMarkdownToPeer(
|
|
284
|
+
outbound: OutboundContext,
|
|
285
|
+
params: {
|
|
286
|
+
to: string;
|
|
287
|
+
markdown: string;
|
|
288
|
+
digest?: string;
|
|
289
|
+
replyToId?: string;
|
|
290
|
+
clientMsgId?: string;
|
|
291
|
+
},
|
|
292
|
+
): Promise<ImweSendResponse> {
|
|
293
|
+
const clientMsgId = params.clientMsgId ?? genClientMsgId();
|
|
294
|
+
const { apiBaseUrl, auth, fromId, e2eeService } = outbound;
|
|
295
|
+
|
|
296
|
+
let result: ImweSendResponse;
|
|
297
|
+
|
|
298
|
+
if (e2eeService) {
|
|
299
|
+
// E2EE 路径
|
|
300
|
+
const envelopeBytes = await buildMarkdownEnvelopeBytes({
|
|
301
|
+
markdown: params.markdown,
|
|
302
|
+
digest: params.digest,
|
|
303
|
+
});
|
|
304
|
+
result = await encryptAndSendWithRetry(outbound, params.to, envelopeBytes, clientMsgId);
|
|
305
|
+
} else {
|
|
306
|
+
// 明文路径(E2EE 未初始化时的兼容回退)
|
|
307
|
+
const { sendMarkdownMessage } = await import('./api-client.js');
|
|
308
|
+
result = await sendMarkdownMessage(apiBaseUrl, auth, {
|
|
309
|
+
fromId,
|
|
310
|
+
to: params.to,
|
|
311
|
+
markdown: params.markdown,
|
|
312
|
+
digest: params.digest,
|
|
313
|
+
clientMsgId,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// 成功时写入 recent-message-cache
|
|
318
|
+
if (result.ok && result.clientMsgId) {
|
|
319
|
+
ensureRecentMessageCachePersistence();
|
|
320
|
+
const preview = params.digest || params.markdown.slice(0, 120) || '[markdown]';
|
|
321
|
+
rememberRecentMessage(params.to, {
|
|
322
|
+
msgId: result.clientMsgId,
|
|
323
|
+
body: preview,
|
|
324
|
+
senderId: outbound.fromId,
|
|
325
|
+
timestampMs: Date.now(),
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return result;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* 基础层:向对端发送多媒体消息。
|
|
334
|
+
* 有 e2eeService 时走 buildMediaEnvelopeBytes → encryptSingle → encodeSingleChatReqPacket(e2eeFlag=true)。
|
|
335
|
+
* 无 e2eeService 时走明文路径(兼容 E2EE 未初始化场景)。
|
|
336
|
+
* rememberRecentMessage 仅在基础层写入。
|
|
337
|
+
*
|
|
338
|
+
* 注意:媒体上传(uploadEncryptedFile)由调用方在调用前完成,
|
|
339
|
+
* 基础层只负责已上传媒体的 envelope 构造 + 加密 + 发送。
|
|
340
|
+
*/
|
|
341
|
+
export async function sendMediaToPeer(
|
|
342
|
+
outbound: OutboundContext,
|
|
343
|
+
params: {
|
|
344
|
+
to: string;
|
|
345
|
+
url: string;
|
|
346
|
+
mediaType: 'image' | 'video' | 'audio' | 'file';
|
|
347
|
+
fileName?: string;
|
|
348
|
+
fileSize?: number;
|
|
349
|
+
mimeType?: string;
|
|
350
|
+
width?: number;
|
|
351
|
+
height?: number;
|
|
352
|
+
caption?: string;
|
|
353
|
+
blurHash?: string;
|
|
354
|
+
clientMsgId?: string;
|
|
355
|
+
/** E2EE 文件加密密钥(32 字节) */
|
|
356
|
+
fileKey?: Uint8Array;
|
|
357
|
+
/** E2EE 文件加密 IV(16 字节) */
|
|
358
|
+
fileIv?: Uint8Array;
|
|
359
|
+
/** SHA256(plaintext) 小写十六进制 */
|
|
360
|
+
fileDigest?: string;
|
|
361
|
+
/** 明文长度(字节) */
|
|
362
|
+
plaintextLength?: number;
|
|
363
|
+
/** 上传会话过期时间(毫秒时间戳) */
|
|
364
|
+
expireTime?: number;
|
|
365
|
+
/** 操作凭证(batchCompleteChunks 返回) */
|
|
366
|
+
opCreds?: string;
|
|
367
|
+
/** 密文长度(字节,AES-CTR 下等于 plaintextLength) */
|
|
368
|
+
length?: number;
|
|
369
|
+
},
|
|
370
|
+
): Promise<ImweSendResponse> {
|
|
371
|
+
const clientMsgId = params.clientMsgId ?? genClientMsgId();
|
|
372
|
+
const { apiBaseUrl, auth, fromId, e2eeService } = outbound;
|
|
373
|
+
|
|
374
|
+
let result: ImweSendResponse;
|
|
375
|
+
|
|
376
|
+
if (e2eeService) {
|
|
377
|
+
// E2EE 路径
|
|
378
|
+
const envelopeBytes = await buildMediaEnvelopeBytes({
|
|
379
|
+
to: params.to,
|
|
380
|
+
fromId,
|
|
381
|
+
url: params.url,
|
|
382
|
+
mediaType: params.mediaType,
|
|
383
|
+
fileName: params.fileName,
|
|
384
|
+
fileSize: params.fileSize,
|
|
385
|
+
mimeType: params.mimeType,
|
|
386
|
+
width: params.width,
|
|
387
|
+
height: params.height,
|
|
388
|
+
caption: params.caption,
|
|
389
|
+
blurHash: params.blurHash,
|
|
390
|
+
fileKey: params.fileKey,
|
|
391
|
+
fileIv: params.fileIv,
|
|
392
|
+
fileDigest: params.fileDigest,
|
|
393
|
+
plaintextLength: params.plaintextLength,
|
|
394
|
+
expireTime: params.expireTime,
|
|
395
|
+
opCreds: params.opCreds,
|
|
396
|
+
length: params.length,
|
|
397
|
+
clientMsgId,
|
|
398
|
+
});
|
|
399
|
+
result = await encryptAndSendWithRetry(outbound, params.to, envelopeBytes, clientMsgId);
|
|
400
|
+
} else {
|
|
401
|
+
// 明文路径(E2EE 未初始化时的兼容回退)
|
|
402
|
+
const { sendMediaMessage } = await import('./api-client.js');
|
|
403
|
+
result = await sendMediaMessage(apiBaseUrl, auth, {
|
|
404
|
+
to: params.to,
|
|
405
|
+
fromId,
|
|
406
|
+
url: params.url,
|
|
407
|
+
mediaType: params.mediaType,
|
|
408
|
+
fileName: params.fileName,
|
|
409
|
+
fileSize: params.fileSize,
|
|
410
|
+
mimeType: params.mimeType,
|
|
411
|
+
width: params.width,
|
|
412
|
+
height: params.height,
|
|
413
|
+
caption: params.caption,
|
|
414
|
+
blurHash: params.blurHash,
|
|
415
|
+
clientMsgId,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// 成功时写入 recent-message-cache
|
|
420
|
+
if (result.ok && result.clientMsgId) {
|
|
421
|
+
ensureRecentMessageCachePersistence();
|
|
422
|
+
rememberRecentMessage(params.to, {
|
|
423
|
+
msgId: result.clientMsgId,
|
|
424
|
+
body: buildCachedOutboundBody(params.caption, '[media]'),
|
|
425
|
+
senderId: outbound.fromId,
|
|
426
|
+
timestampMs: Date.now(),
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return result;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* 基础层:向对端发送 typing signal。
|
|
435
|
+
* typing signal 按 R14 保持明文(不走 E2EE 加密),直接 encodeTypingSignalRequest + postProto。
|
|
436
|
+
*/
|
|
437
|
+
export async function sendTypingSignalToPeer(
|
|
438
|
+
outbound: OutboundContext,
|
|
439
|
+
params: { to: string; status: 1 | 2 },
|
|
440
|
+
): Promise<ImweSendResponse> {
|
|
441
|
+
const { apiBaseUrl, auth, fromId } = outbound;
|
|
442
|
+
const botImAcctId = fromId; // botAcctId 同时用作 botImAcctId
|
|
443
|
+
|
|
444
|
+
// typing signal 保持明文,不走 E2EE
|
|
445
|
+
const requestBody = await encodeTypingSignalRequest(
|
|
446
|
+
params.to,
|
|
447
|
+
params.status,
|
|
448
|
+
botImAcctId,
|
|
449
|
+
fromId,
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
const responseBytes = await postProto(
|
|
453
|
+
apiBaseUrl,
|
|
454
|
+
'/api/im/open/bot/sendMessage',
|
|
455
|
+
requestBody,
|
|
456
|
+
auth,
|
|
457
|
+
);
|
|
458
|
+
|
|
459
|
+
return decodeSendResponse(responseBytes);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* 基础层:向对端发送已读操作消息。
|
|
464
|
+
* 明文发送(e2eeFlag=false),envelopeType=2,与 typing signal 一致。
|
|
465
|
+
* 发送失败仅记录 warn 日志,不抛出异常(已读回执是辅助功能,丢失可接受)。
|
|
466
|
+
*/
|
|
467
|
+
export async function sendMsgReadToPeer(
|
|
468
|
+
outbound: OutboundContext,
|
|
469
|
+
params: { to: string; targetClientMsgIds: string[]; readStamp: number },
|
|
470
|
+
): Promise<void> {
|
|
471
|
+
const { apiBaseUrl, auth, fromId, log } = outbound;
|
|
472
|
+
const prefix = `[send][${outbound.accountId}]`;
|
|
473
|
+
|
|
474
|
+
try {
|
|
475
|
+
const envelopeBytes = await buildMsgReadEnvelopeBytes({
|
|
476
|
+
targetClientMsgIds: params.targetClientMsgIds,
|
|
477
|
+
readStamp: params.readStamp,
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
const packetBytes = await encodeSingleChatReqPacket({
|
|
481
|
+
fromId,
|
|
482
|
+
to: params.to,
|
|
483
|
+
clientMsgId: genClientMsgId(),
|
|
484
|
+
envelopeBytes,
|
|
485
|
+
e2eeFlag: false,
|
|
486
|
+
envelopeType: 2,
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
await postProto(apiBaseUrl, '/api/im/open/bot/sendMessage', packetBytes, auth);
|
|
490
|
+
log?.info?.(
|
|
491
|
+
`${prefix}: msgRead 发送成功, to=${params.to}, clientMsgIds=${params.targetClientMsgIds.join(',')}`,
|
|
492
|
+
);
|
|
493
|
+
} catch (err) {
|
|
494
|
+
log?.warn?.(`${prefix}: msgRead 发送失败(不影响主流程): ${String(err)}`);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
499
|
+
// 顶层:sendImweText / sendImweMarkdown / sendImweMedia / sendImweTypingSignal
|
|
500
|
+
// 保留既有签名,实现改为 resolveImweAccount + resolveFromId + 构造 OutboundContext → 委托基础层
|
|
501
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* 构造 OutboundContext 的内部辅助。
|
|
505
|
+
* 解析账号 → 校验配置 → resolveFromId → 返回 OutboundContext 或错误。
|
|
506
|
+
*/
|
|
507
|
+
async function buildOutboundContext(params: {
|
|
508
|
+
accountId?: string;
|
|
509
|
+
cfg: OpenClawConfig;
|
|
510
|
+
log?: { info?: (...args: unknown[]) => void; warn?: (...args: unknown[]) => void };
|
|
511
|
+
}): Promise<{ ctx?: OutboundContext; error?: string }> {
|
|
512
|
+
const account = resolveImweAccount({ cfg: params.cfg, accountId: params.accountId });
|
|
513
|
+
|
|
514
|
+
if (!account.appKey || !account.appSecret) {
|
|
515
|
+
return { error: 'imwe AppKey/AppSecret 未配置,请先运行 `openclaw setup imwe`。' };
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
if (!account.apiBaseUrl) {
|
|
519
|
+
return { error: 'imwe apiBaseUrl 未配置。' };
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const auth = { apiKey: account.appKey, apiSecret: account.appSecret };
|
|
523
|
+
const fromId = await resolveFromId(account.accountId, account.apiBaseUrl, auth);
|
|
524
|
+
|
|
525
|
+
return {
|
|
526
|
+
ctx: {
|
|
527
|
+
apiBaseUrl: account.apiBaseUrl,
|
|
528
|
+
auth,
|
|
529
|
+
accountId: account.accountId,
|
|
530
|
+
fromId,
|
|
531
|
+
// e2eeService 由 channel.ts 在 startAccount 时注入到模块级缓存,
|
|
532
|
+
// 顶层入口通过 getE2eeServiceForAccount 获取(后续 9.2 任务实现)。
|
|
533
|
+
// 当前阶段先从全局缓存获取,若无则不走 E2EE。
|
|
534
|
+
e2eeService: getE2eeServiceForAccount(account.accountId),
|
|
535
|
+
log: params.log,
|
|
536
|
+
},
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// ── E2EE Service 模块级缓存(由 channel.ts startAccount 注入) ──────────────
|
|
541
|
+
|
|
542
|
+
const e2eeServiceCache = new Map<string, E2eeService>();
|
|
543
|
+
|
|
544
|
+
/** 注册 E2EE Service 实例(由 channel.ts startAccount 调用) */
|
|
545
|
+
export function registerE2eeService(accountId: string, service: E2eeService): void {
|
|
546
|
+
e2eeServiceCache.set(accountId, service);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** 注销 E2EE Service 实例(由 channel.ts stopAccount 调用) */
|
|
550
|
+
export function unregisterE2eeService(accountId: string): void {
|
|
551
|
+
e2eeServiceCache.delete(accountId);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** 获取已注册的 E2EE Service 实例 */
|
|
555
|
+
function getE2eeServiceForAccount(accountId: string): E2eeService | undefined {
|
|
556
|
+
return e2eeServiceCache.get(accountId);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
export async function sendImweText(params: {
|
|
560
|
+
to: string;
|
|
561
|
+
text: string;
|
|
562
|
+
accountId?: string;
|
|
563
|
+
cfg: OpenClawConfig;
|
|
564
|
+
replyToId?: string;
|
|
565
|
+
}): Promise<{ ok: boolean; messageId?: string; error?: string }> {
|
|
566
|
+
const { ctx, error } = await buildOutboundContext({
|
|
567
|
+
accountId: params.accountId,
|
|
568
|
+
cfg: params.cfg,
|
|
569
|
+
});
|
|
570
|
+
if (!ctx) return { ok: false, error };
|
|
571
|
+
|
|
572
|
+
try {
|
|
573
|
+
const result = await sendTextToPeer(ctx, {
|
|
574
|
+
to: params.to,
|
|
575
|
+
text: params.text,
|
|
576
|
+
replyToId: params.replyToId,
|
|
577
|
+
});
|
|
578
|
+
return { ok: result.ok, messageId: result.msgId, error: result.error };
|
|
579
|
+
} catch (err) {
|
|
580
|
+
return { ok: false, error: String(err) };
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* 发送 imwe typing signal 的统一入口。
|
|
586
|
+
*
|
|
587
|
+
* typing signal 发送失败不阻断主流程(由 createTypingCallbacks 的错误处理机制管理)。
|
|
588
|
+
*/
|
|
589
|
+
export async function sendImweTypingSignal(params: {
|
|
590
|
+
to: string;
|
|
591
|
+
status: 1 | 2;
|
|
592
|
+
accountId?: string;
|
|
593
|
+
cfg: OpenClawConfig;
|
|
594
|
+
}): Promise<{ ok: boolean; error?: string }> {
|
|
595
|
+
const { ctx, error } = await buildOutboundContext({
|
|
596
|
+
accountId: params.accountId,
|
|
597
|
+
cfg: params.cfg,
|
|
598
|
+
});
|
|
599
|
+
if (!ctx) return { ok: false, error };
|
|
600
|
+
|
|
601
|
+
try {
|
|
602
|
+
const result = await sendTypingSignalToPeer(ctx, {
|
|
603
|
+
to: params.to,
|
|
604
|
+
status: params.status,
|
|
605
|
+
});
|
|
606
|
+
return { ok: result.ok, error: result.error };
|
|
607
|
+
} catch (err) {
|
|
608
|
+
return { ok: false, error: String(err) };
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* 发送已读操作消息的统一入口。
|
|
614
|
+
* 在插件回复用户消息成功后调用,标记用户原始消息为已读。
|
|
615
|
+
*/
|
|
616
|
+
export async function sendImweMsgRead(params: {
|
|
617
|
+
to: string;
|
|
618
|
+
targetClientMsgIds: string[];
|
|
619
|
+
readStamp: number;
|
|
620
|
+
accountId?: string;
|
|
621
|
+
cfg: OpenClawConfig;
|
|
622
|
+
}): Promise<void> {
|
|
623
|
+
const { ctx, error } = await buildOutboundContext({
|
|
624
|
+
accountId: params.accountId,
|
|
625
|
+
cfg: params.cfg,
|
|
626
|
+
});
|
|
627
|
+
if (!ctx) {
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
await sendMsgReadToPeer(ctx, {
|
|
632
|
+
to: params.to,
|
|
633
|
+
targetClientMsgIds: params.targetClientMsgIds,
|
|
634
|
+
readStamp: params.readStamp,
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* 发送 imwe 多媒体消息的统一入口。
|
|
640
|
+
*
|
|
641
|
+
* 顶层负责:解析账号 + 上传媒体 + 委托基础层发送。
|
|
642
|
+
*/
|
|
643
|
+
export async function sendImweMedia(params: {
|
|
644
|
+
to: string;
|
|
645
|
+
mediaUrl: string;
|
|
646
|
+
mediaType?: 'image' | 'video' | 'audio' | 'file';
|
|
647
|
+
fileName?: string;
|
|
648
|
+
caption?: string;
|
|
649
|
+
accountId?: string;
|
|
650
|
+
cfg: OpenClawConfig;
|
|
651
|
+
mediaAccess?: unknown;
|
|
652
|
+
mediaLocalRoots?: readonly string[];
|
|
653
|
+
mediaReadFile?: (filePath: string) => Promise<Buffer>;
|
|
654
|
+
/** 日志接口(可选,由调用方传入) */
|
|
655
|
+
log?: { info?: (...args: unknown[]) => void; warn?: (...args: unknown[]) => void };
|
|
656
|
+
}): Promise<{ ok: boolean; messageId?: string; error?: string }> {
|
|
657
|
+
const { ctx, error } = await buildOutboundContext({
|
|
658
|
+
accountId: params.accountId,
|
|
659
|
+
cfg: params.cfg,
|
|
660
|
+
log: params.log,
|
|
661
|
+
});
|
|
662
|
+
if (!ctx) return { ok: false, error };
|
|
663
|
+
|
|
664
|
+
const log = params.log;
|
|
665
|
+
const tag = `[${ctx.accountId}]`;
|
|
666
|
+
|
|
667
|
+
log?.info?.(
|
|
668
|
+
`${tag} sendImweMedia 开始: to=${params.to}, mediaUrl=${params.mediaUrl}, mediaType=${params.mediaType ?? '(auto)'}`,
|
|
669
|
+
);
|
|
670
|
+
|
|
671
|
+
// 推断 mediaType(未指定时从 URL 后缀推断,默认 file)
|
|
672
|
+
const mediaType = params.mediaType ?? inferMediaType(params.mediaUrl);
|
|
673
|
+
|
|
674
|
+
// 使用 E2EE 加密分片上传
|
|
675
|
+
let uploadResult;
|
|
676
|
+
try {
|
|
677
|
+
uploadResult = await uploadEncryptedFile(params.mediaUrl, ctx.auth, ctx.apiBaseUrl, {
|
|
678
|
+
imMainAccId: params.to,
|
|
679
|
+
config: {
|
|
680
|
+
stateDir: path.join(resolveStateDir(), 'channel-data', 'imwe', ctx.fromId, 'file-transfer'),
|
|
681
|
+
},
|
|
682
|
+
sendContext: {
|
|
683
|
+
to: params.to,
|
|
684
|
+
fromId: ctx.fromId,
|
|
685
|
+
mediaType,
|
|
686
|
+
caption: params.caption,
|
|
687
|
+
fileName: params.fileName,
|
|
688
|
+
},
|
|
689
|
+
mediaLocalRoots: params.mediaLocalRoots,
|
|
690
|
+
mediaReadFile: params.mediaReadFile,
|
|
691
|
+
log,
|
|
692
|
+
});
|
|
693
|
+
} catch (err) {
|
|
694
|
+
log?.warn?.(`${tag} sendImweMedia 媒体上传失败: ${String(err)}`);
|
|
695
|
+
return { ok: false, error: `媒体上传失败: ${String(err)}` };
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
log?.info?.(
|
|
699
|
+
`${tag} sendImweMedia 上传完成, 准备发送: uploadUrl=${uploadResult.url}, mediaType=${mediaType}, fileSize=${uploadResult.plaintextLength}`,
|
|
700
|
+
);
|
|
701
|
+
|
|
702
|
+
try {
|
|
703
|
+
const result = await sendMediaToPeer(ctx, {
|
|
704
|
+
to: params.to,
|
|
705
|
+
url: uploadResult.url,
|
|
706
|
+
mediaType,
|
|
707
|
+
fileKey: uploadResult.fileKey,
|
|
708
|
+
fileIv: uploadResult.fileIv,
|
|
709
|
+
fileDigest: uploadResult.fileDigest,
|
|
710
|
+
plaintextLength: uploadResult.plaintextLength,
|
|
711
|
+
fileName: params.fileName,
|
|
712
|
+
fileSize: uploadResult.plaintextLength,
|
|
713
|
+
mimeType: uploadResult.contentType,
|
|
714
|
+
caption: params.caption,
|
|
715
|
+
blurHash: uploadResult.blurHash,
|
|
716
|
+
width: uploadResult.width,
|
|
717
|
+
height: uploadResult.height,
|
|
718
|
+
expireTime: uploadResult.expireTime,
|
|
719
|
+
opCreds: uploadResult.opCreds,
|
|
720
|
+
length: uploadResult.plaintextLength,
|
|
721
|
+
});
|
|
722
|
+
log?.info?.(
|
|
723
|
+
`${tag} sendImweMedia 发送完成: ok=${result.ok}, msgId=${result.msgId ?? '(none)'}`,
|
|
724
|
+
);
|
|
725
|
+
return { ok: result.ok, messageId: result.msgId, error: result.error };
|
|
726
|
+
} catch (err) {
|
|
727
|
+
log?.warn?.(`${tag} sendImweMedia 发送失败: ${String(err)}`);
|
|
728
|
+
return { ok: false, error: String(err) };
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* sendImweMarkdown 的入参校验 schema。
|
|
734
|
+
*/
|
|
735
|
+
export const MarkdownPayloadSchema = z.object({
|
|
736
|
+
to: z.string().trim().min(1, 'to 不能为空'),
|
|
737
|
+
markdown: z.string().min(1, 'markdown 内容不能为空'),
|
|
738
|
+
digest: z.string().max(256).optional(),
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
export type MarkdownPayload = z.infer<typeof MarkdownPayloadSchema>;
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* 发送 imwe markdown 消息的统一入口。
|
|
745
|
+
*
|
|
746
|
+
* 与 sendImweText 行为对齐:
|
|
747
|
+
* - 解析账号配置
|
|
748
|
+
* - 获取 fromId
|
|
749
|
+
* - 委托基础层 sendMarkdownToPeer 发送
|
|
750
|
+
* - 支持 replyToId(与 sendImweText 对齐)
|
|
751
|
+
*/
|
|
752
|
+
export async function sendImweMarkdown(params: {
|
|
753
|
+
to: string;
|
|
754
|
+
markdown: string;
|
|
755
|
+
digest?: string;
|
|
756
|
+
replyToId?: string;
|
|
757
|
+
accountId?: string;
|
|
758
|
+
cfg: OpenClawConfig;
|
|
759
|
+
}): Promise<{ ok: boolean; messageId?: string; error?: string }> {
|
|
760
|
+
// Step 1: zod 校验
|
|
761
|
+
const parsed = MarkdownPayloadSchema.safeParse({
|
|
762
|
+
to: params.to,
|
|
763
|
+
markdown: params.markdown,
|
|
764
|
+
digest: params.digest,
|
|
765
|
+
});
|
|
766
|
+
if (!parsed.success) {
|
|
767
|
+
const firstIssue = parsed.error.issues[0];
|
|
768
|
+
const errorMsg = firstIssue?.message ?? '参数校验失败';
|
|
769
|
+
return { ok: false, error: errorMsg };
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// Step 2: 构造 OutboundContext
|
|
773
|
+
const { ctx, error } = await buildOutboundContext({
|
|
774
|
+
accountId: params.accountId,
|
|
775
|
+
cfg: params.cfg,
|
|
776
|
+
});
|
|
777
|
+
if (!ctx) return { ok: false, error };
|
|
778
|
+
|
|
779
|
+
// Step 3: 委托基础层
|
|
780
|
+
try {
|
|
781
|
+
const effectiveDigest = parsed.data.digest || extractMarkdownDigest(parsed.data.markdown);
|
|
782
|
+
const result = await sendMarkdownToPeer(ctx, {
|
|
783
|
+
to: parsed.data.to,
|
|
784
|
+
markdown: parsed.data.markdown,
|
|
785
|
+
replyToId: params.replyToId,
|
|
786
|
+
digest: effectiveDigest || undefined,
|
|
787
|
+
});
|
|
788
|
+
return { ok: result.ok, messageId: result.msgId, error: result.error };
|
|
789
|
+
} catch (err) {
|
|
790
|
+
return { ok: false, error: String(err) };
|
|
791
|
+
}
|
|
792
|
+
}
|