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