@xgjktech/xg_cwork_im 1.0.8 → 1.10.0
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 +369 -360
- package/package.json +1 -1
- package/src/channel.ts +139 -12
- package/src/connection.ts +362 -359
- package/src/inbound-media-local.ts +199 -0
- package/src/types.ts +27 -2
package/package.json
CHANGED
package/src/channel.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { buildChannelConfigSchema } from "openclaw/plugin-sdk";
|
|
|
14
14
|
import { z } from "zod";
|
|
15
15
|
import { clearTokenCache, getToken } from "./auth.js";
|
|
16
16
|
import { startWebSocket, type ImStreamClient } from "./connection.js";
|
|
17
|
+
import { normalizeInboundWorkspaceSubdir, saveInboundFilesToWorkspace } from "./inbound-media-local.js";
|
|
17
18
|
import { sendReplyDeliverBlock, sendTextMessage, type ReplyDeliverPayload } from "./send-service.js";
|
|
18
19
|
import {
|
|
19
20
|
imFormatToMimeType,
|
|
@@ -53,6 +54,8 @@ const XgImAccountConfigSchema = z.object({
|
|
|
53
54
|
groupPolicy: z.enum(["open", "mention"]).optional().default("mention"),
|
|
54
55
|
fileUploadFormField: z.string().min(1).optional(),
|
|
55
56
|
maxAttachmentBytes: z.number().int().positive().optional(),
|
|
57
|
+
inboundMediaWorkspaceSubdir: z.string().optional(),
|
|
58
|
+
inboundMediaOpenClawPath: z.enum(["workspaceRelative", "absoluteFileUrl"]).optional(),
|
|
56
59
|
});
|
|
57
60
|
|
|
58
61
|
const XgImConfigSchema = z.object({
|
|
@@ -71,9 +74,27 @@ const XgImConfigSchema = z.object({
|
|
|
71
74
|
reconnectJitter: z.number().min(0).max(1).optional().default(0.3),
|
|
72
75
|
fileUploadFormField: z.string().min(1).optional(),
|
|
73
76
|
maxAttachmentBytes: z.number().int().positive().optional(),
|
|
77
|
+
/**
|
|
78
|
+
* 非空时:将入站附件下载到 `resolveAgentWorkspaceDir(cfg)` 下该相对子目录,
|
|
79
|
+
* 再向 OpenClaw 传 workspace 相对路径或 file://(见 inboundMediaOpenClawPath、inbound-media-local)。
|
|
80
|
+
*/
|
|
81
|
+
inboundMediaWorkspaceSubdir: z.string().optional(),
|
|
82
|
+
inboundMediaOpenClawPath: z.enum(["workspaceRelative", "absoluteFileUrl"]).optional(),
|
|
74
83
|
// 多账户:对象 map(key 为 accountId)
|
|
75
84
|
accounts: z.record(z.string(), XgImAccountConfigSchema).optional(),
|
|
76
85
|
}).superRefine((val, ctx) => {
|
|
86
|
+
const badSubdir = (s: string | undefined): boolean =>
|
|
87
|
+
typeof s === "string" && s.trim().length > 0 && normalizeInboundWorkspaceSubdir(s) == null;
|
|
88
|
+
|
|
89
|
+
if (badSubdir(val.inboundMediaWorkspaceSubdir)) {
|
|
90
|
+
ctx.addIssue({
|
|
91
|
+
code: z.ZodIssueCode.custom,
|
|
92
|
+
path: ["inboundMediaWorkspaceSubdir"],
|
|
93
|
+
message:
|
|
94
|
+
"inboundMediaWorkspaceSubdir must be a relative path under workspace (no .., no absolute path)",
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
77
98
|
const accounts = val.accounts;
|
|
78
99
|
if (!accounts) return;
|
|
79
100
|
for (const [key, acc] of Object.entries(accounts)) {
|
|
@@ -86,6 +107,14 @@ const XgImConfigSchema = z.object({
|
|
|
86
107
|
message: "appKey is required for account entries (except accounts.default)",
|
|
87
108
|
});
|
|
88
109
|
}
|
|
110
|
+
if (badSubdir(acc.inboundMediaWorkspaceSubdir)) {
|
|
111
|
+
ctx.addIssue({
|
|
112
|
+
code: z.ZodIssueCode.custom,
|
|
113
|
+
path: ["accounts", key, "inboundMediaWorkspaceSubdir"],
|
|
114
|
+
message:
|
|
115
|
+
"inboundMediaWorkspaceSubdir must be a relative path under workspace (no .., no absolute path)",
|
|
116
|
+
});
|
|
117
|
+
}
|
|
89
118
|
}
|
|
90
119
|
});
|
|
91
120
|
|
|
@@ -129,6 +158,53 @@ function isConfigured(cfg: OpenClawConfig): boolean {
|
|
|
129
158
|
}
|
|
130
159
|
}
|
|
131
160
|
|
|
161
|
+
/**
|
|
162
|
+
* OpenClaw 对入站命令:`CommandAuthorized === undefined` 会按 false 处理,文本 /command 会被静默忽略(仍走普通 AI)。
|
|
163
|
+
* 见 openclaw LINE #26996 等修复。此处与 channels.xg_cwork_im.allowFrom 对齐;allowFrom 为空表示不限制发送者。
|
|
164
|
+
*/
|
|
165
|
+
function isSenderInXgImAllowFrom(config: XgImConfig, senderId: string | undefined): boolean {
|
|
166
|
+
const allow = config.allowFrom ?? [];
|
|
167
|
+
if (allow.length === 0) return true;
|
|
168
|
+
const id = senderId?.trim();
|
|
169
|
+
if (!id) return false;
|
|
170
|
+
return allow.includes(id);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function resolveInboundCommandAuthorized(
|
|
174
|
+
rt: unknown,
|
|
175
|
+
cfg: OpenClawConfig,
|
|
176
|
+
p: { accountId: string; senderId: string | undefined; config: XgImConfig },
|
|
177
|
+
): boolean {
|
|
178
|
+
const r = rt as {
|
|
179
|
+
channel?: {
|
|
180
|
+
commands?: {
|
|
181
|
+
resolveControlCommandGate?: (args: Record<string, unknown>) =>
|
|
182
|
+
| boolean
|
|
183
|
+
| { commandAuthorized?: boolean };
|
|
184
|
+
};
|
|
185
|
+
};
|
|
186
|
+
};
|
|
187
|
+
const fn = r?.channel?.commands?.resolveControlCommandGate;
|
|
188
|
+
if (typeof fn === "function") {
|
|
189
|
+
try {
|
|
190
|
+
const out = fn({
|
|
191
|
+
cfg,
|
|
192
|
+
channel: "xg_cwork_im",
|
|
193
|
+
accountId: p.accountId,
|
|
194
|
+
senderId: p.senderId ?? "",
|
|
195
|
+
chatType: "group",
|
|
196
|
+
});
|
|
197
|
+
if (typeof out === "boolean") return out;
|
|
198
|
+
if (out && typeof out === "object" && "commandAuthorized" in out) {
|
|
199
|
+
return Boolean((out as { commandAuthorized?: boolean }).commandAuthorized);
|
|
200
|
+
}
|
|
201
|
+
} catch {
|
|
202
|
+
/* 回退到 allowFrom */
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return isSenderInXgImAllowFrom(p.config, p.senderId);
|
|
206
|
+
}
|
|
207
|
+
|
|
132
208
|
// ─── 日志适配器 ───────────────────────────────────────────────────────────────
|
|
133
209
|
// ChannelLogSink 的 info/warn/error 方法接受单个 string
|
|
134
210
|
|
|
@@ -157,11 +233,26 @@ function collectFileItems(msgContent: WsMessageContent | undefined): MsgFileVO[]
|
|
|
157
233
|
function buildInboundDisplayText(msgContent: WsMessageContent | undefined, fileItems: MsgFileVO[]): string {
|
|
158
234
|
let rawText = msgContent?.text ?? "";
|
|
159
235
|
if (fileItems.length > 0) {
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
.
|
|
163
|
-
|
|
164
|
-
|
|
236
|
+
const lines = fileItems.map((f) => {
|
|
237
|
+
const name = f.name?.trim() || f.fileId || "未命名文件";
|
|
238
|
+
const mime = imFormatToMimeType(f.format);
|
|
239
|
+
return mime ? `- \`${name}\` (${mime})` : `- \`${name}\``;
|
|
240
|
+
});
|
|
241
|
+
const block = `**附件(${fileItems.length})**\n${lines.join("\n")}`;
|
|
242
|
+
rawText = rawText.trim() ? `${rawText.trim()}\n\n${block}` : block;
|
|
243
|
+
|
|
244
|
+
const parsedParts = fileItems
|
|
245
|
+
.map((f) => {
|
|
246
|
+
const c = f.content?.trim();
|
|
247
|
+
if (!c) return null;
|
|
248
|
+
const name = f.name?.trim() || f.fileId || "未命名文件";
|
|
249
|
+
return `### ${name}\n\n${c}`;
|
|
250
|
+
})
|
|
251
|
+
.filter((x): x is string => x != null);
|
|
252
|
+
if (parsedParts.length > 0) {
|
|
253
|
+
const parsedBlock = `**附件解析内容**\n\n${parsedParts.join("\n\n")}`;
|
|
254
|
+
rawText = `${rawText.trim()}\n\n${parsedBlock}`;
|
|
255
|
+
}
|
|
165
256
|
}
|
|
166
257
|
return rawText;
|
|
167
258
|
}
|
|
@@ -187,23 +278,39 @@ function mediaFieldsFromFileItems(fileItems: MsgFileVO[]): Record<string, unknow
|
|
|
187
278
|
const mimes = fileItems.map(
|
|
188
279
|
(f) => imFormatToMimeType(f.format) ?? "application/octet-stream",
|
|
189
280
|
);
|
|
281
|
+
// OpenClaw 入站展示与媒体拉取以 MediaPath(s) 为准;[media attached: …] 依赖 Path,仅写 MediaUrl 时该段不出现。
|
|
282
|
+
// 不要同时写 Path 与 Url(同链接会被拼成两段,出现 `url | url`)。
|
|
283
|
+
if (fileItems.length === 1) {
|
|
284
|
+
return {
|
|
285
|
+
MediaPath: fileItems[0]!.url,
|
|
286
|
+
MediaType: mimes[0],
|
|
287
|
+
};
|
|
288
|
+
}
|
|
190
289
|
return {
|
|
191
|
-
MediaPath: fileItems[0]!.url,
|
|
192
|
-
MediaUrl: fileItems[0]!.url,
|
|
193
290
|
MediaPaths: fileItems.map((f) => f.url),
|
|
194
|
-
MediaUrls: fileItems.map((f) => f.url),
|
|
195
|
-
MediaType: mimes[0],
|
|
196
291
|
MediaTypes: mimes,
|
|
197
292
|
};
|
|
198
293
|
}
|
|
199
294
|
|
|
200
|
-
|
|
295
|
+
/** ext / 用户 background / 附件元数据(不含下载 URL,避免预签名链过长;URL 已在入站 MediaPath(s)) */
|
|
296
|
+
function buildUntrustedContext(
|
|
201
297
|
msgExt: WsMessageContent["ext"],
|
|
202
298
|
senderBackground: string | undefined,
|
|
299
|
+
fileItems: MsgFileVO[],
|
|
203
300
|
): string[] | undefined {
|
|
204
301
|
const parts: string[] = [];
|
|
205
302
|
if (msgExt) parts.push(JSON.stringify(msgExt));
|
|
206
303
|
if (senderBackground) parts.push(String(senderBackground));
|
|
304
|
+
if (fileItems.length > 0) {
|
|
305
|
+
const summary = fileItems.map((f) => ({
|
|
306
|
+
name: f.name ?? null,
|
|
307
|
+
format: f.format ?? null,
|
|
308
|
+
fileId: f.fileId ?? null,
|
|
309
|
+
size: f.size ?? null,
|
|
310
|
+
contentChars: f.content?.trim() ? f.content.trim().length : null,
|
|
311
|
+
}));
|
|
312
|
+
parts.push(`xg_cwork_im.attachments: ${JSON.stringify(summary)}`);
|
|
313
|
+
}
|
|
207
314
|
return parts.length > 0 ? parts : undefined;
|
|
208
315
|
}
|
|
209
316
|
|
|
@@ -556,12 +663,25 @@ export const xgCworkImChannelPlugin: XgImChannelPlugin = {
|
|
|
556
663
|
}
|
|
557
664
|
|
|
558
665
|
const msgContent = params.msgContent;
|
|
559
|
-
|
|
666
|
+
let fileItems = collectFileItems(msgContent);
|
|
560
667
|
const msgExt = msgContent?.ext;
|
|
561
668
|
const senderId = params.userInfo?.id;
|
|
562
669
|
const senderName = params.userInfo?.name || senderId || "";
|
|
563
670
|
const senderBackground = params.userInfo?.background;
|
|
564
671
|
|
|
672
|
+
const inboundSub = config.inboundMediaWorkspaceSubdir?.trim();
|
|
673
|
+
if (inboundSub && fileItems.length > 0 && normalizeInboundWorkspaceSubdir(inboundSub)) {
|
|
674
|
+
fileItems = await saveInboundFilesToWorkspace({
|
|
675
|
+
rt,
|
|
676
|
+
cfg: ctx.cfg,
|
|
677
|
+
config,
|
|
678
|
+
workspaceSubdir: inboundSub,
|
|
679
|
+
senderUserId: senderId,
|
|
680
|
+
fileItems,
|
|
681
|
+
log,
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
|
|
565
685
|
const text = buildInboundDisplayText(msgContent, fileItems);
|
|
566
686
|
|
|
567
687
|
if (!text.trim() && fileItems.length === 0) {
|
|
@@ -609,10 +729,17 @@ export const xgCworkImChannelPlugin: XgImChannelPlugin = {
|
|
|
609
729
|
envelope: envelopeOptions,
|
|
610
730
|
});
|
|
611
731
|
|
|
732
|
+
const commandAuthorized = resolveInboundCommandAuthorized(rt, ctx.cfg, {
|
|
733
|
+
accountId: account.accountId,
|
|
734
|
+
senderId,
|
|
735
|
+
config,
|
|
736
|
+
});
|
|
737
|
+
|
|
612
738
|
const inboundCtx = rt.channel.reply.finalizeInboundContext({
|
|
613
739
|
Body: body,
|
|
614
740
|
RawBody: text,
|
|
615
741
|
CommandBody: text,
|
|
742
|
+
CommandAuthorized: commandAuthorized,
|
|
616
743
|
From: params.groupId,
|
|
617
744
|
To: params.groupId,
|
|
618
745
|
SessionKey: route.sessionKey,
|
|
@@ -630,7 +757,7 @@ export const xgCworkImChannelPlugin: XgImChannelPlugin = {
|
|
|
630
757
|
OriginatingTo: params.groupId,
|
|
631
758
|
GroupChannel: route.sessionKey,
|
|
632
759
|
...mediaFieldsFromFileItems(fileItems),
|
|
633
|
-
UntrustedContext:
|
|
760
|
+
UntrustedContext: buildUntrustedContext(msgExt, senderBackground, fileItems),
|
|
634
761
|
// 同时保留原始 ext 供可能的后续逻辑使用
|
|
635
762
|
XgImExt: msgExt,
|
|
636
763
|
});
|