@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,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bot-info-cache.ts — 机器人信息的进程级缓存
|
|
3
|
+
*
|
|
4
|
+
* 生命周期:
|
|
5
|
+
* - monitor.ts 的 monitorImweAccount 在 getMe 成功后调用 setBotInfo 写入
|
|
6
|
+
* - send.ts 的 sendImweText 在发送消息时调用 getBotInfo 读取 botAcctId 作为 fromId
|
|
7
|
+
* - monitor.ts 的轮询循环退出时调用 clearBotInfo 清理
|
|
8
|
+
*
|
|
9
|
+
* 多账号安全:以 accountId 为 key,每个账号独立缓存,互不干扰。
|
|
10
|
+
*
|
|
11
|
+
* 为什么不用 channelRuntime.runtimeContexts:
|
|
12
|
+
* outbound 的 sendText 回调签名是 (ctx: ChannelOutboundContext) => ...,
|
|
13
|
+
* ChannelOutboundContext 只有 cfg/to/text/accountId,没有 channelRuntime,
|
|
14
|
+
* 无法调用 runtimeContexts.get()。模块级 Map 是最简单可靠的方案。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { BotInfo } from './api-client.js';
|
|
18
|
+
|
|
19
|
+
/** accountId → BotInfo,进程级缓存 */
|
|
20
|
+
const cache = new Map<string, BotInfo>();
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 缓存机器人信息(monitor 启动时写入)。
|
|
24
|
+
*
|
|
25
|
+
* @param accountId 账号 ID(多账号时区分不同机器人)
|
|
26
|
+
* @param info getMe 返回的机器人信息
|
|
27
|
+
*/
|
|
28
|
+
export function setBotInfo(accountId: string, info: BotInfo): void {
|
|
29
|
+
cache.set(accountId, info);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 读取缓存的机器人信息(send / outbound / pairing 读取)。
|
|
34
|
+
*
|
|
35
|
+
* @param accountId 账号 ID
|
|
36
|
+
* @returns 缓存的 BotInfo,未缓存时返回 undefined
|
|
37
|
+
*/
|
|
38
|
+
export function getBotInfo(accountId: string): BotInfo | undefined {
|
|
39
|
+
return cache.get(accountId);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 清除缓存的机器人信息(monitor 停止时调用)。
|
|
44
|
+
*
|
|
45
|
+
* @param accountId 账号 ID
|
|
46
|
+
*/
|
|
47
|
+
export function clearBotInfo(accountId: string): void {
|
|
48
|
+
cache.delete(accountId);
|
|
49
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* channel.runtime.ts — 运行时模块的统一导出入口
|
|
3
|
+
*
|
|
4
|
+
* createLazyRuntimeModule 的加载目标,setup-only 模式下不会被加载。
|
|
5
|
+
*
|
|
6
|
+
* 导出分三类:
|
|
7
|
+
* - monitor:事件箱轮询(monitorImweAccount)
|
|
8
|
+
* - send:发送消息(sendImweText)
|
|
9
|
+
* - 初始化:getMe(获取机器人信息)、botInfo 缓存管理
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export { monitorImweAccount } from './monitor.js';
|
|
13
|
+
export {
|
|
14
|
+
sendImweText,
|
|
15
|
+
sendImweTypingSignal,
|
|
16
|
+
sendImweMedia,
|
|
17
|
+
sendImweMarkdown,
|
|
18
|
+
sendTextToPeer,
|
|
19
|
+
sendMarkdownToPeer,
|
|
20
|
+
sendMediaToPeer,
|
|
21
|
+
sendTypingSignalToPeer,
|
|
22
|
+
registerE2eeService,
|
|
23
|
+
unregisterE2eeService,
|
|
24
|
+
} from './send.js';
|
|
25
|
+
export type { OutboundContext } from './send.js';
|
|
26
|
+
export { getMe } from './api-client.js';
|
|
27
|
+
export { getBotInfo, setBotInfo, clearBotInfo } from './bot-info-cache.js';
|
|
28
|
+
export { createE2eeService } from './e2ee/index.js';
|
|
29
|
+
export type { E2eeService, BotInfo } from './e2ee/index.js';
|
package/src/channel.ts
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* channel.ts — ChannelPlugin 主对象
|
|
3
|
+
*
|
|
4
|
+
* 认证方式:AppKey + AppSecret HMAC-SHA256 签名(无需登录流程)
|
|
5
|
+
* 支持:私聊文字消息、多账号、long polling
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from 'fs';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import {
|
|
11
|
+
buildChannelConfigSchema,
|
|
12
|
+
createChatChannelPlugin,
|
|
13
|
+
type ChannelPlugin,
|
|
14
|
+
} from 'openclaw/plugin-sdk/channel-core';
|
|
15
|
+
import {
|
|
16
|
+
adaptScopedAccountAccessor,
|
|
17
|
+
createScopedChannelConfigAdapter,
|
|
18
|
+
createScopedDmSecurityResolver,
|
|
19
|
+
} from 'openclaw/plugin-sdk/channel-config-helpers';
|
|
20
|
+
import { createLazyRuntimeModule } from 'openclaw/plugin-sdk/lazy-runtime';
|
|
21
|
+
import { createStaticReplyToModeResolver } from 'openclaw/plugin-sdk/conversation-runtime';
|
|
22
|
+
import {
|
|
23
|
+
createComputedAccountStatusAdapter,
|
|
24
|
+
createDefaultChannelRuntimeState,
|
|
25
|
+
} from 'openclaw/plugin-sdk/status-helpers';
|
|
26
|
+
import { DEFAULT_ACCOUNT_ID } from 'openclaw/plugin-sdk/account-id';
|
|
27
|
+
import { chunkTextForOutbound } from 'openclaw/plugin-sdk/text-chunking';
|
|
28
|
+
import { createRawChannelSendResultAdapter } from 'openclaw/plugin-sdk/channel-send-result';
|
|
29
|
+
import { listImweAccountIds, resolveDefaultImweAccountId, resolveImweAccount } from './accounts.js';
|
|
30
|
+
import { imweSetupAdapter } from './setup-core.js';
|
|
31
|
+
import { ImweConfigSchema } from './config-schema.js';
|
|
32
|
+
import { isMarkdownContent } from './markdown-detect.js';
|
|
33
|
+
import {
|
|
34
|
+
init as initRecentMessageCache,
|
|
35
|
+
forceFlushAll as forceFlushRecentMessageCache,
|
|
36
|
+
} from './recent-message-cache.js';
|
|
37
|
+
import { resumePendingUploads, resumePendingDownloads } from './file-transfer/index.js';
|
|
38
|
+
import type { ResolvedImweAccount } from './types.js';
|
|
39
|
+
|
|
40
|
+
import { resolveStateDir } from 'openclaw/plugin-sdk/state-paths';
|
|
41
|
+
|
|
42
|
+
const loadImweRuntime = createLazyRuntimeModule(() => import('./channel.runtime.js'));
|
|
43
|
+
|
|
44
|
+
// E2EE service 实例缓存(按 accountId 索引),通过 runtime module 懒加载创建
|
|
45
|
+
type ImweRuntime = Awaited<ReturnType<typeof loadImweRuntime>>;
|
|
46
|
+
type E2eeServiceInstance = ReturnType<ImweRuntime['createE2eeService']>;
|
|
47
|
+
const e2eeServiceMap = new Map<string, E2eeServiceInstance>();
|
|
48
|
+
|
|
49
|
+
const meta = {
|
|
50
|
+
id: 'imwe',
|
|
51
|
+
label: 'imwe',
|
|
52
|
+
selectionLabel: 'imwe (AppKey/AppSecret)',
|
|
53
|
+
docsPath: '/channels/imwe',
|
|
54
|
+
docsLabel: 'imwe',
|
|
55
|
+
blurb: 'imwe 即时通讯平台,AppKey/AppSecret 签名认证,支持多账号。',
|
|
56
|
+
aliases: ['iw'],
|
|
57
|
+
order: 95,
|
|
58
|
+
quickstartAllowFrom: true,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const imweConfigAdapter = createScopedChannelConfigAdapter<ResolvedImweAccount>({
|
|
62
|
+
sectionKey: 'imwe',
|
|
63
|
+
listAccountIds: listImweAccountIds,
|
|
64
|
+
resolveAccount: adaptScopedAccountAccessor(resolveImweAccount),
|
|
65
|
+
defaultAccountId: resolveDefaultImweAccountId,
|
|
66
|
+
clearBaseFields: ['appKey', 'appSecret', 'name'],
|
|
67
|
+
resolveAllowFrom: (account) => account.config.allowFrom,
|
|
68
|
+
formatAllowFrom: (allowFrom) =>
|
|
69
|
+
allowFrom.map((e) =>
|
|
70
|
+
String(e)
|
|
71
|
+
.trim()
|
|
72
|
+
.replace(/^(imwe|iw):/i, ''),
|
|
73
|
+
),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const resolveImweDmPolicy = createScopedDmSecurityResolver<ResolvedImweAccount>({
|
|
77
|
+
channelKey: 'imwe',
|
|
78
|
+
resolvePolicy: (account) => account.config.dmPolicy,
|
|
79
|
+
resolveAllowFrom: (account) => account.config.allowFrom,
|
|
80
|
+
policyPathSuffix: 'dmPolicy',
|
|
81
|
+
normalizeEntry: (raw) => raw.trim().replace(/^(imwe|iw):/i, ''),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 检测文本是否为纯本地图片路径。
|
|
86
|
+
* 当 AI agent 回复的文本内容是一个本地图片路径时,自动识别并作为图片发送。
|
|
87
|
+
* 参考 feishu 插件的 normalizePossibleLocalImagePath 实现。
|
|
88
|
+
*/
|
|
89
|
+
function detectLocalImagePath(text: string | undefined): string | null {
|
|
90
|
+
const raw = text?.trim();
|
|
91
|
+
if (!raw) return null;
|
|
92
|
+
// 仅当文本是纯路径时才转换,避免误判包含路径的普通句子
|
|
93
|
+
if (/\s/.test(raw)) return null;
|
|
94
|
+
// 忽略 URL
|
|
95
|
+
if (/^(https?:\/\/|data:|file:\/\/)/i.test(raw)) return null;
|
|
96
|
+
const ext = path.extname(raw).toLowerCase();
|
|
97
|
+
if (
|
|
98
|
+
!['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.ico', '.tiff', '.heic', '.heif'].includes(
|
|
99
|
+
ext,
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
return null;
|
|
103
|
+
if (!path.isAbsolute(raw)) return null;
|
|
104
|
+
try {
|
|
105
|
+
if (!fs.existsSync(raw) || !fs.statSync(raw).isFile()) return null;
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
return raw;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const imweRawSendResultAdapter = createRawChannelSendResultAdapter({
|
|
113
|
+
channel: 'imwe',
|
|
114
|
+
sendText: async ({
|
|
115
|
+
to,
|
|
116
|
+
text,
|
|
117
|
+
accountId,
|
|
118
|
+
cfg,
|
|
119
|
+
mediaAccess,
|
|
120
|
+
mediaLocalRoots,
|
|
121
|
+
mediaReadFile,
|
|
122
|
+
replyToId,
|
|
123
|
+
}) => {
|
|
124
|
+
// 兼容处理:当 agent 回复纯本地图片路径时,自动作为图片发送
|
|
125
|
+
const localImagePath = detectLocalImagePath(text);
|
|
126
|
+
if (localImagePath) {
|
|
127
|
+
const { sendImweMedia } = await loadImweRuntime();
|
|
128
|
+
return sendImweMedia({
|
|
129
|
+
to,
|
|
130
|
+
mediaUrl: localImagePath,
|
|
131
|
+
accountId: accountId ?? undefined,
|
|
132
|
+
cfg,
|
|
133
|
+
mediaAccess,
|
|
134
|
+
mediaLocalRoots,
|
|
135
|
+
mediaReadFile,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
// Markdown 文本分派:命中 markdown 特征走 sendImweMarkdown(优先级低于本地图片路径,高于普通文本兜底)
|
|
139
|
+
if (isMarkdownContent(text)) {
|
|
140
|
+
const { sendImweMarkdown } = await loadImweRuntime();
|
|
141
|
+
return sendImweMarkdown({
|
|
142
|
+
to,
|
|
143
|
+
markdown: text,
|
|
144
|
+
accountId: accountId ?? undefined,
|
|
145
|
+
cfg,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
const { sendImweText } = await loadImweRuntime();
|
|
149
|
+
return sendImweText({
|
|
150
|
+
to,
|
|
151
|
+
text,
|
|
152
|
+
accountId: accountId ?? undefined,
|
|
153
|
+
cfg,
|
|
154
|
+
replyToId: replyToId ?? undefined,
|
|
155
|
+
});
|
|
156
|
+
},
|
|
157
|
+
sendMedia: async ({
|
|
158
|
+
to,
|
|
159
|
+
mediaUrl,
|
|
160
|
+
mediaType,
|
|
161
|
+
fileName,
|
|
162
|
+
caption,
|
|
163
|
+
text,
|
|
164
|
+
accountId,
|
|
165
|
+
cfg,
|
|
166
|
+
mediaAccess,
|
|
167
|
+
mediaLocalRoots,
|
|
168
|
+
mediaReadFile,
|
|
169
|
+
}) => {
|
|
170
|
+
const { sendImweMedia } = await loadImweRuntime();
|
|
171
|
+
return sendImweMedia({
|
|
172
|
+
to,
|
|
173
|
+
mediaUrl,
|
|
174
|
+
mediaType,
|
|
175
|
+
fileName,
|
|
176
|
+
caption: caption ?? text,
|
|
177
|
+
accountId: accountId ?? undefined,
|
|
178
|
+
cfg,
|
|
179
|
+
mediaAccess,
|
|
180
|
+
mediaLocalRoots,
|
|
181
|
+
mediaReadFile,
|
|
182
|
+
log: console,
|
|
183
|
+
});
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
export const imwePlugin: ChannelPlugin<ResolvedImweAccount> = createChatChannelPlugin({
|
|
188
|
+
base: {
|
|
189
|
+
id: 'imwe',
|
|
190
|
+
meta,
|
|
191
|
+
capabilities: {
|
|
192
|
+
chatTypes: ['direct'],
|
|
193
|
+
media: true,
|
|
194
|
+
reactions: false,
|
|
195
|
+
threads: false,
|
|
196
|
+
polls: false,
|
|
197
|
+
nativeCommands: false,
|
|
198
|
+
},
|
|
199
|
+
reload: { configPrefixes: ['channels.imwe'] },
|
|
200
|
+
configSchema: buildChannelConfigSchema(ImweConfigSchema),
|
|
201
|
+
config: {
|
|
202
|
+
...imweConfigAdapter,
|
|
203
|
+
isConfigured: (account) => Boolean(account.appKey?.trim() && account.appSecret?.trim()),
|
|
204
|
+
describeAccount: (account) => ({
|
|
205
|
+
accountId: account.accountId,
|
|
206
|
+
name: account.name,
|
|
207
|
+
enabled: account.enabled,
|
|
208
|
+
configured: Boolean(account.appKey?.trim() && account.appSecret?.trim()),
|
|
209
|
+
credentialSource: account.credentialSource,
|
|
210
|
+
dmPolicy: account.config.dmPolicy ?? 'open',
|
|
211
|
+
}),
|
|
212
|
+
},
|
|
213
|
+
setup: imweSetupAdapter,
|
|
214
|
+
status: createComputedAccountStatusAdapter<ResolvedImweAccount>({
|
|
215
|
+
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
|
|
216
|
+
resolveAccountSnapshot: ({ account }) => ({
|
|
217
|
+
accountId: account.accountId,
|
|
218
|
+
name: account.name,
|
|
219
|
+
enabled: account.enabled,
|
|
220
|
+
configured: Boolean(account.appKey?.trim() && account.appSecret?.trim()),
|
|
221
|
+
credentialSource: account.credentialSource,
|
|
222
|
+
dmPolicy: account.config.dmPolicy ?? 'open',
|
|
223
|
+
}),
|
|
224
|
+
}),
|
|
225
|
+
gateway: {
|
|
226
|
+
/**
|
|
227
|
+
* 启动账号监听循环。
|
|
228
|
+
*
|
|
229
|
+
* 职责分离:
|
|
230
|
+
* - startAccount:调用 getMe 获取机器人信息,缓存到 bot-info-cache,然后启动轮询
|
|
231
|
+
* - monitorImweAccount:只负责事件箱轮询,不关心初始化
|
|
232
|
+
* - stopAccount:清理 bot-info-cache 缓存
|
|
233
|
+
*/
|
|
234
|
+
startAccount: async (ctx) => {
|
|
235
|
+
const { monitorImweAccount, getMe, setBotInfo } = await loadImweRuntime();
|
|
236
|
+
const { account, log } = ctx;
|
|
237
|
+
const { apiBaseUrl, appKey, appSecret, accountId } = account;
|
|
238
|
+
|
|
239
|
+
log?.info(`[${accountId}] imwe 账号启动`);
|
|
240
|
+
|
|
241
|
+
// 前置条件:调用 getMe 获取机器人信息(botAcctId)
|
|
242
|
+
// botAcctId 用于 pullMessages 的 boxId 和 sendMessage 的 fromId
|
|
243
|
+
if (!appKey || !appSecret) {
|
|
244
|
+
log?.warn?.(
|
|
245
|
+
`[${accountId}] imwe AppKey/AppSecret 未配置,请先运行 \`openclaw setup imwe\``,
|
|
246
|
+
);
|
|
247
|
+
ctx.setStatus({
|
|
248
|
+
accountId,
|
|
249
|
+
enabled: true,
|
|
250
|
+
configured: false,
|
|
251
|
+
lastError: 'AppKey/AppSecret 未配置',
|
|
252
|
+
});
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
let botInfo;
|
|
257
|
+
try {
|
|
258
|
+
botInfo = await getMe(apiBaseUrl, {
|
|
259
|
+
apiKey: appKey,
|
|
260
|
+
apiSecret: appSecret,
|
|
261
|
+
});
|
|
262
|
+
log?.info?.(
|
|
263
|
+
`[${accountId}] imwe getMe 成功:botAcctId=${botInfo.botAcctId}, botMainAcctId=${botInfo.botMainAcctId} botName=${botInfo.botName}`,
|
|
264
|
+
);
|
|
265
|
+
} catch (err) {
|
|
266
|
+
log?.error?.(`[${accountId}] imwe getMe 失败,无法启动监听:${String(err)}`);
|
|
267
|
+
ctx.setStatus({
|
|
268
|
+
accountId,
|
|
269
|
+
enabled: true,
|
|
270
|
+
configured: false,
|
|
271
|
+
lastError: `getMe 失败:${String(err)}`,
|
|
272
|
+
});
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// 缓存 botInfo,供 send.ts / outbound / pairing 读取 botAcctId 作为 fromId
|
|
277
|
+
setBotInfo(accountId, botInfo);
|
|
278
|
+
|
|
279
|
+
// E2EE 本地设备 bootstrap(通过 runtime module 懒加载,不直接 import e2ee/*)
|
|
280
|
+
let e2eeService: E2eeServiceInstance | undefined;
|
|
281
|
+
if (botInfo.e2eeEnabled === false) {
|
|
282
|
+
log?.info?.(`[${accountId}] Bot 未启用 E2EE,跳过 E2EE bootstrap`);
|
|
283
|
+
} else {
|
|
284
|
+
try {
|
|
285
|
+
const { createE2eeService, registerE2eeService } = await loadImweRuntime();
|
|
286
|
+
e2eeService = createE2eeService({
|
|
287
|
+
accountId,
|
|
288
|
+
botAcctId: botInfo.botAcctId,
|
|
289
|
+
apiBaseUrl,
|
|
290
|
+
auth: { apiKey: appKey, apiSecret: appSecret },
|
|
291
|
+
stateDir: resolveStateDir(),
|
|
292
|
+
log,
|
|
293
|
+
});
|
|
294
|
+
await e2eeService.ensureLocalDevice(botInfo);
|
|
295
|
+
e2eeServiceMap.set(accountId, e2eeService);
|
|
296
|
+
// 注册到 send.ts 模块级缓存,供顶层 sendImweXxx 函数获取
|
|
297
|
+
registerE2eeService(accountId, e2eeService);
|
|
298
|
+
log?.info?.(`[${accountId}] E2EE bootstrap 成功`);
|
|
299
|
+
} catch (err) {
|
|
300
|
+
log?.error?.(`[${accountId}] E2EE bootstrap 失败:${String(err)}`);
|
|
301
|
+
ctx.setStatus({
|
|
302
|
+
accountId,
|
|
303
|
+
enabled: true,
|
|
304
|
+
configured: true,
|
|
305
|
+
lastError: `E2EE bootstrap 失败:${err instanceof Error ? err.message : String(err)}`,
|
|
306
|
+
});
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// 初始化最近消息缓存持久化(从磁盘恢复,轮询启动之前)
|
|
312
|
+
initRecentMessageCache(resolveStateDir(), {
|
|
313
|
+
logInfo: (msg) => log?.info?.(msg),
|
|
314
|
+
logWarn: (msg) => log?.warn?.(msg),
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// 断点续传恢复(fire-and-forget,不阻塞轮询启动)
|
|
318
|
+
const fileTransferStateDir = path.join(
|
|
319
|
+
resolveStateDir(),
|
|
320
|
+
'channel-data',
|
|
321
|
+
'imwe',
|
|
322
|
+
botInfo.botAcctId,
|
|
323
|
+
'file-transfer',
|
|
324
|
+
);
|
|
325
|
+
const resumeAuth = { apiKey: appKey!, apiSecret: appSecret! };
|
|
326
|
+
|
|
327
|
+
// 恢复未完成的上传任务,完成后使用 sendContext 发送消息
|
|
328
|
+
resumePendingUploads({
|
|
329
|
+
stateDir: fileTransferStateDir,
|
|
330
|
+
auth: resumeAuth,
|
|
331
|
+
apiBaseUrl,
|
|
332
|
+
onUploadComplete: async (state, result) => {
|
|
333
|
+
const { sendMediaToPeer } = await loadImweRuntime();
|
|
334
|
+
const outbound = {
|
|
335
|
+
apiBaseUrl,
|
|
336
|
+
auth: resumeAuth,
|
|
337
|
+
accountId,
|
|
338
|
+
fromId: state.sendContext.fromId,
|
|
339
|
+
e2eeService,
|
|
340
|
+
log,
|
|
341
|
+
};
|
|
342
|
+
await sendMediaToPeer(outbound, {
|
|
343
|
+
to: state.sendContext.to,
|
|
344
|
+
url: result.url,
|
|
345
|
+
mediaType: state.sendContext.mediaType,
|
|
346
|
+
fileKey: result.fileKey,
|
|
347
|
+
fileIv: result.fileIv,
|
|
348
|
+
fileDigest: result.fileDigest,
|
|
349
|
+
plaintextLength: result.plaintextLength,
|
|
350
|
+
fileName: state.sendContext.fileName,
|
|
351
|
+
caption: state.sendContext.caption,
|
|
352
|
+
expireTime: result.expireTime,
|
|
353
|
+
opCreds: result.opCreds,
|
|
354
|
+
length: result.plaintextLength,
|
|
355
|
+
});
|
|
356
|
+
},
|
|
357
|
+
log,
|
|
358
|
+
}).catch((err) => {
|
|
359
|
+
log?.warn?.(`[${accountId}] 上传恢复调度失败:${String(err)}`);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
// 恢复未完成的下载任务(通知级别,实际恢复由 monitor 再次遇到消息时触发)
|
|
363
|
+
resumePendingDownloads({
|
|
364
|
+
stateDir: fileTransferStateDir,
|
|
365
|
+
auth: resumeAuth,
|
|
366
|
+
apiBaseUrl,
|
|
367
|
+
onDownloadComplete: async (_state, _localPath) => {
|
|
368
|
+
// 下载恢复由 scheduler 层面仅做通知,实际 saveMediaSource 流程
|
|
369
|
+
// 在 monitor.ts 再次处理该消息时自动完成
|
|
370
|
+
log?.info?.(`[${accountId}] 下载恢复任务已通知`);
|
|
371
|
+
},
|
|
372
|
+
log,
|
|
373
|
+
}).catch((err) => {
|
|
374
|
+
log?.warn?.(`[${accountId}] 下载恢复调度失败:${String(err)}`);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
// 启动事件箱轮询(monitor 只负责轮询,不负责初始化;注入 e2eeService)
|
|
378
|
+
return monitorImweAccount(ctx, botInfo, { e2eeService });
|
|
379
|
+
},
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* 停止账号监听时清理 botInfo 缓存。
|
|
383
|
+
* 注意:monitor 的轮询循环通过 abortSignal 退出,此处做额外的缓存清理保险。
|
|
384
|
+
*/
|
|
385
|
+
stopAccount: async (ctx) => {
|
|
386
|
+
const { clearBotInfo, unregisterE2eeService } = await loadImweRuntime();
|
|
387
|
+
clearBotInfo(ctx.accountId);
|
|
388
|
+
// 注销 send.ts 模块级缓存中的 E2EE Service
|
|
389
|
+
unregisterE2eeService(ctx.accountId);
|
|
390
|
+
// 释放 E2EE WASM handle
|
|
391
|
+
const e2eeService = e2eeServiceMap.get(ctx.accountId);
|
|
392
|
+
if (e2eeService) {
|
|
393
|
+
await e2eeService.dispose();
|
|
394
|
+
e2eeServiceMap.delete(ctx.accountId);
|
|
395
|
+
}
|
|
396
|
+
// 强制刷盘,确保 stop 前缓存已持久化
|
|
397
|
+
forceFlushRecentMessageCache();
|
|
398
|
+
ctx.log?.info(`[${ctx.accountId}] imwe 账号已停止,botInfo 缓存已清理`);
|
|
399
|
+
},
|
|
400
|
+
},
|
|
401
|
+
groups: {
|
|
402
|
+
resolveRequireMention: () => false,
|
|
403
|
+
},
|
|
404
|
+
messaging: {
|
|
405
|
+
normalizeTarget: (raw) =>
|
|
406
|
+
raw
|
|
407
|
+
?.trim()
|
|
408
|
+
.replace(/^(imwe|iw):/i, '')
|
|
409
|
+
.trim() || undefined,
|
|
410
|
+
targetResolver: {
|
|
411
|
+
looksLikeId: (s) => /^((imwe|iw):)?[A-Za-z0-9_]+$/.test(s.trim()),
|
|
412
|
+
hint: '<userId>',
|
|
413
|
+
},
|
|
414
|
+
},
|
|
415
|
+
},
|
|
416
|
+
|
|
417
|
+
security: {
|
|
418
|
+
resolveDmPolicy: resolveImweDmPolicy,
|
|
419
|
+
},
|
|
420
|
+
|
|
421
|
+
pairing: {
|
|
422
|
+
text: {
|
|
423
|
+
idLabel: 'imweUserId',
|
|
424
|
+
message: '您的配对请求已通过审批。',
|
|
425
|
+
normalizeAllowEntry: (entry) => entry.trim().replace(/^(imwe|iw):/i, ''),
|
|
426
|
+
// notify 参数中接收方字段名为 id(来自 ChannelPairingAdapter.notifyApproval)
|
|
427
|
+
notify: async (params) => {
|
|
428
|
+
const { sendImweText } = await loadImweRuntime();
|
|
429
|
+
const { loadConfig } = await import('openclaw/plugin-sdk/config-runtime');
|
|
430
|
+
const cfg = loadConfig();
|
|
431
|
+
const account = resolveImweAccount({
|
|
432
|
+
cfg,
|
|
433
|
+
accountId: params.accountId,
|
|
434
|
+
});
|
|
435
|
+
await sendImweText({
|
|
436
|
+
to: String(params.id),
|
|
437
|
+
text: params.message,
|
|
438
|
+
accountId: account.accountId,
|
|
439
|
+
cfg,
|
|
440
|
+
});
|
|
441
|
+
},
|
|
442
|
+
},
|
|
443
|
+
},
|
|
444
|
+
|
|
445
|
+
threading: {
|
|
446
|
+
resolveReplyToMode: createStaticReplyToModeResolver('first'),
|
|
447
|
+
},
|
|
448
|
+
|
|
449
|
+
outbound: {
|
|
450
|
+
deliveryMode: 'direct',
|
|
451
|
+
chunker: chunkTextForOutbound,
|
|
452
|
+
chunkerMode: 'text',
|
|
453
|
+
textChunkLimit: 4000,
|
|
454
|
+
...imweRawSendResultAdapter,
|
|
455
|
+
},
|
|
456
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* config-schema.ts — Zod 配置 schema
|
|
3
|
+
*
|
|
4
|
+
* 用于运行时验证用户配置,与 openclaw.plugin.json 中的 JSON Schema 保持一致。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
AllowFromListSchema,
|
|
9
|
+
buildCatchallMultiAccountChannelSchema,
|
|
10
|
+
DmPolicySchema,
|
|
11
|
+
} from 'openclaw/plugin-sdk/channel-config-schema';
|
|
12
|
+
import { z } from 'openclaw/plugin-sdk/zod';
|
|
13
|
+
|
|
14
|
+
const imweAccountSchema = z.object({
|
|
15
|
+
name: z.string().optional(),
|
|
16
|
+
enabled: z.boolean().optional(),
|
|
17
|
+
apiBaseUrl: z.string().optional(),
|
|
18
|
+
appKey: z.string().optional(),
|
|
19
|
+
appSecret: z.string().optional(),
|
|
20
|
+
dmPolicy: DmPolicySchema.optional(),
|
|
21
|
+
allowFrom: AllowFromListSchema,
|
|
22
|
+
/** 短轮询间隔(毫秒),默认 3000 */
|
|
23
|
+
pollIntervalMs: z.number().optional(),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export const ImweConfigSchema = buildCatchallMultiAccountChannelSchema(imweAccountSchema);
|