@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xgjktech/xg_cwork_im",
3
- "version": "1.0.8",
3
+ "version": "1.0.9",
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,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 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;
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
- function untrustedContextFromExtAndBackground(
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
- const fileItems = collectFileItems(msgContent);
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: untrustedContextFromExtAndBackground(msgExt, senderBackground),
758
+ UntrustedContext: buildUntrustedContext(msgExt, senderBackground, fileItems),
634
759
  // 同时保留原始 ext 供可能的后续逻辑使用
635
760
  XgImExt: msgExt,
636
761
  });