@xgjktech/xg_cwork_im 1.11.3 → 1.11.4
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/README.md +10 -10
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/{index.ts → dist/index.js} +5 -9
- package/dist/index.js.map +1 -0
- package/dist/src/auth.d.ts +25 -0
- package/dist/src/auth.d.ts.map +1 -0
- package/{src/auth.ts → dist/src/auth.js} +55 -79
- package/dist/src/auth.js.map +1 -0
- package/dist/src/channel.d.ts +13 -0
- package/dist/src/channel.d.ts.map +1 -0
- package/{src/channel.ts → dist/src/channel.js} +180 -385
- package/dist/src/channel.js.map +1 -0
- package/dist/src/connection.d.ts +51 -0
- package/dist/src/connection.d.ts.map +1 -0
- package/{src/connection.ts → dist/src/connection.js} +45 -122
- package/dist/src/connection.js.map +1 -0
- package/dist/src/group-history-tool.d.ts +23 -0
- package/dist/src/group-history-tool.d.ts.map +1 -0
- package/{src/group-history-tool.ts → dist/src/group-history-tool.js} +168 -204
- package/dist/src/group-history-tool.js.map +1 -0
- package/dist/src/inbound-media-local.d.ts +40 -0
- package/dist/src/inbound-media-local.d.ts.map +1 -0
- package/{src/inbound-media-local.ts → dist/src/inbound-media-local.js} +52 -90
- package/dist/src/inbound-media-local.js.map +1 -0
- package/dist/src/recommended-system-prompt.d.ts +3 -0
- package/dist/src/recommended-system-prompt.d.ts.map +1 -0
- package/dist/src/recommended-system-prompt.js +3 -0
- package/dist/src/recommended-system-prompt.js.map +1 -0
- package/dist/src/resource-file.d.ts +65 -0
- package/dist/src/resource-file.d.ts.map +1 -0
- package/{src/resource-file.ts → dist/src/resource-file.js} +77 -99
- package/dist/src/resource-file.js.map +1 -0
- package/dist/src/send-group-message-tool.d.ts +16 -0
- package/dist/src/send-group-message-tool.d.ts.map +1 -0
- package/{src/send-group-message-tool.ts → dist/src/send-group-message-tool.js} +17 -34
- package/dist/src/send-group-message-tool.js.map +1 -0
- package/dist/src/send-service.d.ts +34 -0
- package/dist/src/send-service.d.ts.map +1 -0
- package/{src/send-service.ts → dist/src/send-service.js} +60 -146
- package/dist/src/send-service.js.map +1 -0
- package/{src/tool-json-result.ts → dist/src/tool-json-result.d.ts} +14 -24
- package/dist/src/tool-json-result.d.ts.map +1 -0
- package/dist/src/tool-json-result.js +20 -0
- package/dist/src/tool-json-result.js.map +1 -0
- package/{src/types.ts → dist/src/types.d.ts} +26 -94
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +32 -0
- package/dist/src/types.js.map +1 -0
- package/package.json +7 -6
- package/src/recommended-system-prompt.ts +0 -3
|
@@ -7,44 +7,24 @@
|
|
|
7
7
|
* - gateway : 启动 WebSocket 长连接,接收 robotMention 消息后通过
|
|
8
8
|
* PluginRuntime 路由给 OpenClaw 处理
|
|
9
9
|
*/
|
|
10
|
-
|
|
11
10
|
import { randomUUID } from "node:crypto";
|
|
12
|
-
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
13
11
|
import { buildChannelConfigSchema } from "openclaw/plugin-sdk";
|
|
14
12
|
import { z } from "zod";
|
|
15
13
|
import { clearTokenCache, getToken } from "./auth.js";
|
|
16
|
-
import { startWebSocket
|
|
14
|
+
import { startWebSocket } from "./connection.js";
|
|
17
15
|
import { normalizeInboundWorkspaceSubdir, saveInboundFilesToWorkspace } from "./inbound-media-local.js";
|
|
18
|
-
import { sendReplyDeliverBlock, sendTextMessage
|
|
19
|
-
import {
|
|
20
|
-
imFormatToMimeType,
|
|
21
|
-
type BotIdentity,
|
|
22
|
-
type GatewayStartContext,
|
|
23
|
-
type MsgFileVO,
|
|
24
|
-
type PluginRuntime,
|
|
25
|
-
type ResolvedAccount,
|
|
26
|
-
type WsMessage,
|
|
27
|
-
type WsMessageContent,
|
|
28
|
-
type WsMessageParams,
|
|
29
|
-
type XgImChannelPlugin,
|
|
30
|
-
type XgImConfig,
|
|
31
|
-
} from "./types.js";
|
|
32
|
-
|
|
16
|
+
import { sendReplyDeliverBlock, sendTextMessage } from "./send-service.js";
|
|
33
17
|
// ─── 全局 Runtime(在 index.ts 的 register 中注入)────────────────────────────
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
export function setXgImRuntime(rt: PluginRuntime): void {
|
|
18
|
+
let xgImRuntime = null;
|
|
19
|
+
export function setXgImRuntime(rt) {
|
|
38
20
|
xgImRuntime = rt;
|
|
39
21
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
22
|
+
function getXgImRuntime() {
|
|
23
|
+
if (!xgImRuntime)
|
|
24
|
+
throw new Error("[cwork_im] Plugin runtime not initialized");
|
|
43
25
|
return xgImRuntime;
|
|
44
26
|
}
|
|
45
|
-
|
|
46
27
|
// ─── 配置 Schema ──────────────────────────────────────────────────────────────
|
|
47
|
-
|
|
48
28
|
const XgImAccountConfigSchema = z.object({
|
|
49
29
|
// 注意:为了兼容 OpenClaw doctor 生成的 accounts.default(仅包含默认项,不含 appKey/robotKey),这里先放宽为 optional,
|
|
50
30
|
// 然后在 XgImConfigSchema.superRefine 中强制校验:除 default 之外的账号必须提供 robotKey 或 appKey。
|
|
@@ -60,7 +40,6 @@ const XgImAccountConfigSchema = z.object({
|
|
|
60
40
|
inboundMediaSandboxRootPrefix: z.string().optional(),
|
|
61
41
|
systemPrompt: z.string().optional(),
|
|
62
42
|
});
|
|
63
|
-
|
|
64
43
|
const XgImConfigSchema = z.object({
|
|
65
44
|
robotKey: z.string().optional(),
|
|
66
45
|
appKey: z.string().optional(),
|
|
@@ -91,21 +70,15 @@ const XgImConfigSchema = z.object({
|
|
|
91
70
|
// 多账户:对象 map(key 为 accountId)
|
|
92
71
|
accounts: z.record(z.string(), XgImAccountConfigSchema).optional(),
|
|
93
72
|
}).superRefine((val, ctx) => {
|
|
94
|
-
const badSubdir = (s
|
|
95
|
-
typeof s === "string" && s.trim().length > 0 && normalizeInboundWorkspaceSubdir(s) == null;
|
|
96
|
-
|
|
73
|
+
const badSubdir = (s) => typeof s === "string" && s.trim().length > 0 && normalizeInboundWorkspaceSubdir(s) == null;
|
|
97
74
|
if (badSubdir(val.inboundMediaWorkspaceSubdir)) {
|
|
98
75
|
ctx.addIssue({
|
|
99
76
|
code: z.ZodIssueCode.custom,
|
|
100
77
|
path: ["inboundMediaWorkspaceSubdir"],
|
|
101
|
-
message:
|
|
102
|
-
"inboundMediaWorkspaceSubdir must be a relative path under workspace (no .., no absolute path)",
|
|
78
|
+
message: "inboundMediaWorkspaceSubdir must be a relative path under workspace (no .., no absolute path)",
|
|
103
79
|
});
|
|
104
80
|
}
|
|
105
|
-
|
|
106
|
-
const badSandboxPrefix = (s: string | undefined): boolean =>
|
|
107
|
-
typeof s === "string" && s.trim().length > 0 && !s.trim().startsWith("/");
|
|
108
|
-
|
|
81
|
+
const badSandboxPrefix = (s) => typeof s === "string" && s.trim().length > 0 && !s.trim().startsWith("/");
|
|
109
82
|
if (badSandboxPrefix(val.inboundMediaSandboxRootPrefix)) {
|
|
110
83
|
ctx.addIssue({
|
|
111
84
|
code: z.ZodIssueCode.custom,
|
|
@@ -113,12 +86,14 @@ const XgImConfigSchema = z.object({
|
|
|
113
86
|
message: "inboundMediaSandboxRootPrefix must be empty or an absolute path such as /workspace",
|
|
114
87
|
});
|
|
115
88
|
}
|
|
116
|
-
|
|
117
89
|
const accounts = val.accounts;
|
|
118
|
-
if (!accounts)
|
|
90
|
+
if (!accounts)
|
|
91
|
+
return;
|
|
119
92
|
for (const [key, acc] of Object.entries(accounts)) {
|
|
120
|
-
if (key === "default")
|
|
121
|
-
|
|
93
|
+
if (key === "default")
|
|
94
|
+
continue;
|
|
95
|
+
if (!acc || typeof acc !== "object")
|
|
96
|
+
continue;
|
|
122
97
|
if (!acc.robotKey && !acc.appKey) {
|
|
123
98
|
ctx.addIssue({
|
|
124
99
|
code: z.ZodIssueCode.custom,
|
|
@@ -130,122 +105,104 @@ const XgImConfigSchema = z.object({
|
|
|
130
105
|
ctx.addIssue({
|
|
131
106
|
code: z.ZodIssueCode.custom,
|
|
132
107
|
path: ["accounts", key, "inboundMediaWorkspaceSubdir"],
|
|
133
|
-
message:
|
|
134
|
-
"inboundMediaWorkspaceSubdir must be a relative path under workspace (no .., no absolute path)",
|
|
108
|
+
message: "inboundMediaWorkspaceSubdir must be a relative path under workspace (no .., no absolute path)",
|
|
135
109
|
});
|
|
136
110
|
}
|
|
137
111
|
if (badSandboxPrefix(acc.inboundMediaSandboxRootPrefix)) {
|
|
138
112
|
ctx.addIssue({
|
|
139
113
|
code: z.ZodIssueCode.custom,
|
|
140
114
|
path: ["accounts", key, "inboundMediaSandboxRootPrefix"],
|
|
141
|
-
message:
|
|
142
|
-
"inboundMediaSandboxRootPrefix must be empty or an absolute path such as /workspace",
|
|
115
|
+
message: "inboundMediaSandboxRootPrefix must be empty or an absolute path such as /workspace",
|
|
143
116
|
});
|
|
144
117
|
}
|
|
145
118
|
}
|
|
146
119
|
});
|
|
147
|
-
|
|
148
120
|
// ─── 辅助函数 ─────────────────────────────────────────────────────────────────
|
|
149
|
-
|
|
150
121
|
/**
|
|
151
122
|
* 规范化 key 字段:优先取 robotKey,没有则取 appKey,统一写入 appKey。
|
|
152
123
|
* 这样下游代码(auth.ts 等)只需读 config.appKey,无需感知 robotKey 的存在。
|
|
153
124
|
*/
|
|
154
|
-
function normalizeRobotKey
|
|
125
|
+
function normalizeRobotKey(config) {
|
|
155
126
|
const resolved = config.robotKey ?? config.appKey;
|
|
156
|
-
if (resolved === config.appKey)
|
|
127
|
+
if (resolved === config.appKey)
|
|
128
|
+
return config;
|
|
157
129
|
return { ...config, appKey: resolved };
|
|
158
130
|
}
|
|
159
|
-
|
|
160
131
|
/** 从顶层 cfg 中取出 XgImConfig,支持多账户(README:channels.xg_cwork_im) */
|
|
161
|
-
function getXgImConfig(cfg
|
|
162
|
-
const raw =
|
|
163
|
-
if (!raw)
|
|
164
|
-
|
|
165
|
-
const accounts = raw.accounts
|
|
166
|
-
const accountMap =
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
: undefined;
|
|
132
|
+
function getXgImConfig(cfg, accountId) {
|
|
133
|
+
const raw = cfg?.channels?.xg_cwork_im;
|
|
134
|
+
if (!raw)
|
|
135
|
+
throw new Error("[cwork_im] channels.xg_cwork_im config not found");
|
|
136
|
+
const accounts = raw.accounts;
|
|
137
|
+
const accountMap = accounts && typeof accounts === "object" && !Array.isArray(accounts)
|
|
138
|
+
? accounts
|
|
139
|
+
: undefined;
|
|
170
140
|
const defaults = accountMap?.default ? { ...accountMap.default } : undefined;
|
|
171
|
-
|
|
172
141
|
// 指定了具体账户 ID 时,优先合并对应账户配置
|
|
173
142
|
if (accountId && accountId !== "default" && accountMap) {
|
|
174
143
|
const sub = accountMap[accountId];
|
|
175
|
-
if (sub)
|
|
144
|
+
if (sub)
|
|
145
|
+
return normalizeRobotKey({ ...raw, ...(defaults ?? {}), ...sub });
|
|
176
146
|
}
|
|
177
|
-
|
|
178
147
|
// 没有指定 accountId(如 Cron outbound 场景),自动 fallback 到「第一个账户」
|
|
179
148
|
// 避免顶层 raw 没有 appKey/robotKey 时 getToken 失败
|
|
180
149
|
if (accountMap && !raw.appKey && !raw.robotKey) {
|
|
181
150
|
const firstKey = Object.keys(accountMap).find((k) => k !== "default");
|
|
182
151
|
const first = firstKey ? accountMap[firstKey] : undefined;
|
|
183
|
-
if (first)
|
|
152
|
+
if (first)
|
|
153
|
+
return normalizeRobotKey({ ...raw, ...(defaults ?? {}), ...first });
|
|
184
154
|
}
|
|
185
|
-
|
|
186
155
|
return normalizeRobotKey(raw);
|
|
187
156
|
}
|
|
188
|
-
|
|
189
157
|
/**
|
|
190
158
|
* 合并 `channels.xg_cwork_im` 顶层、`accounts.default`、以及 `accounts.<accountId>` 后的 `systemPrompt`,
|
|
191
159
|
* 写入 OpenClaw 入站 `GroupSystemPrompt`(与 getXgImConfig 的非 default 账户合并方式一致;对 default 账户会合并 default 片段)。
|
|
192
160
|
*/
|
|
193
|
-
function resolveXgImGroupSystemPrompt(cfg
|
|
161
|
+
function resolveXgImGroupSystemPrompt(cfg, accountId) {
|
|
194
162
|
try {
|
|
195
|
-
const raw =
|
|
196
|
-
if (!raw)
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
163
|
+
const raw = cfg?.channels?.xg_cwork_im;
|
|
164
|
+
if (!raw)
|
|
165
|
+
return undefined;
|
|
166
|
+
const accountMap = raw.accounts && typeof raw.accounts === "object" && !Array.isArray(raw.accounts)
|
|
167
|
+
? raw.accounts
|
|
168
|
+
: undefined;
|
|
201
169
|
const defaults = accountMap?.default ? { ...accountMap.default } : {};
|
|
202
|
-
const base
|
|
203
|
-
let merged
|
|
170
|
+
const base = { ...raw, ...defaults };
|
|
171
|
+
let merged = base;
|
|
204
172
|
if (accountId && accountId !== "default" && accountMap?.[accountId]) {
|
|
205
173
|
merged = { ...base, ...accountMap[accountId] };
|
|
206
174
|
}
|
|
207
175
|
const t = merged.systemPrompt?.trim();
|
|
208
176
|
return t || undefined;
|
|
209
|
-
}
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
210
179
|
return undefined;
|
|
211
180
|
}
|
|
212
181
|
}
|
|
213
|
-
|
|
214
|
-
function isConfigured(cfg: OpenClawConfig): boolean {
|
|
182
|
+
function isConfigured(cfg) {
|
|
215
183
|
try {
|
|
216
184
|
const c = getXgImConfig(cfg);
|
|
217
185
|
return Boolean(c?.appKey && c?.baseUrl);
|
|
218
|
-
}
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
219
188
|
return false;
|
|
220
189
|
}
|
|
221
190
|
}
|
|
222
|
-
|
|
223
191
|
/**
|
|
224
192
|
* OpenClaw 对入站命令:`CommandAuthorized === undefined` 会按 false 处理,文本 /command 会被静默忽略(仍走普通 AI)。
|
|
225
193
|
* 见 openclaw LINE #26996 等修复。此处与 channels.xg_cwork_im.allowFrom 对齐;allowFrom 为空表示不限制发送者。
|
|
226
194
|
*/
|
|
227
|
-
function isSenderInXgImAllowFrom(config
|
|
195
|
+
function isSenderInXgImAllowFrom(config, senderId) {
|
|
228
196
|
const allow = config.allowFrom ?? [];
|
|
229
|
-
if (allow.length === 0)
|
|
197
|
+
if (allow.length === 0)
|
|
198
|
+
return true;
|
|
230
199
|
const id = senderId?.trim();
|
|
231
|
-
if (!id)
|
|
200
|
+
if (!id)
|
|
201
|
+
return false;
|
|
232
202
|
return allow.includes(id);
|
|
233
203
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
rt: unknown,
|
|
237
|
-
cfg: OpenClawConfig,
|
|
238
|
-
p: { accountId: string; senderId: string | undefined; config: XgImConfig },
|
|
239
|
-
): boolean {
|
|
240
|
-
const r = rt as {
|
|
241
|
-
channel?: {
|
|
242
|
-
commands?: {
|
|
243
|
-
resolveControlCommandGate?: (args: Record<string, unknown>) =>
|
|
244
|
-
| boolean
|
|
245
|
-
| { commandAuthorized?: boolean };
|
|
246
|
-
};
|
|
247
|
-
};
|
|
248
|
-
};
|
|
204
|
+
function resolveInboundCommandAuthorized(rt, cfg, p) {
|
|
205
|
+
const r = rt;
|
|
249
206
|
const fn = r?.channel?.commands?.resolveControlCommandGate;
|
|
250
207
|
if (typeof fn === "function") {
|
|
251
208
|
try {
|
|
@@ -256,28 +213,19 @@ function resolveInboundCommandAuthorized(
|
|
|
256
213
|
senderId: p.senderId ?? "",
|
|
257
214
|
chatType: "group",
|
|
258
215
|
});
|
|
259
|
-
if (typeof out === "boolean")
|
|
216
|
+
if (typeof out === "boolean")
|
|
217
|
+
return out;
|
|
260
218
|
if (out && typeof out === "object" && "commandAuthorized" in out) {
|
|
261
|
-
return Boolean(
|
|
219
|
+
return Boolean(out.commandAuthorized);
|
|
262
220
|
}
|
|
263
|
-
}
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
264
223
|
/* 回退到 allowFrom */
|
|
265
224
|
}
|
|
266
225
|
}
|
|
267
226
|
return isSenderInXgImAllowFrom(p.config, p.senderId);
|
|
268
227
|
}
|
|
269
|
-
|
|
270
|
-
// ─── 日志适配器 ───────────────────────────────────────────────────────────────
|
|
271
|
-
// ChannelLogSink 的 info/warn/error 方法接受单个 string
|
|
272
|
-
|
|
273
|
-
interface Logger {
|
|
274
|
-
info: (msg: string) => void;
|
|
275
|
-
warn: (msg: string) => void;
|
|
276
|
-
error: (msg: string) => void;
|
|
277
|
-
debug?: (msg: string) => void;
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
function toLogger(sink: { info?: (msg: string) => void; warn?: (msg: string) => void; error?: (msg: string) => void; debug?: (msg: string) => void } | undefined): Logger {
|
|
228
|
+
function toLogger(sink) {
|
|
281
229
|
return {
|
|
282
230
|
info: (msg) => sink?.info?.(msg),
|
|
283
231
|
warn: (msg) => sink?.warn?.(msg),
|
|
@@ -285,14 +233,11 @@ function toLogger(sink: { info?: (msg: string) => void; warn?: (msg: string) =>
|
|
|
285
233
|
debug: (msg) => sink?.debug?.(msg),
|
|
286
234
|
};
|
|
287
235
|
}
|
|
288
|
-
|
|
289
236
|
// ─── WebSocket 入站:纯函数与 @ 后派发 ───────────────────────────────────────
|
|
290
|
-
|
|
291
|
-
function collectFileItems(msgContent: WsMessageContent | undefined): MsgFileVO[] {
|
|
237
|
+
function collectFileItems(msgContent) {
|
|
292
238
|
return (msgContent?.files ?? []).filter((f) => f.url?.trim());
|
|
293
239
|
}
|
|
294
|
-
|
|
295
|
-
function buildInboundDisplayText(msgContent: WsMessageContent | undefined, fileItems: MsgFileVO[]): string {
|
|
240
|
+
function buildInboundDisplayText(msgContent, fileItems) {
|
|
296
241
|
let rawText = msgContent?.text ?? "";
|
|
297
242
|
if (fileItems.length > 0) {
|
|
298
243
|
const lines = fileItems.map((f) => {
|
|
@@ -301,67 +246,54 @@ function buildInboundDisplayText(msgContent: WsMessageContent | undefined, fileI
|
|
|
301
246
|
});
|
|
302
247
|
const block = `**附件(${fileItems.length})**\n${lines.join("\n")}`;
|
|
303
248
|
rawText = rawText.trim() ? `${rawText.trim()}\n\n${block}` : block;
|
|
304
|
-
|
|
305
249
|
const parsedParts = fileItems
|
|
306
250
|
.map((f) => {
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
}
|
|
312
|
-
|
|
251
|
+
const c = f.content?.trim();
|
|
252
|
+
if (!c)
|
|
253
|
+
return null;
|
|
254
|
+
const name = f.name?.trim() || f.fileId || "未命名文件";
|
|
255
|
+
return `### ${name}\n\n${c}`;
|
|
256
|
+
})
|
|
257
|
+
.filter((x) => x != null);
|
|
313
258
|
if (parsedParts.length > 0) {
|
|
314
259
|
const parsedBlock = `**附件解析内容**\n\n${parsedParts.join("\n\n")}`;
|
|
315
260
|
rawText = `${rawText.trim()}\n\n${parsedBlock}`;
|
|
316
261
|
}
|
|
317
|
-
|
|
318
|
-
const urlGuard =
|
|
319
|
-
"**附件 URL 使用约束**\n" +
|
|
262
|
+
const urlGuard = "**附件 URL 使用约束**\n" +
|
|
320
263
|
"处理附件时必须逐字原样使用消息中提供的完整 URL;禁止修改、删除或重排任何 query 参数。";
|
|
321
264
|
rawText = `${rawText.trim()}\n\n${urlGuard}`;
|
|
322
265
|
}
|
|
323
266
|
return rawText;
|
|
324
267
|
}
|
|
325
|
-
|
|
326
|
-
function isActuallyMentioned(
|
|
327
|
-
msg: WsMessage,
|
|
328
|
-
params: WsMessageParams,
|
|
329
|
-
bot: { userId: string; name: string },
|
|
330
|
-
rawTextForLegacy: string,
|
|
331
|
-
): boolean {
|
|
268
|
+
function isActuallyMentioned(msg, params, bot, rawTextForLegacy) {
|
|
332
269
|
const mentions = params.mentions;
|
|
333
|
-
const isMentioned =
|
|
334
|
-
|
|
335
|
-
const isLegacyMentioned =
|
|
336
|
-
msg.cmd === "robotMention" &&
|
|
270
|
+
const isMentioned = Array.isArray(mentions) && (mentions.includes(bot.userId) || mentions.includes("all"));
|
|
271
|
+
const isLegacyMentioned = msg.cmd === "robotMention" &&
|
|
337
272
|
(!params.mentions || params.mentions.length === 0) &&
|
|
338
273
|
new RegExp(`@${bot.name}\\b`).test(rawTextForLegacy);
|
|
339
274
|
return isMentioned || isLegacyMentioned;
|
|
340
275
|
}
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
276
|
+
function mediaFieldsFromFileItems(fileItems) {
|
|
277
|
+
if (fileItems.length === 0)
|
|
278
|
+
return {};
|
|
344
279
|
// 只传 MediaPath(s),不传 MediaType(s)。
|
|
345
280
|
// 预签名 URL 的 query 中常含 response-content-type;若额外给 MediaType,模型/工具更容易“改写 URL”导致签名失效。
|
|
346
281
|
if (fileItems.length === 1) {
|
|
347
282
|
return {
|
|
348
|
-
MediaPath: fileItems[0]
|
|
283
|
+
MediaPath: fileItems[0].url,
|
|
349
284
|
};
|
|
350
285
|
}
|
|
351
286
|
return {
|
|
352
287
|
MediaPaths: fileItems.map((f) => f.url),
|
|
353
288
|
};
|
|
354
289
|
}
|
|
355
|
-
|
|
356
290
|
/** ext / 用户 background / 附件元数据(不含下载 URL,避免预签名链过长;URL 已在入站 MediaPath(s)) */
|
|
357
|
-
function buildUntrustedContext(
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
)
|
|
362
|
-
|
|
363
|
-
if (msgExt) parts.push(JSON.stringify(msgExt));
|
|
364
|
-
if (senderBackground) parts.push(String(senderBackground));
|
|
291
|
+
function buildUntrustedContext(msgExt, senderBackground, fileItems) {
|
|
292
|
+
const parts = [];
|
|
293
|
+
if (msgExt)
|
|
294
|
+
parts.push(JSON.stringify(msgExt));
|
|
295
|
+
if (senderBackground)
|
|
296
|
+
parts.push(String(senderBackground));
|
|
365
297
|
if (fileItems.length > 0) {
|
|
366
298
|
const summary = fileItems.map((f) => ({
|
|
367
299
|
name: f.name ?? null,
|
|
@@ -374,13 +306,7 @@ function buildUntrustedContext(
|
|
|
374
306
|
}
|
|
375
307
|
return parts.length > 0 ? parts : undefined;
|
|
376
308
|
}
|
|
377
|
-
|
|
378
|
-
function buildTargetReplyMeta(params: WsMessageParams): {
|
|
379
|
-
targetMsgId: string;
|
|
380
|
-
targetUserId: string;
|
|
381
|
-
targetUserName: string;
|
|
382
|
-
previewText: string;
|
|
383
|
-
} {
|
|
309
|
+
function buildTargetReplyMeta(params) {
|
|
384
310
|
return {
|
|
385
311
|
targetMsgId: params.msgId,
|
|
386
312
|
targetUserId: params.userInfo?.id ?? "",
|
|
@@ -388,67 +314,41 @@ function buildTargetReplyMeta(params: WsMessageParams): {
|
|
|
388
314
|
previewText: params.msgContent?.text ?? "",
|
|
389
315
|
};
|
|
390
316
|
}
|
|
391
|
-
|
|
392
317
|
/** 流式 START → dispatch → 必发 END;首包超时走 HTTP 覆盖占位 */
|
|
393
|
-
async function dispatchMentionedReply(args
|
|
394
|
-
rt: any;
|
|
395
|
-
cfg: OpenClawConfig;
|
|
396
|
-
config: XgImConfig;
|
|
397
|
-
log: Logger;
|
|
398
|
-
logPrefix: string;
|
|
399
|
-
route: { sessionKey: string };
|
|
400
|
-
inboundCtx: unknown;
|
|
401
|
-
params: WsMessageParams;
|
|
402
|
-
currentIdentity: BotIdentity;
|
|
403
|
-
streamClient: ImStreamClient;
|
|
404
|
-
}): Promise<void> {
|
|
318
|
+
async function dispatchMentionedReply(args) {
|
|
405
319
|
const { rt, cfg, config, log, logPrefix, route, inboundCtx, params, currentIdentity, streamClient } = args;
|
|
406
|
-
|
|
407
320
|
log.info(`${logPrefix} [dispatch] Dispatching to OpenClaw AI, sessionKey=${route.sessionKey}`);
|
|
408
321
|
let isFirstReply = true;
|
|
409
322
|
const dispatchStart = Date.now();
|
|
410
|
-
|
|
411
323
|
const { msgId: streamMsgId } = await streamClient.start({
|
|
412
324
|
groupId: params.groupId,
|
|
413
325
|
});
|
|
414
326
|
log.info(`${logPrefix} [stream] START acknowledged: msgId=${streamMsgId}`);
|
|
415
|
-
|
|
416
327
|
let hasFirstReply = false;
|
|
417
328
|
const firstReplyTimeoutMs = config.firstReplyTimeoutMs ?? 30 * 60_000;
|
|
418
329
|
const timeoutLabel = `${logPrefix} [stream] First reply timeout after ${firstReplyTimeoutMs}ms, updating thinking message as error`;
|
|
419
330
|
const firstReplyTimeout = setTimeout(async () => {
|
|
420
|
-
if (hasFirstReply)
|
|
331
|
+
if (hasFirstReply)
|
|
332
|
+
return;
|
|
421
333
|
log.warn(timeoutLabel);
|
|
422
334
|
try {
|
|
423
335
|
const reply = buildTargetReplyMeta(params);
|
|
424
336
|
const timeoutText = "当前请求处理超时,请稍后重试。";
|
|
425
|
-
await sendTextMessage(
|
|
426
|
-
config,
|
|
427
|
-
currentIdentity.token,
|
|
428
|
-
params.groupId,
|
|
429
|
-
timeoutText,
|
|
430
|
-
[reply.targetUserId] as string[],
|
|
431
|
-
log,
|
|
432
|
-
streamMsgId,
|
|
433
|
-
reply,
|
|
434
|
-
);
|
|
337
|
+
await sendTextMessage(config, currentIdentity.token, params.groupId, timeoutText, [reply.targetUserId], log, streamMsgId, reply);
|
|
435
338
|
// 标记已发送,让 finally 以 reason=stop 结束,避免后台再显示"AI未响应"
|
|
436
339
|
hasFirstReply = true;
|
|
437
|
-
log.info(
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
} catch (err: unknown) {
|
|
340
|
+
log.info(`${logPrefix} [send] Timeout reply sent via HTTP: groupId=${params.groupId} msgId=${streamMsgId} text="${timeoutText}"`);
|
|
341
|
+
}
|
|
342
|
+
catch (err) {
|
|
441
343
|
log.error(`${logPrefix} [timeout] Failed to send timeout reply: ${String(err)}`);
|
|
442
344
|
}
|
|
443
345
|
}, firstReplyTimeoutMs);
|
|
444
|
-
|
|
445
346
|
let deliverCallCount = 0;
|
|
446
347
|
let deliverSkippedCount = 0;
|
|
447
|
-
let dispatchError
|
|
348
|
+
let dispatchError = undefined;
|
|
448
349
|
// 捕获 deliver 抛出后被 dispatcher 静默吞掉的错误:postImMessage 已带 5 次重试,
|
|
449
350
|
// 进到这里说明所有重试均失败(IM 后台不可用 / 4xx / resultCode 业务错误等),需要让 finally 走兜底分支
|
|
450
|
-
let lastDeliverError
|
|
451
|
-
|
|
351
|
+
let lastDeliverError = undefined;
|
|
452
352
|
try {
|
|
453
353
|
await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
454
354
|
ctx: inboundCtx,
|
|
@@ -456,100 +356,61 @@ async function dispatchMentionedReply(args: {
|
|
|
456
356
|
dispatcherOptions: {
|
|
457
357
|
responsePrefix: "",
|
|
458
358
|
// Logged when normalizeReplyPayload decides to skip a payload (e.g. heartbeat, silent token, empty).
|
|
459
|
-
onSkip: (payload
|
|
359
|
+
onSkip: (payload, meta) => {
|
|
460
360
|
deliverSkippedCount++;
|
|
461
|
-
log.info(
|
|
462
|
-
`${logPrefix} [deliver] Payload skipped by normalizer kind=${meta.kind} reason=${meta.reason}`,
|
|
463
|
-
);
|
|
361
|
+
log.info(`${logPrefix} [deliver] Payload skipped by normalizer kind=${meta.kind} reason=${meta.reason}`);
|
|
464
362
|
},
|
|
465
363
|
// Logged when deliver throws and the dispatcher catches it (error would otherwise be silently dropped).
|
|
466
|
-
onError: (err
|
|
364
|
+
onError: (err, meta) => {
|
|
467
365
|
lastDeliverError = err;
|
|
468
|
-
log.error(
|
|
469
|
-
`${logPrefix} [deliver] Dispatcher caught unhandled error kind=${meta.kind}: ${String(err)}`,
|
|
470
|
-
);
|
|
366
|
+
log.error(`${logPrefix} [deliver] Dispatcher caught unhandled error kind=${meta.kind}: ${String(err)}`);
|
|
471
367
|
},
|
|
472
|
-
deliver: async (payload
|
|
368
|
+
deliver: async (payload) => {
|
|
473
369
|
try {
|
|
474
370
|
const textPart = (payload.markdown || payload.text || "").trim();
|
|
475
|
-
const hasMedia =
|
|
476
|
-
Boolean(payload.mediaUrl?.trim()) ||
|
|
371
|
+
const hasMedia = Boolean(payload.mediaUrl?.trim()) ||
|
|
477
372
|
Boolean(payload.mediaUrls?.some((u) => typeof u === "string" && u.trim()));
|
|
478
373
|
if (!textPart && !(hasMedia && !payload.isThinking)) {
|
|
479
|
-
log.info(
|
|
480
|
-
|
|
481
|
-
` (isThinking=${payload.isThinking ?? false} hasMedia=${hasMedia})`,
|
|
482
|
-
);
|
|
374
|
+
log.info(`${logPrefix} [deliver] Payload has no sendable content, skipping` +
|
|
375
|
+
` (isThinking=${payload.isThinking ?? false} hasMedia=${hasMedia})`);
|
|
483
376
|
return;
|
|
484
377
|
}
|
|
485
|
-
|
|
486
378
|
deliverCallCount++;
|
|
487
379
|
if (isFirstReply) {
|
|
488
380
|
const ttfr = Date.now() - dispatchStart;
|
|
489
|
-
log.info(
|
|
490
|
-
`${logPrefix} [deliver] First response block received from AI (TTFB: ${ttfr}ms)`,
|
|
491
|
-
);
|
|
381
|
+
log.info(`${logPrefix} [deliver] First response block received from AI (TTFB: ${ttfr}ms)`);
|
|
492
382
|
isFirstReply = false;
|
|
493
383
|
}
|
|
494
|
-
|
|
495
384
|
const reply = buildTargetReplyMeta(params);
|
|
496
|
-
const atIds = [reply.targetUserId]
|
|
497
|
-
|
|
385
|
+
const atIds = [reply.targetUserId];
|
|
498
386
|
if (!hasFirstReply) {
|
|
499
387
|
clearTimeout(firstReplyTimeout);
|
|
500
|
-
await sendReplyDeliverBlock(
|
|
501
|
-
config,
|
|
502
|
-
currentIdentity.token,
|
|
503
|
-
params.groupId,
|
|
504
|
-
payload,
|
|
505
|
-
atIds,
|
|
506
|
-
log,
|
|
507
|
-
streamMsgId,
|
|
508
|
-
reply,
|
|
509
|
-
);
|
|
388
|
+
await sendReplyDeliverBlock(config, currentIdentity.token, params.groupId, payload, atIds, log, streamMsgId, reply);
|
|
510
389
|
hasFirstReply = true; // set after successful send
|
|
511
|
-
const preview =
|
|
512
|
-
|
|
513
|
-
log.info(
|
|
514
|
-
`${logPrefix} [send] First reply sent via HTTP: groupId=${params.groupId} msgId=${streamMsgId} preview="${preview}"`,
|
|
515
|
-
);
|
|
390
|
+
const preview = textPart.length > 80 ? `${textPart.slice(0, 80)}...` : textPart || "[media]";
|
|
391
|
+
log.info(`${logPrefix} [send] First reply sent via HTTP: groupId=${params.groupId} msgId=${streamMsgId} preview="${preview}"`);
|
|
516
392
|
return;
|
|
517
393
|
}
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
payload,
|
|
524
|
-
atIds,
|
|
525
|
-
log,
|
|
526
|
-
undefined,
|
|
527
|
-
reply,
|
|
528
|
-
);
|
|
529
|
-
const preview =
|
|
530
|
-
textPart.length > 80 ? `${textPart.slice(0, 80)}...` : textPart || "[media]";
|
|
531
|
-
log.info(
|
|
532
|
-
`${logPrefix} [send] Additional reply sent via HTTP: groupId=${params.groupId} preview="${preview}"`,
|
|
533
|
-
);
|
|
534
|
-
} catch (err: unknown) {
|
|
394
|
+
await sendReplyDeliverBlock(config, currentIdentity.token, params.groupId, payload, atIds, log, undefined, reply);
|
|
395
|
+
const preview = textPart.length > 80 ? `${textPart.slice(0, 80)}...` : textPart || "[media]";
|
|
396
|
+
log.info(`${logPrefix} [send] Additional reply sent via HTTP: groupId=${params.groupId} preview="${preview}"`);
|
|
397
|
+
}
|
|
398
|
+
catch (err) {
|
|
535
399
|
log.error(`${logPrefix} [deliver] Reply deliver failed: ${String(err)}`);
|
|
536
400
|
throw err;
|
|
537
401
|
}
|
|
538
402
|
},
|
|
539
403
|
},
|
|
540
404
|
});
|
|
541
|
-
log.info(
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
} catch (dispatchErr: unknown) {
|
|
405
|
+
log.info(`${logPrefix} [dispatch] Dispatch completed for sessionKey=${route.sessionKey}` +
|
|
406
|
+
` (delivered=${deliverCallCount} skipped=${deliverSkippedCount})`);
|
|
407
|
+
}
|
|
408
|
+
catch (dispatchErr) {
|
|
546
409
|
dispatchError = dispatchErr;
|
|
547
|
-
log.error(
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
} finally {
|
|
410
|
+
log.error(`${logPrefix} [dispatch] Dispatch failed (delivered=${deliverCallCount} skipped=${deliverSkippedCount}): ${String(dispatchErr)}`);
|
|
411
|
+
}
|
|
412
|
+
finally {
|
|
551
413
|
clearTimeout(firstReplyTimeout);
|
|
552
|
-
|
|
553
414
|
// 兜底原则:
|
|
554
415
|
// - 有"实际错误"(dispatch 自身异常 / deliver 全部重试后仍失败)且尚未发过任何消息
|
|
555
416
|
// → 主动告知用户,以 reason=stop 结束(后台信任已发消息)。
|
|
@@ -559,51 +420,36 @@ async function dispatchMentionedReply(args: {
|
|
|
559
420
|
const reportableError = dispatchError ?? lastDeliverError;
|
|
560
421
|
if (reportableError !== undefined) {
|
|
561
422
|
const errSource = dispatchError !== undefined ? "dispatch" : "deliver";
|
|
562
|
-
log.warn(
|
|
563
|
-
|
|
564
|
-
` (delivered=${deliverCallCount} skipped=${deliverSkippedCount}), sending error notice`,
|
|
565
|
-
);
|
|
423
|
+
log.warn(`${logPrefix} [dispatch] ${errSource} error with no prior reply` +
|
|
424
|
+
` (delivered=${deliverCallCount} skipped=${deliverSkippedCount}), sending error notice`);
|
|
566
425
|
const replyMeta = buildTargetReplyMeta(params);
|
|
567
426
|
try {
|
|
568
|
-
await sendTextMessage(
|
|
569
|
-
config,
|
|
570
|
-
currentIdentity.token,
|
|
571
|
-
params.groupId,
|
|
572
|
-
`当前请求处理时出现异常,请稍后重试。\n错误信息:${String(reportableError)}`,
|
|
573
|
-
[replyMeta.targetUserId] as string[],
|
|
574
|
-
log,
|
|
575
|
-
streamMsgId,
|
|
576
|
-
replyMeta,
|
|
577
|
-
);
|
|
427
|
+
await sendTextMessage(config, currentIdentity.token, params.groupId, `当前请求处理时出现异常,请稍后重试。\n错误信息:${String(reportableError)}`, [replyMeta.targetUserId], log, streamMsgId, replyMeta);
|
|
578
428
|
hasFirstReply = true;
|
|
579
|
-
} catch (fallbackErr: unknown) {
|
|
580
|
-
log.error(
|
|
581
|
-
`${logPrefix} [dispatch] Failed to send error notice (${errSource} error): ${String(fallbackErr)}`,
|
|
582
|
-
);
|
|
583
429
|
}
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
430
|
+
catch (fallbackErr) {
|
|
431
|
+
log.error(`${logPrefix} [dispatch] Failed to send error notice (${errSource} error): ${String(fallbackErr)}`);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
else {
|
|
435
|
+
log.warn(`${logPrefix} [dispatch] No reply was sent to user` +
|
|
436
|
+
` (delivered=${deliverCallCount} skipped=${deliverSkippedCount}), ending with reason=no_reply`);
|
|
589
437
|
}
|
|
590
438
|
}
|
|
591
|
-
|
|
592
439
|
// reason=stop:已通过 HTTP 发送了至少一条回复(含兜底错误消息),后台信任已有消息。
|
|
593
440
|
// reason=no_reply:本次无任何回复且无异常,后台凭此展示"AI未响应"。
|
|
594
441
|
const endReason = hasFirstReply ? "stop" : "no_reply";
|
|
595
442
|
try {
|
|
596
443
|
await streamClient.end(streamMsgId, endReason);
|
|
597
444
|
log.info(`${logPrefix} [stream] END sent: msgId=${streamMsgId} reason=${endReason}`);
|
|
598
|
-
}
|
|
445
|
+
}
|
|
446
|
+
catch (endErr) {
|
|
599
447
|
log.error(`${logPrefix} [stream] END failed (msgId=${streamMsgId}): ${String(endErr)}`);
|
|
600
448
|
}
|
|
601
449
|
}
|
|
602
450
|
}
|
|
603
|
-
|
|
604
451
|
// ─── Channel Plugin 定义 ─────────────────────────────────────────────────────
|
|
605
|
-
|
|
606
|
-
export const xgCworkImChannelPlugin: XgImChannelPlugin = {
|
|
452
|
+
export const xgCworkImChannelPlugin = {
|
|
607
453
|
id: "xg_cwork_im",
|
|
608
454
|
meta: {
|
|
609
455
|
id: "xg_cwork_im",
|
|
@@ -615,9 +461,9 @@ export const xgCworkImChannelPlugin: XgImChannelPlugin = {
|
|
|
615
461
|
},
|
|
616
462
|
// 双重类型转换绕过 zod v3/v4 的 TS 类型不兼容
|
|
617
463
|
// buildChannelConfigSchema 会将 schema 包装成可序列化的形式,避免 DataCloneError
|
|
618
|
-
configSchema: buildChannelConfigSchema(XgImConfigSchema
|
|
464
|
+
configSchema: buildChannelConfigSchema(XgImConfigSchema),
|
|
619
465
|
capabilities: {
|
|
620
|
-
chatTypes: ["group"]
|
|
466
|
+
chatTypes: ["group"],
|
|
621
467
|
reactions: false,
|
|
622
468
|
threads: false,
|
|
623
469
|
media: true,
|
|
@@ -625,27 +471,26 @@ export const xgCworkImChannelPlugin: XgImChannelPlugin = {
|
|
|
625
471
|
blockStreaming: false,
|
|
626
472
|
},
|
|
627
473
|
reload: { configPrefixes: ["channels.xg_cwork_im"] },
|
|
628
|
-
|
|
629
474
|
// ── 账户配置 ────────────────────────────────────────────────────────────────
|
|
630
475
|
config: {
|
|
631
|
-
listAccountIds: (cfg
|
|
476
|
+
listAccountIds: (cfg) => {
|
|
632
477
|
try {
|
|
633
478
|
const config = getXgImConfig(cfg);
|
|
634
|
-
const accounts = config.accounts
|
|
479
|
+
const accounts = config.accounts;
|
|
635
480
|
if (accounts) {
|
|
636
481
|
if (!Array.isArray(accounts) && typeof accounts === "object") {
|
|
637
482
|
// 推荐写法:对象 key 作为 accountId
|
|
638
|
-
const keys = Object.keys(accounts
|
|
483
|
+
const keys = Object.keys(accounts).filter((k) => k !== "default");
|
|
639
484
|
return keys;
|
|
640
485
|
}
|
|
641
486
|
}
|
|
642
487
|
return isConfigured(cfg) ? ["default"] : [];
|
|
643
|
-
}
|
|
488
|
+
}
|
|
489
|
+
catch {
|
|
644
490
|
return [];
|
|
645
491
|
}
|
|
646
492
|
},
|
|
647
|
-
|
|
648
|
-
resolveAccount: (cfg: OpenClawConfig, accountId?: string | null): ResolvedAccount => {
|
|
493
|
+
resolveAccount: (cfg, accountId) => {
|
|
649
494
|
const id = accountId || "default";
|
|
650
495
|
try {
|
|
651
496
|
const config = getXgImConfig(cfg, id);
|
|
@@ -656,166 +501,135 @@ export const xgCworkImChannelPlugin: XgImChannelPlugin = {
|
|
|
656
501
|
configured: Boolean(config.appKey && config.baseUrl),
|
|
657
502
|
name: config.name ?? null,
|
|
658
503
|
};
|
|
659
|
-
}
|
|
504
|
+
}
|
|
505
|
+
catch {
|
|
660
506
|
return {
|
|
661
507
|
accountId: id,
|
|
662
|
-
config: {}
|
|
508
|
+
config: {},
|
|
663
509
|
enabled: false,
|
|
664
510
|
configured: false,
|
|
665
511
|
name: null,
|
|
666
512
|
};
|
|
667
513
|
}
|
|
668
514
|
},
|
|
669
|
-
|
|
670
515
|
// 注意:OpenClaw 可能在缺省路由时使用 defaultAccountId。
|
|
671
516
|
// 我们使用 "default" 作为别名:getXgImConfig 会自动 fallback 到第一个非 default 的真实账号。
|
|
672
|
-
defaultAccountId: ()
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
describeAccount: (account: ResolvedAccount) => ({
|
|
517
|
+
defaultAccountId: () => "default",
|
|
518
|
+
isConfigured: (account) => account.configured,
|
|
519
|
+
describeAccount: (account) => ({
|
|
677
520
|
accountId: account.accountId,
|
|
678
521
|
name: account.config?.name ?? "xg_cwork_im",
|
|
679
522
|
enabled: account.enabled,
|
|
680
523
|
configured: account.configured,
|
|
681
524
|
}),
|
|
682
525
|
},
|
|
683
|
-
|
|
684
526
|
// ── 群聊设置 ─────────────────────────────────────────────────────────────────
|
|
685
527
|
groups: {
|
|
686
|
-
resolveRequireMention: (params)
|
|
528
|
+
resolveRequireMention: (params) => {
|
|
687
529
|
try {
|
|
688
530
|
const config = getXgImConfig(params.cfg, params.accountId);
|
|
689
531
|
return config?.groupPolicy !== "open";
|
|
690
|
-
}
|
|
532
|
+
}
|
|
533
|
+
catch {
|
|
691
534
|
return true; // 默认需要 @
|
|
692
535
|
}
|
|
693
536
|
},
|
|
694
537
|
},
|
|
695
|
-
|
|
696
538
|
// ── 出站消息(openclaw 主动发送时调用)────────────────────────────────────────
|
|
697
539
|
outbound: {
|
|
698
|
-
deliveryMode: "direct"
|
|
699
|
-
|
|
540
|
+
deliveryMode: "direct",
|
|
700
541
|
resolveTarget: (params) => {
|
|
701
542
|
const trimmed = params.to?.trim();
|
|
702
543
|
if (!trimmed) {
|
|
703
544
|
return {
|
|
704
|
-
ok: false
|
|
545
|
+
ok: false,
|
|
705
546
|
error: new Error("XG-IM message requires --to <groupId>"),
|
|
706
547
|
};
|
|
707
548
|
}
|
|
708
|
-
return { ok: true
|
|
549
|
+
return { ok: true, to: trimmed };
|
|
709
550
|
},
|
|
710
|
-
|
|
711
551
|
sendText: async (ctx) => {
|
|
712
|
-
const { cfg, to, text, accountId, log: ctxLog } = ctx
|
|
552
|
+
const { cfg, to, text, accountId, log: ctxLog } = ctx;
|
|
713
553
|
const log = toLogger(ctxLog);
|
|
714
554
|
const config = getXgImConfig(cfg, accountId);
|
|
715
555
|
const identity = await getToken(config, log);
|
|
716
|
-
|
|
717
556
|
await sendTextMessage(config, identity.token, to, text, [], log);
|
|
718
|
-
|
|
719
557
|
return {
|
|
720
558
|
channel: "xg_cwork_im",
|
|
721
559
|
messageId: randomUUID(),
|
|
722
560
|
};
|
|
723
561
|
},
|
|
724
|
-
|
|
725
562
|
/** 出站带媒体:上传资源后发 FILE,与网关 deliver 逻辑一致(OpenClaw 要求与 sendText 同时实现) */
|
|
726
563
|
sendMedia: async (ctx) => {
|
|
727
|
-
const { cfg, to, text, mediaUrl, accountId, log: ctxLog } = ctx
|
|
564
|
+
const { cfg, to, text, mediaUrl, accountId, log: ctxLog } = ctx;
|
|
728
565
|
const log = toLogger(ctxLog);
|
|
729
566
|
const config = getXgImConfig(cfg, accountId);
|
|
730
567
|
const identity = await getToken(config, log);
|
|
731
|
-
|
|
732
568
|
if (!mediaUrl?.trim()) {
|
|
733
569
|
await sendTextMessage(config, identity.token, to, text || "", [], log);
|
|
734
570
|
return { channel: "xg_cwork_im", messageId: randomUUID() };
|
|
735
571
|
}
|
|
736
|
-
|
|
737
|
-
await sendReplyDeliverBlock(
|
|
738
|
-
config,
|
|
739
|
-
identity.token,
|
|
740
|
-
to,
|
|
741
|
-
{ text: text || "", mediaUrl: mediaUrl.trim() },
|
|
742
|
-
[],
|
|
743
|
-
log,
|
|
744
|
-
undefined,
|
|
745
|
-
undefined,
|
|
746
|
-
);
|
|
747
|
-
|
|
572
|
+
await sendReplyDeliverBlock(config, identity.token, to, { text: text || "", mediaUrl: mediaUrl.trim() }, [], log, undefined, undefined);
|
|
748
573
|
return {
|
|
749
574
|
channel: "xg_cwork_im",
|
|
750
575
|
messageId: randomUUID(),
|
|
751
576
|
};
|
|
752
577
|
},
|
|
753
578
|
},
|
|
754
|
-
|
|
755
579
|
// ── 网关(WebSocket 长连接)─────────────────────────────────────────────────
|
|
756
580
|
gateway: {
|
|
757
|
-
startAccount: async (ctx
|
|
581
|
+
startAccount: async (ctx) => {
|
|
758
582
|
const account = ctx.account;
|
|
759
583
|
const config = account.config;
|
|
760
584
|
const log = toLogger(ctx.log);
|
|
761
585
|
const logPrefix = `[${account.accountId}:${config.agentId || "main"}]`;
|
|
762
|
-
|
|
763
586
|
if (!config.appKey || !config.baseUrl) {
|
|
764
587
|
throw new Error(`${logPrefix} appKey and baseUrl are required in config`);
|
|
765
588
|
}
|
|
766
|
-
|
|
767
589
|
log.info(`${logPrefix} Starting xg-cwork-im channel...`);
|
|
768
|
-
|
|
769
590
|
// 1. 获取机器人 token(有效期一年,缓存后无需重复请求)
|
|
770
591
|
const identity = await getToken(config, log);
|
|
771
|
-
|
|
772
592
|
// 2. 获取 PluginRuntime(用于路由消息给 OpenClaw)
|
|
773
593
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
774
|
-
const rt = getXgImRuntime()
|
|
775
|
-
|
|
594
|
+
const rt = getXgImRuntime();
|
|
776
595
|
// 3. 若 abort 信号已触发则不启动
|
|
777
596
|
if (ctx.abortSignal?.aborted) {
|
|
778
597
|
throw new Error(`${logPrefix} Connection aborted before start`);
|
|
779
598
|
}
|
|
780
|
-
|
|
781
599
|
// 4. 消息去重(按账户隔离)
|
|
782
|
-
const processedMsgIds = new Set
|
|
600
|
+
const processedMsgIds = new Set();
|
|
783
601
|
const MSG_DEDUP_MAX = 1_000;
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
602
|
+
const isDuplicate = (msgId) => {
|
|
603
|
+
if (processedMsgIds.has(msgId))
|
|
604
|
+
return true;
|
|
787
605
|
if (processedMsgIds.size >= MSG_DEDUP_MAX) {
|
|
788
606
|
const iter = processedMsgIds.values();
|
|
789
607
|
for (let i = 0; i < MSG_DEDUP_MAX / 2; i++) {
|
|
790
608
|
const val = iter.next().value;
|
|
791
|
-
if (val !== undefined)
|
|
609
|
+
if (val !== undefined)
|
|
610
|
+
processedMsgIds.delete(val);
|
|
792
611
|
}
|
|
793
612
|
}
|
|
794
613
|
processedMsgIds.add(msgId);
|
|
795
614
|
return false;
|
|
796
615
|
};
|
|
797
|
-
|
|
798
616
|
// 5. 收到 WebSocket 消息时的处理逻辑
|
|
799
|
-
const handleMessage = async (msg
|
|
617
|
+
const handleMessage = async (msg) => {
|
|
800
618
|
const params = msg.params;
|
|
801
619
|
try {
|
|
802
620
|
if (isDuplicate(params.msgId)) {
|
|
803
621
|
log.debug?.(`${logPrefix} Duplicate msgId=${params.msgId}, skipping`);
|
|
804
622
|
return;
|
|
805
623
|
}
|
|
806
|
-
|
|
807
624
|
const msgContent = params.msgContent;
|
|
808
625
|
let fileItems = collectFileItems(msgContent);
|
|
809
626
|
const msgExt = msgContent?.ext;
|
|
810
627
|
const senderId = params.userInfo?.id;
|
|
811
628
|
const senderName = params.userInfo?.name || senderId || "";
|
|
812
629
|
const senderBackground = params.userInfo?.background;
|
|
813
|
-
|
|
814
630
|
const inboundSub = config.inboundMediaWorkspaceSubdir?.trim();
|
|
815
631
|
if (inboundSub && fileItems.length > 0 && normalizeInboundWorkspaceSubdir(inboundSub)) {
|
|
816
|
-
log.info(
|
|
817
|
-
`${logPrefix} [inbound-local] enabled subdir=${inboundSub} sender=${senderId ?? "unknown"} files=${fileItems.length} pathStyle=${config.inboundMediaOpenClawPath ?? "workspaceRelative"} sandboxPrefix=${config.inboundMediaSandboxRootPrefix ?? "(none)"}`,
|
|
818
|
-
);
|
|
632
|
+
log.info(`${logPrefix} [inbound-local] enabled subdir=${inboundSub} sender=${senderId ?? "unknown"} files=${fileItems.length} pathStyle=${config.inboundMediaOpenClawPath ?? "workspaceRelative"} sandboxPrefix=${config.inboundMediaSandboxRootPrefix ?? "(none)"}`);
|
|
819
633
|
fileItems = await saveInboundFilesToWorkspace({
|
|
820
634
|
rt,
|
|
821
635
|
cfg: ctx.cfg,
|
|
@@ -826,21 +640,14 @@ export const xgCworkImChannelPlugin: XgImChannelPlugin = {
|
|
|
826
640
|
log,
|
|
827
641
|
});
|
|
828
642
|
}
|
|
829
|
-
|
|
830
643
|
const text = buildInboundDisplayText(msgContent, fileItems);
|
|
831
|
-
|
|
832
644
|
if (!text.trim() && fileItems.length === 0) {
|
|
833
645
|
log.debug?.(`${logPrefix} Empty message (no text and no files), skipping`);
|
|
834
646
|
return;
|
|
835
647
|
}
|
|
836
|
-
|
|
837
|
-
log.info(
|
|
838
|
-
`${logPrefix} Message from ${senderName}(${senderId ?? "unknown"}) in group=${params.groupId}: ${text}`,
|
|
839
|
-
);
|
|
840
|
-
|
|
648
|
+
log.info(`${logPrefix} Message from ${senderName}(${senderId ?? "unknown"}) in group=${params.groupId}: ${text}`);
|
|
841
649
|
const currentIdentity = await getToken(config, log);
|
|
842
650
|
const actuallyMentioned = isActuallyMentioned(msg, params, currentIdentity, text);
|
|
843
|
-
|
|
844
651
|
// 通过 PluginRuntime 路由消息到 OpenClaw
|
|
845
652
|
const route = rt.channel.routing.resolveAgentRoute({
|
|
846
653
|
cfg: ctx.cfg,
|
|
@@ -850,17 +657,14 @@ export const xgCworkImChannelPlugin: XgImChannelPlugin = {
|
|
|
850
657
|
peer: { kind: "group", id: params.groupId },
|
|
851
658
|
});
|
|
852
659
|
log.info(`${logPrefix} [route] agentId=${route.agentId} sessionKey=${route.sessionKey}`);
|
|
853
|
-
|
|
854
660
|
const storePath = rt.channel.session.resolveStorePath(ctx.cfg.session?.store, {
|
|
855
661
|
agentId: route.agentId,
|
|
856
662
|
});
|
|
857
|
-
|
|
858
663
|
const envelopeOptions = rt.channel.reply.resolveEnvelopeFormatOptions(ctx.cfg);
|
|
859
664
|
const previousTimestamp = rt.channel.session.readSessionUpdatedAt({
|
|
860
665
|
storePath,
|
|
861
666
|
sessionKey: route.sessionKey,
|
|
862
667
|
});
|
|
863
|
-
|
|
864
668
|
const msgTime = params.timestamp ?? params.msgSendTime ?? 0;
|
|
865
669
|
const fromLabel = `${params.groupId} - ${senderName}`;
|
|
866
670
|
const body = rt.channel.reply.formatInboundEnvelope({
|
|
@@ -873,23 +677,18 @@ export const xgCworkImChannelPlugin: XgImChannelPlugin = {
|
|
|
873
677
|
previousTimestamp,
|
|
874
678
|
envelope: envelopeOptions,
|
|
875
679
|
});
|
|
876
|
-
|
|
877
680
|
const commandAuthorized = resolveInboundCommandAuthorized(rt, ctx.cfg, {
|
|
878
681
|
accountId: account.accountId,
|
|
879
682
|
senderId,
|
|
880
683
|
config,
|
|
881
684
|
});
|
|
882
|
-
|
|
883
685
|
const groupSystemPromptResolved = resolveXgImGroupSystemPrompt(ctx.cfg, account.accountId);
|
|
884
686
|
if (config.debug) {
|
|
885
|
-
log.info(
|
|
886
|
-
|
|
887
|
-
(groupSystemPromptResolved
|
|
888
|
-
|
|
889
|
-
: "(empty — 若已配置 systemPrompt:检查网关是否加载了含本字段的插件构建,或把 systemPrompt 写到 accounts.<id> 再试)"),
|
|
890
|
-
);
|
|
687
|
+
log.info(`${logPrefix} [GroupSystemPrompt] ` +
|
|
688
|
+
(groupSystemPromptResolved
|
|
689
|
+
? `active len=${groupSystemPromptResolved.length} preview=${JSON.stringify(groupSystemPromptResolved.slice(0, 96))}`
|
|
690
|
+
: "(empty — 若已配置 systemPrompt:检查网关是否加载了含本字段的插件构建,或把 systemPrompt 写到 accounts.<id> 再试)"));
|
|
891
691
|
}
|
|
892
|
-
|
|
893
692
|
const inboundCtx = rt.channel.reply.finalizeInboundContext({
|
|
894
693
|
Body: body,
|
|
895
694
|
RawBody: text,
|
|
@@ -917,18 +716,15 @@ export const xgCworkImChannelPlugin: XgImChannelPlugin = {
|
|
|
917
716
|
// 同时保留原始 ext 供可能的后续逻辑使用
|
|
918
717
|
XgImExt: msgExt,
|
|
919
718
|
});
|
|
920
|
-
|
|
921
719
|
// 若消息内容为 /reset,则在将其转交给 OpenClaw 之前完整打印一次入站上下文,便于排查 reset 行为
|
|
922
720
|
if (text.trim() === "/reset") {
|
|
923
721
|
try {
|
|
924
|
-
log.info?.(
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
} catch {
|
|
722
|
+
log.info?.(`${logPrefix} [reset] inboundCtx payload before dispatch: ${JSON.stringify(inboundCtx)}`);
|
|
723
|
+
}
|
|
724
|
+
catch {
|
|
928
725
|
// 忽略 JSON 序列化异常,避免影响正常流程
|
|
929
726
|
}
|
|
930
727
|
}
|
|
931
|
-
|
|
932
728
|
// 【所有消息都必须做】记录到数据库!让 AI 产生“记忆”
|
|
933
729
|
log.info(`${logPrefix} [session] Recording inbound session sessionKey=${inboundCtx.SessionKey || route.sessionKey}`);
|
|
934
730
|
await rt.channel.session.recordInboundSession({
|
|
@@ -941,11 +737,10 @@ export const xgCworkImChannelPlugin: XgImChannelPlugin = {
|
|
|
941
737
|
to: params.groupId,
|
|
942
738
|
accountId: account.accountId,
|
|
943
739
|
},
|
|
944
|
-
onRecordError: (err
|
|
740
|
+
onRecordError: (err) => {
|
|
945
741
|
log.error(`${logPrefix} Failed to record session: ${String(err)}`);
|
|
946
742
|
},
|
|
947
743
|
});
|
|
948
|
-
|
|
949
744
|
if (actuallyMentioned) {
|
|
950
745
|
await dispatchMentionedReply({
|
|
951
746
|
rt,
|
|
@@ -959,31 +754,31 @@ export const xgCworkImChannelPlugin: XgImChannelPlugin = {
|
|
|
959
754
|
currentIdentity,
|
|
960
755
|
streamClient: wsHandle.streamClient,
|
|
961
756
|
});
|
|
962
|
-
}
|
|
757
|
+
}
|
|
758
|
+
else {
|
|
963
759
|
// 没 @ 我,仅作为旁观者缓存记忆,不打扰群里聊天
|
|
964
760
|
log.debug?.(`${logPrefix} Not mentioned in group, quietly memorized the message context.`);
|
|
965
761
|
}
|
|
966
|
-
}
|
|
762
|
+
}
|
|
763
|
+
catch (err) {
|
|
967
764
|
log.error(`${logPrefix} handleMessage error: ${String(err)}`);
|
|
968
765
|
}
|
|
969
766
|
};
|
|
970
|
-
|
|
971
767
|
// 5. 启动 WebSocket 连接
|
|
972
768
|
const wsHandle = startWebSocket(config, identity.token, handleMessage, log);
|
|
973
|
-
|
|
974
769
|
// 6. 阻塞到 abortSignal 触发(若存在);若无 abortSignal,则交由进程生命周期管理
|
|
975
770
|
if (ctx.abortSignal) {
|
|
976
|
-
await new Promise
|
|
977
|
-
const abortHandler = ()
|
|
771
|
+
await new Promise((resolve) => {
|
|
772
|
+
const abortHandler = () => {
|
|
978
773
|
log.info(`${logPrefix} Abort signal received, stopping XG-IM channel...`);
|
|
979
774
|
wsHandle.stop();
|
|
980
775
|
clearTokenCache(config);
|
|
981
776
|
resolve();
|
|
982
777
|
};
|
|
983
|
-
|
|
984
778
|
ctx.abortSignal?.addEventListener("abort", abortHandler, { once: true });
|
|
985
779
|
});
|
|
986
780
|
}
|
|
987
781
|
},
|
|
988
782
|
},
|
|
989
783
|
};
|
|
784
|
+
//# sourceMappingURL=channel.js.map
|