@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/monitor.ts
ADDED
|
@@ -0,0 +1,838 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* monitor.ts — 事件箱轮询消息监听器
|
|
3
|
+
*
|
|
4
|
+
* 职责(仅轮询,不负责初始化):
|
|
5
|
+
* - 接收 startAccount 传入的 botInfo(已通过 getMe 获取)
|
|
6
|
+
* - 通过短轮询定期向平台拉取新消息(PbBoxPullReq 事件箱模式)
|
|
7
|
+
* - 将收到的消息通过 channelRuntime.reply 交给 OpenClaw core 处理
|
|
8
|
+
* - 监听 abortSignal,在 gateway.stopAccount() 时优雅退出
|
|
9
|
+
*
|
|
10
|
+
* 初始化职责(由 channel.ts startAccount 负责):
|
|
11
|
+
* - 调用 getMe 获取 botInfo
|
|
12
|
+
* - 缓存 botInfo 到 bot-info-cache
|
|
13
|
+
* - 清理缓存(stopAccount)
|
|
14
|
+
*
|
|
15
|
+
* 事件箱拉取工作原理(对应文档 §二):
|
|
16
|
+
* ┌──────────────────────────────────────────────────────────────────┐
|
|
17
|
+
* │ POST /pullMessages { boxId, startSeq } │
|
|
18
|
+
* │ ↓ 平台立即返回(有消息返回列表,无消息 endSeq=0) │
|
|
19
|
+
* │ 处理消息 │
|
|
20
|
+
* │ milestone=true → 落库 endSeq,下次从 endSeq+1 开始 │
|
|
21
|
+
* │ hasMore=true → 立即继续拉取;否则等待 pollIntervalMs │
|
|
22
|
+
* └──────────────────────────────────────────────────────────────────┘
|
|
23
|
+
*
|
|
24
|
+
* ctx 字段命名规则:
|
|
25
|
+
* OpenClaw core 的 dispatchReplyWithBufferedBlockDispatcher 期望 PascalCase 字段名:
|
|
26
|
+
* Body / BodyForAgent / RawBody / CommandBody — 消息文本
|
|
27
|
+
* From / To / SenderId / SenderName — 发送方/接收方
|
|
28
|
+
* SessionKey / AccountId / ChatType — 路由和会话
|
|
29
|
+
* MessageSid / Provider / Surface — 消息标识和渠道
|
|
30
|
+
* 参考:extensions/twitch/src/monitor.ts、extensions/googlechat/src/monitor.ts
|
|
31
|
+
*
|
|
32
|
+
* 错误处理策略:
|
|
33
|
+
* - 401 Unauthorized:签名验证失败,停止监听并标记账号未配置
|
|
34
|
+
* - 其他错误:指数退避重试(1s → 2s → 4s → ... → 最多 30s)
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import * as fs from 'fs';
|
|
38
|
+
import * as path from 'path';
|
|
39
|
+
import type { ChannelGatewayContext } from 'openclaw/plugin-sdk/channel-contract';
|
|
40
|
+
import { createTypingCallbacks } from 'openclaw/plugin-sdk/channel-reply-pipeline';
|
|
41
|
+
import type { OpenClawConfig } from 'openclaw/plugin-sdk/config-runtime';
|
|
42
|
+
import { resolveStateDir } from 'openclaw/plugin-sdk/state-paths';
|
|
43
|
+
import { pullMessages } from './api-client.js';
|
|
44
|
+
import type { BotInfo, PullState } from './api-client.js';
|
|
45
|
+
import type { E2eeService } from './e2ee/index.js';
|
|
46
|
+
import { downloadAndDecryptFile } from './file-transfer/index.js';
|
|
47
|
+
import { isMarkdownContent, extractMarkdownDigest } from './markdown-detect.js';
|
|
48
|
+
import { uploadEncryptedFile } from './file-transfer/index.js';
|
|
49
|
+
import { inferMediaType, inferMediaTypeFromMime } from './media-utils.js';
|
|
50
|
+
import type { E2eeDecryptFn } from './proto/inbound.codec.js';
|
|
51
|
+
import { rememberRecentMessage, resolveQuotedMessage } from './recent-message-cache.js';
|
|
52
|
+
import { sendTextToPeer, sendMarkdownToPeer, sendMediaToPeer, sendMsgReadToPeer } from './send.js';
|
|
53
|
+
import type { OutboundContext } from './send.js';
|
|
54
|
+
import type { ImweInboundMessage, ResolvedImweAccount } from './types.js';
|
|
55
|
+
|
|
56
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
57
|
+
// channelRuntime 的最小类型定义
|
|
58
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
59
|
+
// ChannelRuntimeSurface 是 { [key: string]: unknown },运行时注入完整的
|
|
60
|
+
// createPluginRuntime().channel 对象。这里只声明插件实际用到的方法签名。
|
|
61
|
+
|
|
62
|
+
type ChannelReplyRuntime = {
|
|
63
|
+
/** 将入站消息分发给 AI agent,通过 deliver 回调发送回复 */
|
|
64
|
+
dispatchReplyWithBufferedBlockDispatcher: (params: {
|
|
65
|
+
ctx: Record<string, unknown>;
|
|
66
|
+
cfg: OpenClawConfig;
|
|
67
|
+
dispatcherOptions: {
|
|
68
|
+
deliver: (payload: unknown) => Promise<void>;
|
|
69
|
+
onError?: (err: unknown, info: { kind: string }) => void;
|
|
70
|
+
typingCallbacks?: {
|
|
71
|
+
onReplyStart: () => Promise<void>;
|
|
72
|
+
onIdle?: () => void;
|
|
73
|
+
onCleanup?: () => void;
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
}) => Promise<unknown>;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 格式化入站消息的 agent 信封(添加发送者、时间戳等上下文信息)。
|
|
80
|
+
* 返回格式化后的字符串,作为 Body 字段传给 dispatchReply。
|
|
81
|
+
*/
|
|
82
|
+
formatAgentEnvelope: (params: {
|
|
83
|
+
channel: string;
|
|
84
|
+
from: string;
|
|
85
|
+
timestamp?: number;
|
|
86
|
+
envelope: unknown;
|
|
87
|
+
body: string;
|
|
88
|
+
}) => string;
|
|
89
|
+
|
|
90
|
+
/** 获取信封格式化选项(从 cfg 中读取用户配置) */
|
|
91
|
+
resolveEnvelopeFormatOptions: (cfg: OpenClawConfig) => unknown;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 对入站 ctx 做最终处理(注入默认字段、规范化等)。
|
|
95
|
+
* 必须在传给 dispatchReply 之前调用。
|
|
96
|
+
*/
|
|
97
|
+
finalizeInboundContext: <T extends Record<string, unknown>>(ctx: T) => T;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
type ChannelRoutingRuntime = {
|
|
101
|
+
/** 根据渠道、账号、对端信息解析 agent 路由(agentId + sessionKey) */
|
|
102
|
+
resolveAgentRoute: (params: {
|
|
103
|
+
cfg: OpenClawConfig;
|
|
104
|
+
channel: string;
|
|
105
|
+
accountId: string;
|
|
106
|
+
peer: { kind: string; id: string };
|
|
107
|
+
}) => { agentId: string; sessionKey: string; accountId: string };
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
type ChannelSessionRuntime = {
|
|
111
|
+
/** 解析会话存储路径 */
|
|
112
|
+
resolveStorePath: (store: string | undefined, params: { agentId: string }) => string;
|
|
113
|
+
/** 记录入站会话元数据(发送者信息、时间戳等) */
|
|
114
|
+
recordInboundSession: (params: {
|
|
115
|
+
storePath: string;
|
|
116
|
+
sessionKey: string;
|
|
117
|
+
ctx: Record<string, unknown>;
|
|
118
|
+
onRecordError?: (err: unknown) => void;
|
|
119
|
+
}) => Promise<void>;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/** 短轮询默认间隔(毫秒),也作为自适应策略的初始间隔 */
|
|
123
|
+
const DEFAULT_POLL_INTERVAL_MS = 10_000;
|
|
124
|
+
|
|
125
|
+
/** 自适应轮询参数:有消息时使用最小间隔,空闲时逐步退避到最大间隔 */
|
|
126
|
+
const MIN_POLL_INTERVAL_MS = 2_000;
|
|
127
|
+
const MAX_POLL_INTERVAL_MS = 15_000;
|
|
128
|
+
const POLL_GROW_FACTOR = 1.5;
|
|
129
|
+
|
|
130
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
131
|
+
// pullState 持久化(按账号隔离)
|
|
132
|
+
// 存储路径:~/.openclaw/channel-data/imwe/<accountId>/pull-state.json
|
|
133
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
function resolvePullStatePath(boxId: string): string {
|
|
136
|
+
const stateDir = resolveStateDir();
|
|
137
|
+
return path.join(stateDir, 'channel-data', 'imwe', boxId, 'pull-state.json');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** 从磁盘读取持久化的 pullState,不存在或解析失败时返回默认值 */
|
|
141
|
+
function loadPullState(boxId: string, log?: ChannelGatewayContext['log']): PullState {
|
|
142
|
+
const filePath = resolvePullStatePath(boxId);
|
|
143
|
+
try {
|
|
144
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
145
|
+
const data = JSON.parse(raw) as { nextStartSeq?: number };
|
|
146
|
+
if (typeof data.nextStartSeq === 'number' && data.nextStartSeq > 0) {
|
|
147
|
+
log?.info?.(`[${boxId}] 从 ${filePath} 恢复 pullState: nextStartSeq=${data.nextStartSeq}`);
|
|
148
|
+
return { nextStartSeq: data.nextStartSeq };
|
|
149
|
+
}
|
|
150
|
+
} catch {
|
|
151
|
+
// 文件不存在或解析失败,使用默认值
|
|
152
|
+
}
|
|
153
|
+
return { nextStartSeq: 0 };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 将 pullState 持久化到磁盘 */
|
|
157
|
+
function savePullState(boxId: string, state: PullState, log?: ChannelGatewayContext['log']): void {
|
|
158
|
+
const filePath = resolvePullStatePath(boxId);
|
|
159
|
+
try {
|
|
160
|
+
const dir = path.dirname(filePath);
|
|
161
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
162
|
+
fs.writeFileSync(filePath, JSON.stringify(state), 'utf-8');
|
|
163
|
+
} catch (err) {
|
|
164
|
+
log?.warn?.(`[${boxId}] pullState 持久化失败:${String(err)}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
169
|
+
// 主入口
|
|
170
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* 启动 imwe 账号的事件箱轮询循环。
|
|
174
|
+
*
|
|
175
|
+
* 前置条件(由 channel.ts startAccount 保证):
|
|
176
|
+
* - 凭证已检查(appKey/appSecret 非空)
|
|
177
|
+
* - getMe 已调用成功,botInfo 已缓存到 bot-info-cache
|
|
178
|
+
*
|
|
179
|
+
* @param ctx ChannelGatewayContext,包含账号信息、配置、日志、abortSignal 等
|
|
180
|
+
* @param botInfo getMe 返回的机器人信息(由 startAccount 传入)
|
|
181
|
+
*/
|
|
182
|
+
export async function monitorImweAccount(
|
|
183
|
+
ctx: ChannelGatewayContext<ResolvedImweAccount>,
|
|
184
|
+
botInfo: BotInfo,
|
|
185
|
+
opts?: { e2eeService?: E2eeService },
|
|
186
|
+
): Promise<void> {
|
|
187
|
+
const { account, cfg, abortSignal, log } = ctx;
|
|
188
|
+
const { apiBaseUrl, appKey, appSecret, accountId } = account;
|
|
189
|
+
|
|
190
|
+
const auth = { apiKey: appKey, apiSecret: appSecret };
|
|
191
|
+
const basePollIntervalMs = account.config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
192
|
+
|
|
193
|
+
// boxId = 机器人的 botAcctId,用于事件箱拉取
|
|
194
|
+
const boxId = botInfo.botAcctId;
|
|
195
|
+
|
|
196
|
+
// 构造 e2eeDecrypt 回调(若注入了 e2eeService)
|
|
197
|
+
const e2eeService = opts?.e2eeService;
|
|
198
|
+
|
|
199
|
+
// 一次性构造 OutboundContext,供 deliver 回调使用基础层发送
|
|
200
|
+
const outbound: OutboundContext = {
|
|
201
|
+
apiBaseUrl,
|
|
202
|
+
auth,
|
|
203
|
+
accountId,
|
|
204
|
+
fromId: botInfo.botAcctId,
|
|
205
|
+
e2eeService,
|
|
206
|
+
log,
|
|
207
|
+
};
|
|
208
|
+
const e2eeDecrypt: E2eeDecryptFn | undefined = e2eeService
|
|
209
|
+
? async (params) => {
|
|
210
|
+
const result = await e2eeService.decryptSingle(params);
|
|
211
|
+
// envelopeType=2 解密成功后由 service.dispatchOperation 内部处理
|
|
212
|
+
if (result !== null && params.envelopeType === 2) {
|
|
213
|
+
await e2eeService.dispatchOperation({
|
|
214
|
+
fromId: params.fromId,
|
|
215
|
+
fromE2eeId: params.fromE2eeId,
|
|
216
|
+
plainBytes: result,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
return result;
|
|
220
|
+
}
|
|
221
|
+
: undefined;
|
|
222
|
+
|
|
223
|
+
log?.info?.(
|
|
224
|
+
`[${accountId}] imwe 事件箱轮询启动,boxId=${boxId},初始间隔=${basePollIntervalMs}ms(自适应 ${MIN_POLL_INTERVAL_MS}~${MAX_POLL_INTERVAL_MS}ms)`,
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
// ── 步骤3:进入轮询循环 ────────────────────────────────────────────────────
|
|
228
|
+
let pullState: PullState = loadPullState(boxId, log);
|
|
229
|
+
let errorCount = 0;
|
|
230
|
+
let currentIntervalMs = basePollIntervalMs;
|
|
231
|
+
|
|
232
|
+
while (!abortSignal.aborted) {
|
|
233
|
+
try {
|
|
234
|
+
const result = await pullMessages(apiBaseUrl, auth, boxId, pullState, { e2eeDecrypt });
|
|
235
|
+
|
|
236
|
+
log?.info?.(`[${accountId}] imwe pullMessages 成功,内容: ${JSON.stringify(result)}`);
|
|
237
|
+
|
|
238
|
+
// 重置错误计数器
|
|
239
|
+
errorCount = 0;
|
|
240
|
+
pullState = result.state;
|
|
241
|
+
|
|
242
|
+
// 持久化 pullState,防止重启后重复处理已拉取的消息
|
|
243
|
+
savePullState(boxId, pullState, log);
|
|
244
|
+
|
|
245
|
+
// 测试只取第一条消息
|
|
246
|
+
// result.messages = result.messages.slice(0, 1);
|
|
247
|
+
|
|
248
|
+
for (const msg of result.messages) {
|
|
249
|
+
if (abortSignal.aborted) {
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
await handleInboundMessage(ctx, msg, botInfo, outbound);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// hasMore=true 时立即继续拉取,不等待间隔
|
|
256
|
+
if (result.hasMore && !abortSignal.aborted) {
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// 自适应间隔:有消息时恢复最小间隔,无消息时逐步退避
|
|
261
|
+
if (result.messages.length > 0) {
|
|
262
|
+
currentIntervalMs = MIN_POLL_INTERVAL_MS;
|
|
263
|
+
} else {
|
|
264
|
+
currentIntervalMs = Math.min(currentIntervalMs * POLL_GROW_FACTOR, MAX_POLL_INTERVAL_MS);
|
|
265
|
+
}
|
|
266
|
+
// Jitter:在 75%~125% 范围内随机抖动,防止多账号同时请求(雷群效应)
|
|
267
|
+
const jitteredMs = currentIntervalMs * (0.75 + Math.random() * 0.5);
|
|
268
|
+
await sleep(jitteredMs, abortSignal);
|
|
269
|
+
} catch (err) {
|
|
270
|
+
if (abortSignal.aborted) {
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (isUnauthorizedError(err)) {
|
|
275
|
+
log?.error?.(`[${accountId}] imwe 签名验证失败(401),请检查 AppKey/AppSecret`);
|
|
276
|
+
ctx.setStatus({
|
|
277
|
+
accountId,
|
|
278
|
+
enabled: true,
|
|
279
|
+
configured: false,
|
|
280
|
+
lastError: '签名验证失败,请检查 AppKey/AppSecret',
|
|
281
|
+
});
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
errorCount++;
|
|
286
|
+
const backoffMs = Math.min(1000 * Math.pow(2, errorCount - 1), 30_000);
|
|
287
|
+
log?.warn?.(
|
|
288
|
+
`[${accountId}] pullMessages 出错(第 ${errorCount} 次):${String(err)},${backoffMs}ms 后重试`,
|
|
289
|
+
);
|
|
290
|
+
await sleep(backoffMs, abortSignal);
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
log?.info(`[${accountId}] imwe 事件箱轮询已停止`);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
299
|
+
// 内部:处理单条入站消息
|
|
300
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* 将解包后的入站消息分发给 OpenClaw core 处理,并通过 deliver 回调发送回复。
|
|
304
|
+
*
|
|
305
|
+
* ctx 字段命名遵循 OpenClaw core 的 PascalCase 约定:
|
|
306
|
+
* Body / BodyForAgent / RawBody / CommandBody — 消息文本(core 从 Body 读取内容)
|
|
307
|
+
* From / To / SenderId / SenderName — 发送方/接收方标识
|
|
308
|
+
* SessionKey / AccountId / ChatType — 路由和会话类型
|
|
309
|
+
* MessageSid / Provider / Surface — 消息 ID 和渠道标识
|
|
310
|
+
*
|
|
311
|
+
* @param ctx ChannelGatewayContext
|
|
312
|
+
* @param msg 解包后的文本消息
|
|
313
|
+
* @param botInfo getMe 返回的机器人信息
|
|
314
|
+
*/
|
|
315
|
+
async function handleInboundMessage(
|
|
316
|
+
ctx: ChannelGatewayContext<ResolvedImweAccount>,
|
|
317
|
+
msg: ImweInboundMessage,
|
|
318
|
+
botInfo: BotInfo,
|
|
319
|
+
outbound: OutboundContext,
|
|
320
|
+
): Promise<void> {
|
|
321
|
+
const { account, cfg, log, channelRuntime } = ctx;
|
|
322
|
+
|
|
323
|
+
if (!channelRuntime) {
|
|
324
|
+
log?.warn?.(`[${account.accountId}] channelRuntime 不可用,跳过消息 ${msg.msgId}`);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// msg.content = '今天天气怎么样?'
|
|
329
|
+
log?.info?.(
|
|
330
|
+
`[${account.accountId}] 收到消息 from=${msg.senderId} content="${msg.content.slice(0, 50)}"` +
|
|
331
|
+
(msg.attachments?.length
|
|
332
|
+
? ` attachments=${msg.attachments.length} [${msg.attachments.map((a) => `${a.type}:${a.url?.slice(0, 60)}`).join(', ')}]`
|
|
333
|
+
: ''),
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
// 获取 channelRuntime 上的各子模块
|
|
337
|
+
const replyRuntime = channelRuntime.reply as ChannelReplyRuntime;
|
|
338
|
+
const routingRuntime = channelRuntime.routing as ChannelRoutingRuntime;
|
|
339
|
+
const sessionRuntime = channelRuntime.session as ChannelSessionRuntime;
|
|
340
|
+
|
|
341
|
+
try {
|
|
342
|
+
// ── 1. 解析 agent 路由 ──────────────────────────────────────────────────
|
|
343
|
+
const route = routingRuntime.resolveAgentRoute({
|
|
344
|
+
cfg,
|
|
345
|
+
channel: 'imwe',
|
|
346
|
+
accountId: account.accountId,
|
|
347
|
+
peer: { kind: 'direct', id: msg.senderId },
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
// ── 2. 格式化消息文本(添加发送者、时间戳等上下文信封) ──────────────────
|
|
351
|
+
const rawBody = msg.content;
|
|
352
|
+
const body = replyRuntime.formatAgentEnvelope({
|
|
353
|
+
channel: 'imwe',
|
|
354
|
+
from: msg.senderId,
|
|
355
|
+
timestamp: msg.timestampMs,
|
|
356
|
+
envelope: replyRuntime.resolveEnvelopeFormatOptions(cfg),
|
|
357
|
+
body: rawBody,
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
// ── 3. 处理入站多媒体附件 ──────────────────────────────────────────────
|
|
361
|
+
const mediaFields: Record<string, unknown> = {};
|
|
362
|
+
if (msg.attachments && msg.attachments.length > 0) {
|
|
363
|
+
const { saveMediaSource } = await import('openclaw/plugin-sdk/media-runtime');
|
|
364
|
+
const { buildMediaPayload } = await import('openclaw/plugin-sdk/reply-payload');
|
|
365
|
+
|
|
366
|
+
const mediaInputs: Array<{ path: string; contentType?: string }> = [];
|
|
367
|
+
for (const attachment of msg.attachments) {
|
|
368
|
+
try {
|
|
369
|
+
// 加密文件:通过 downloadAndDecryptFile 分片下载并解密
|
|
370
|
+
if (attachment.fileKey) {
|
|
371
|
+
log?.info?.(
|
|
372
|
+
`[${account.accountId}] 下载加密附件: type=${attachment.type}, url=${attachment.url?.slice(0, 80)}`,
|
|
373
|
+
);
|
|
374
|
+
const stateDir = resolveStateDir();
|
|
375
|
+
const savePath = path.join(
|
|
376
|
+
stateDir,
|
|
377
|
+
'channel-data',
|
|
378
|
+
'imwe',
|
|
379
|
+
botInfo.botAcctId,
|
|
380
|
+
'file-transfer',
|
|
381
|
+
'download',
|
|
382
|
+
`${msg.msgId}-${attachment.fileName ?? 'file'}`,
|
|
383
|
+
);
|
|
384
|
+
const downloadResult = await downloadAndDecryptFile(
|
|
385
|
+
{
|
|
386
|
+
url: attachment.url,
|
|
387
|
+
key: attachment.fileKey,
|
|
388
|
+
iv: attachment.fileIv!,
|
|
389
|
+
digest: attachment.fileDigest!,
|
|
390
|
+
plaintextLength: attachment.plaintextLength!,
|
|
391
|
+
opCreds: attachment.opCreds,
|
|
392
|
+
savePath,
|
|
393
|
+
messageContext: {
|
|
394
|
+
msgId: msg.msgId,
|
|
395
|
+
senderId: msg.senderId,
|
|
396
|
+
content: msg.content,
|
|
397
|
+
timestampMs: msg.timestampMs,
|
|
398
|
+
},
|
|
399
|
+
},
|
|
400
|
+
outbound.auth,
|
|
401
|
+
outbound.apiBaseUrl,
|
|
402
|
+
);
|
|
403
|
+
const saved = await saveMediaSource(downloadResult.localPath);
|
|
404
|
+
log?.info?.(
|
|
405
|
+
`[${account.accountId}] 加密附件下载解密成功: path=${saved.path}, contentType=${saved.contentType ?? attachment.mimeType ?? '(unknown)'}`,
|
|
406
|
+
);
|
|
407
|
+
mediaInputs.push({
|
|
408
|
+
path: saved.path,
|
|
409
|
+
contentType: saved.contentType ?? attachment.mimeType,
|
|
410
|
+
});
|
|
411
|
+
} else {
|
|
412
|
+
// 明文文件:走现有下载路径(向后兼容)
|
|
413
|
+
log?.info?.(
|
|
414
|
+
`[${account.accountId}] 下载附件: type=${attachment.type}, url=${attachment.url?.slice(0, 80)}`,
|
|
415
|
+
);
|
|
416
|
+
const saved = await saveMediaSource(attachment.url);
|
|
417
|
+
log?.info?.(
|
|
418
|
+
`[${account.accountId}] 附件下载成功: path=${saved.path}, contentType=${saved.contentType ?? attachment.mimeType ?? '(unknown)'}`,
|
|
419
|
+
);
|
|
420
|
+
mediaInputs.push({
|
|
421
|
+
path: saved.path,
|
|
422
|
+
contentType: saved.contentType ?? attachment.mimeType,
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
} catch (err) {
|
|
426
|
+
log?.warn?.(
|
|
427
|
+
`[${account.accountId}] 下载附件失败: type=${attachment.type}, url=${attachment.url?.slice(0, 80)}, error=${String(err)}`,
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (mediaInputs.length > 0) {
|
|
433
|
+
const mediaPayload = buildMediaPayload(mediaInputs);
|
|
434
|
+
log?.info?.(
|
|
435
|
+
`[${account.accountId}] 媒体 payload 构造完成: ${mediaInputs.length}/${msg.attachments.length} 个附件`,
|
|
436
|
+
);
|
|
437
|
+
Object.assign(mediaFields, mediaPayload);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// ── 4. 构造 PascalCase ctx 并通过 finalizeInboundContext 规范化 ─────────
|
|
442
|
+
const cacheScopeKey = botInfo.botMainAcctId;
|
|
443
|
+
const replyToId = msg.referenceClientMsgId ?? undefined;
|
|
444
|
+
const quotedMessage = resolveQuotedMessage(cacheScopeKey, replyToId);
|
|
445
|
+
if (replyToId) {
|
|
446
|
+
if (quotedMessage) {
|
|
447
|
+
log?.info?.(
|
|
448
|
+
`[${account.accountId}] 引用消息命中缓存: ref=${replyToId}, sender=${quotedMessage.senderId}`,
|
|
449
|
+
);
|
|
450
|
+
} else {
|
|
451
|
+
log?.warn?.(`[${account.accountId}] 引用消息未命中缓存: ref=${replyToId}`);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
// 先解析引用,再写入当前消息,避免当前消息错误覆盖引用目标。
|
|
455
|
+
rememberRecentMessage(cacheScopeKey, {
|
|
456
|
+
msgId: msg.msgId,
|
|
457
|
+
body: msg.content,
|
|
458
|
+
senderId: msg.senderId,
|
|
459
|
+
timestampMs: msg.timestampMs,
|
|
460
|
+
});
|
|
461
|
+
const ctxPayload = replyRuntime.finalizeInboundContext({
|
|
462
|
+
Body: body,
|
|
463
|
+
BodyForAgent: rawBody,
|
|
464
|
+
RawBody: rawBody,
|
|
465
|
+
CommandBody: rawBody,
|
|
466
|
+
From: `imwe:${msg.senderId}`,
|
|
467
|
+
To: `imwe:${msg.senderId}`,
|
|
468
|
+
SessionKey: route.sessionKey,
|
|
469
|
+
AccountId: route.accountId,
|
|
470
|
+
ChatType: 'direct',
|
|
471
|
+
SenderName: msg.senderId,
|
|
472
|
+
SenderId: msg.senderId,
|
|
473
|
+
Provider: 'imwe',
|
|
474
|
+
Surface: 'imwe',
|
|
475
|
+
// imwe 当前仅支持私聊;消息进入这里说明已通过渠道级接入,
|
|
476
|
+
// 需要显式标记为可处理的文本命令上下文,否则 /skill 会被 core 静默判为未授权。
|
|
477
|
+
CommandAuthorized: true,
|
|
478
|
+
CommandSource: 'text',
|
|
479
|
+
MessageSid: msg.msgId,
|
|
480
|
+
OriginatingChannel: 'imwe',
|
|
481
|
+
OriginatingTo: `imwe:${msg.senderId}`,
|
|
482
|
+
ReplyToId: replyToId,
|
|
483
|
+
ReplyToBody: quotedMessage?.body,
|
|
484
|
+
ReplyToSender: quotedMessage?.senderId,
|
|
485
|
+
ReplyToIsQuote: quotedMessage ? true : undefined,
|
|
486
|
+
...mediaFields,
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
log?.info?.(`[${account.accountId}] reply ctx: ${JSON.stringify(ctxPayload)}`);
|
|
490
|
+
|
|
491
|
+
// ── 5. 记录入站会话元数据 ───────────────────────────────────────────────
|
|
492
|
+
const storePath = sessionRuntime.resolveStorePath(
|
|
493
|
+
(cfg as Record<string, unknown> & { session?: { store?: string } }).session?.store,
|
|
494
|
+
{ agentId: route.agentId },
|
|
495
|
+
);
|
|
496
|
+
await sessionRuntime.recordInboundSession({
|
|
497
|
+
storePath,
|
|
498
|
+
sessionKey: (ctxPayload.SessionKey as string) ?? route.sessionKey,
|
|
499
|
+
ctx: ctxPayload,
|
|
500
|
+
onRecordError: (err) => {
|
|
501
|
+
log?.warn?.(`[${account.accountId}] 更新会话元数据失败:${String(err)}`);
|
|
502
|
+
},
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
// ── 5.5 创建 typing callbacks ──────────────────────────────────────────
|
|
506
|
+
const typingCallbacks = createTypingCallbacks({
|
|
507
|
+
start: async () => {
|
|
508
|
+
const { sendImweTypingSignal } = await import('./send.js');
|
|
509
|
+
await sendImweTypingSignal({
|
|
510
|
+
to: msg.senderId,
|
|
511
|
+
status: 1,
|
|
512
|
+
accountId: account.accountId,
|
|
513
|
+
cfg,
|
|
514
|
+
});
|
|
515
|
+
},
|
|
516
|
+
stop: async () => {
|
|
517
|
+
const { sendImweTypingSignal } = await import('./send.js');
|
|
518
|
+
await sendImweTypingSignal({
|
|
519
|
+
to: msg.senderId,
|
|
520
|
+
status: 2,
|
|
521
|
+
accountId: account.accountId,
|
|
522
|
+
cfg,
|
|
523
|
+
});
|
|
524
|
+
},
|
|
525
|
+
onStartError: (err) => {
|
|
526
|
+
log?.warn?.(`[${account.accountId}] typing start 失败:${String(err)}`);
|
|
527
|
+
},
|
|
528
|
+
onStopError: (err) => {
|
|
529
|
+
log?.warn?.(`[${account.accountId}] typing stop 失败:${String(err)}`);
|
|
530
|
+
},
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
// ── 6. 分发给 AI agent 处理 ─────────────────────────────────────────────
|
|
534
|
+
await replyRuntime.dispatchReplyWithBufferedBlockDispatcher({
|
|
535
|
+
ctx: ctxPayload,
|
|
536
|
+
cfg,
|
|
537
|
+
dispatcherOptions: {
|
|
538
|
+
deliver: async (payload: unknown) => {
|
|
539
|
+
const text = extractTextFromPayload(payload);
|
|
540
|
+
const mediaUrls = extractMediaUrlsFromPayload(payload);
|
|
541
|
+
const payloadReplyToId = extractReplyToIdFromPayload(payload);
|
|
542
|
+
|
|
543
|
+
log?.info?.(
|
|
544
|
+
`[${account.accountId}] 收到回复消息,文本长度:${text.length},媒体数量:${mediaUrls.length}${mediaUrls.length > 0 ? ` [${mediaUrls.map((u) => u.slice(0, 60)).join(', ')}]` : ''},replyToId=${payloadReplyToId ?? 'none'}`,
|
|
545
|
+
);
|
|
546
|
+
// 有媒体 → 逐个上传并通过基础层发送,第一个媒体附带文本作为 caption
|
|
547
|
+
if (mediaUrls.length > 0) {
|
|
548
|
+
for (let i = 0; i < mediaUrls.length; i++) {
|
|
549
|
+
try {
|
|
550
|
+
log?.info?.(
|
|
551
|
+
`[${account.accountId}] 发送媒体回复 (${i + 1}/${mediaUrls.length}): url=${mediaUrls[i].slice(0, 80)}, caption=${i === 0 && text ? 'yes' : 'no'}`,
|
|
552
|
+
);
|
|
553
|
+
// 使用 E2EE 加密分片上传
|
|
554
|
+
const uploadResult = await uploadEncryptedFile(
|
|
555
|
+
mediaUrls[i],
|
|
556
|
+
outbound.auth,
|
|
557
|
+
outbound.apiBaseUrl,
|
|
558
|
+
{
|
|
559
|
+
imMainAccId: msg.senderId,
|
|
560
|
+
config: {
|
|
561
|
+
stateDir: path.join(
|
|
562
|
+
resolveStateDir(),
|
|
563
|
+
'channel-data',
|
|
564
|
+
'imwe',
|
|
565
|
+
outbound.fromId,
|
|
566
|
+
'file-transfer',
|
|
567
|
+
),
|
|
568
|
+
},
|
|
569
|
+
sendContext: {
|
|
570
|
+
to: msg.senderId,
|
|
571
|
+
fromId: outbound.fromId,
|
|
572
|
+
mediaType: inferMediaType(mediaUrls[i]),
|
|
573
|
+
caption: i === 0 ? text : undefined,
|
|
574
|
+
},
|
|
575
|
+
log,
|
|
576
|
+
},
|
|
577
|
+
);
|
|
578
|
+
await sendMediaToPeer(outbound, {
|
|
579
|
+
to: msg.senderId,
|
|
580
|
+
url: uploadResult.url,
|
|
581
|
+
mediaType: uploadResult.contentType
|
|
582
|
+
? inferMediaTypeFromMime(uploadResult.contentType)
|
|
583
|
+
: inferMediaType(mediaUrls[i]),
|
|
584
|
+
fileKey: uploadResult.fileKey,
|
|
585
|
+
fileIv: uploadResult.fileIv,
|
|
586
|
+
fileDigest: uploadResult.fileDigest,
|
|
587
|
+
plaintextLength: uploadResult.plaintextLength,
|
|
588
|
+
fileSize: uploadResult.plaintextLength,
|
|
589
|
+
mimeType: uploadResult.contentType,
|
|
590
|
+
caption: i === 0 ? text : undefined,
|
|
591
|
+
blurHash: uploadResult.blurHash,
|
|
592
|
+
width: uploadResult.width,
|
|
593
|
+
height: uploadResult.height,
|
|
594
|
+
expireTime: uploadResult.expireTime,
|
|
595
|
+
opCreds: uploadResult.opCreds,
|
|
596
|
+
length: uploadResult.plaintextLength,
|
|
597
|
+
});
|
|
598
|
+
log?.info?.(
|
|
599
|
+
`[${account.accountId}] 媒体回复发送成功 (${i + 1}/${mediaUrls.length})`,
|
|
600
|
+
);
|
|
601
|
+
} catch (err) {
|
|
602
|
+
log?.warn?.(
|
|
603
|
+
`[${account.accountId}] 媒体发送失败 (${i + 1}/${mediaUrls.length}, ${mediaUrls[i].slice(0, 60)}): ${String(err)}`,
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
// 发送已读操作消息(不阻断主流程)
|
|
608
|
+
try {
|
|
609
|
+
await sendMsgReadToPeer(outbound, {
|
|
610
|
+
to: msg.senderId,
|
|
611
|
+
targetClientMsgIds: [msg.msgId],
|
|
612
|
+
readStamp: Math.floor(Date.now() / 1000),
|
|
613
|
+
});
|
|
614
|
+
} catch {
|
|
615
|
+
// sendMsgReadToPeer 内部已处理错误
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// markdown 文本:命中 markdown 特征时走 sendMarkdownToPeer
|
|
622
|
+
if (text && isMarkdownContent(text)) {
|
|
623
|
+
const digest = extractMarkdownDigest(text);
|
|
624
|
+
try {
|
|
625
|
+
await sendMarkdownToPeer(outbound, {
|
|
626
|
+
to: msg.senderId,
|
|
627
|
+
markdown: text,
|
|
628
|
+
replyToId: payloadReplyToId,
|
|
629
|
+
digest: digest || undefined,
|
|
630
|
+
});
|
|
631
|
+
} catch (err) {
|
|
632
|
+
log?.warn?.(`[${account.accountId}] imwe markdown reply 发送失败:${String(err)}`);
|
|
633
|
+
}
|
|
634
|
+
// 发送已读操作消息(不阻断主流程)
|
|
635
|
+
try {
|
|
636
|
+
await sendMsgReadToPeer(outbound, {
|
|
637
|
+
to: msg.senderId,
|
|
638
|
+
targetClientMsgIds: [msg.msgId],
|
|
639
|
+
readStamp: Math.floor(Date.now() / 1000),
|
|
640
|
+
});
|
|
641
|
+
} catch {
|
|
642
|
+
// sendMsgReadToPeer 内部已处理错误
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// 纯文本
|
|
649
|
+
if (!text) {
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
await sendTextToPeer(outbound, {
|
|
653
|
+
to: msg.senderId,
|
|
654
|
+
text,
|
|
655
|
+
replyToId: payloadReplyToId,
|
|
656
|
+
});
|
|
657
|
+
// 发送已读操作消息(不阻断主流程)
|
|
658
|
+
try {
|
|
659
|
+
await sendMsgReadToPeer(outbound, {
|
|
660
|
+
to: msg.senderId,
|
|
661
|
+
targetClientMsgIds: [msg.msgId],
|
|
662
|
+
readStamp: Math.floor(Date.now() / 1000),
|
|
663
|
+
});
|
|
664
|
+
} catch {
|
|
665
|
+
// sendMsgReadToPeer 内部已处理错误
|
|
666
|
+
}
|
|
667
|
+
},
|
|
668
|
+
onError: (err, info) => {
|
|
669
|
+
log?.error?.(`[${account.accountId}] imwe ${info.kind} reply 发送失败:${String(err)}`);
|
|
670
|
+
},
|
|
671
|
+
typingCallbacks,
|
|
672
|
+
},
|
|
673
|
+
});
|
|
674
|
+
} catch (err) {
|
|
675
|
+
log?.error?.(`[${account.accountId}] 处理消息失败:${String(err)}`);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
680
|
+
// 工具函数
|
|
681
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
682
|
+
|
|
683
|
+
/** 判断错误是否为 401 鉴权失败 */
|
|
684
|
+
function isUnauthorizedError(err: unknown): boolean {
|
|
685
|
+
const msg = String(err);
|
|
686
|
+
return msg.includes('401') || msg.toLowerCase().includes('unauthorized');
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* 从 OpenClaw reply payload 中提取纯文本内容。
|
|
691
|
+
* payload 可能是 content block 数组(type=text)或直接的 text 字段。
|
|
692
|
+
*/
|
|
693
|
+
function extractTextFromPayload(payload: unknown): string {
|
|
694
|
+
if (!payload || typeof payload !== 'object') {
|
|
695
|
+
return '';
|
|
696
|
+
}
|
|
697
|
+
const p = payload as Record<string, unknown>;
|
|
698
|
+
if (Array.isArray(p.content)) {
|
|
699
|
+
return p.content
|
|
700
|
+
.filter(
|
|
701
|
+
(b): b is { type: string; text: string } =>
|
|
702
|
+
typeof b === 'object' && b !== null && (b as Record<string, unknown>).type === 'text',
|
|
703
|
+
)
|
|
704
|
+
.map((b) => b.text)
|
|
705
|
+
.join('\n');
|
|
706
|
+
}
|
|
707
|
+
if (typeof p.text === 'string') {
|
|
708
|
+
return p.text;
|
|
709
|
+
}
|
|
710
|
+
return '';
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* 从 block content 中提取图片 URL。
|
|
715
|
+
* 兼容 OpenAI 风格的 image_url block,以及本地 image block。
|
|
716
|
+
*/
|
|
717
|
+
function extractMediaUrlsFromContentBlocks(content: unknown): string[] {
|
|
718
|
+
if (!Array.isArray(content)) {
|
|
719
|
+
return [];
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const urls: string[] = [];
|
|
723
|
+
for (const block of content) {
|
|
724
|
+
if (!block || typeof block !== 'object') {
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
const item = block as Record<string, unknown>;
|
|
728
|
+
|
|
729
|
+
if (item.type === 'image_url') {
|
|
730
|
+
const imageUrl = item.image_url;
|
|
731
|
+
if (imageUrl && typeof imageUrl === 'object') {
|
|
732
|
+
const url = (imageUrl as Record<string, unknown>).url;
|
|
733
|
+
if (typeof url === 'string' && url.trim()) {
|
|
734
|
+
urls.push(url.trim());
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
continue;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
if (item.type === 'image') {
|
|
741
|
+
const source = item.source;
|
|
742
|
+
if (source && typeof source === 'object') {
|
|
743
|
+
const sourceRecord = source as Record<string, unknown>;
|
|
744
|
+
if (typeof sourceRecord.data === 'string' && sourceRecord.data.trim()) {
|
|
745
|
+
const mimeType =
|
|
746
|
+
typeof sourceRecord.media_type === 'string' && sourceRecord.media_type.trim()
|
|
747
|
+
? sourceRecord.media_type.trim()
|
|
748
|
+
: 'image/png';
|
|
749
|
+
const raw = sourceRecord.data.trim();
|
|
750
|
+
urls.push(raw.startsWith('data:') ? raw : `data:${mimeType};base64,${raw}`);
|
|
751
|
+
continue;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
if (typeof item.url === 'string' && item.url.trim()) {
|
|
755
|
+
urls.push(item.url.trim());
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
return urls;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* 检测纯文本是否其实是本地媒体路径。
|
|
765
|
+
* 当前仅兼容图片路径,避免 qwen-image 一类 skill 返回本地文件路径时被吞掉。
|
|
766
|
+
*/
|
|
767
|
+
function detectLocalImagePath(text: string): string | null {
|
|
768
|
+
const raw = text.trim();
|
|
769
|
+
if (!raw || /\s/.test(raw)) {
|
|
770
|
+
return null;
|
|
771
|
+
}
|
|
772
|
+
if (/^(https?:\/\/|data:|file:\/\/)/i.test(raw)) {
|
|
773
|
+
return null;
|
|
774
|
+
}
|
|
775
|
+
const ext = path.extname(raw).toLowerCase();
|
|
776
|
+
if (
|
|
777
|
+
!['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.ico', '.tiff', '.heic', '.heif'].includes(
|
|
778
|
+
ext,
|
|
779
|
+
)
|
|
780
|
+
) {
|
|
781
|
+
return null;
|
|
782
|
+
}
|
|
783
|
+
return raw;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* 从 OpenClaw reply payload 中提取媒体 URL 列表。
|
|
788
|
+
* 兼容 mediaUrls / mediaUrl,以及 content 里的图片 block。
|
|
789
|
+
*/
|
|
790
|
+
export function extractMediaUrlsFromPayload(payload: unknown): string[] {
|
|
791
|
+
if (!payload || typeof payload !== 'object') return [];
|
|
792
|
+
const p = payload as Record<string, unknown>;
|
|
793
|
+
|
|
794
|
+
const urls: string[] = [];
|
|
795
|
+
|
|
796
|
+
if (Array.isArray(p.mediaUrls)) {
|
|
797
|
+
urls.push(
|
|
798
|
+
...p.mediaUrls.filter((u): u is string => typeof u === 'string' && u.trim().length > 0),
|
|
799
|
+
);
|
|
800
|
+
}
|
|
801
|
+
if (typeof p.mediaUrl === 'string' && p.mediaUrl.length > 0) {
|
|
802
|
+
urls.push(p.mediaUrl);
|
|
803
|
+
}
|
|
804
|
+
urls.push(...extractMediaUrlsFromContentBlocks(p.content));
|
|
805
|
+
|
|
806
|
+
const localImagePath = typeof p.text === 'string' ? detectLocalImagePath(p.text) : null;
|
|
807
|
+
if (localImagePath) {
|
|
808
|
+
urls.push(localImagePath);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
return [...new Set(urls.map((url) => url.trim()).filter(Boolean))];
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* 从 OpenClaw reply payload 中提取 replyToId。
|
|
816
|
+
* core 根据 replyToMode 决定是否在 payload 中携带 replyToId。
|
|
817
|
+
*/
|
|
818
|
+
function extractReplyToIdFromPayload(payload: unknown): string | undefined {
|
|
819
|
+
if (!payload || typeof payload !== 'object') return undefined;
|
|
820
|
+
const p = payload as Record<string, unknown>;
|
|
821
|
+
if (typeof p.replyToId === 'string' && p.replyToId) return p.replyToId;
|
|
822
|
+
return undefined;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/** 可中断的 sleep,abortSignal 触发时提前 resolve */
|
|
826
|
+
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
827
|
+
return new Promise((resolve) => {
|
|
828
|
+
const timer = setTimeout(resolve, ms);
|
|
829
|
+
signal?.addEventListener(
|
|
830
|
+
'abort',
|
|
831
|
+
() => {
|
|
832
|
+
clearTimeout(timer);
|
|
833
|
+
resolve();
|
|
834
|
+
},
|
|
835
|
+
{ once: true },
|
|
836
|
+
);
|
|
837
|
+
});
|
|
838
|
+
}
|