@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xgjktech/xg_cwork_im",
3
- "version": "1.0.8",
3
+ "version": "1.10.0",
4
4
  "description": "XG CWork IM channel plugin for OpenClaw",
5
5
  "keywords": [
6
6
  "bot",
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 names = fileItems
161
- .map((f) => f.name?.trim() || f.fileId || "未命名文件")
162
- .join("、");
163
- const fileNote = `[附件 ${fileItems.length} 个: ${names}]`;
164
- rawText = rawText.trim() ? `${rawText.trim()}\n\n${fileNote}` : fileNote;
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
- function untrustedContextFromExtAndBackground(
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
- const fileItems = collectFileItems(msgContent);
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: untrustedContextFromExtAndBackground(msgExt, senderBackground),
760
+ UntrustedContext: buildUntrustedContext(msgExt, senderBackground, fileItems),
634
761
  // 同时保留原始 ext 供可能的后续逻辑使用
635
762
  XgImExt: msgExt,
636
763
  });