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