ylib-syim 0.0.32 → 0.0.34

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": "ylib-syim",
3
- "version": "0.0.32",
3
+ "version": "0.0.34",
4
4
  "description": "多 IM / 多 Agent 的会话路由与上下文管理(支持 /new)",
5
5
  "type": "module",
6
6
  "exports": {
@@ -46,9 +46,9 @@
46
46
  },
47
47
  "dependencies": {
48
48
  "@ffmpeg-installer/ffmpeg": "^1.1.0",
49
- "ylib-dingtalk-connector": "0.7.10-beta.19",
50
- "ylib-openclaw-lark": "2026.3.17-beta.23",
51
- "ylib-openclaw-weixin": "2.1.7-beta.11",
49
+ "ylib-dingtalk-connector": "0.7.10-beta.20",
50
+ "ylib-openclaw-lark": "2026.3.17-beta.25",
51
+ "ylib-openclaw-weixin": "2.1.7-beta.12",
52
52
  "axios": "^1.6.0",
53
53
  "dingtalk-stream": "^2.1.4",
54
54
  "fluent-ffmpeg": "^2.1.3",
@@ -513,6 +513,180 @@ function buildCfg(): {
513
513
  // ---------------------------------------------------------------------------
514
514
 
515
515
  function buildMinimalRuntime(cfg: Record<string, unknown>): unknown {
516
+ type BridgeGroupConfig = {
517
+ requireMention?: boolean;
518
+ };
519
+ type BridgeGroups = Record<string, BridgeGroupConfig | undefined>;
520
+
521
+ const normalizeAccountId = (value: unknown): string => {
522
+ const text = String(value || "").trim();
523
+ return text || "__default__";
524
+ };
525
+
526
+ const resolveAccountEntry = (
527
+ accounts: Record<string, unknown> | undefined,
528
+ accountId: unknown,
529
+ ): Record<string, unknown> | undefined => {
530
+ if (!accounts || typeof accounts !== "object") return undefined;
531
+ const normalized = normalizeAccountId(accountId);
532
+ const exact = accounts[normalized];
533
+ if (exact && typeof exact === "object" && !Array.isArray(exact)) {
534
+ return exact as Record<string, unknown>;
535
+ }
536
+ const fallback = accounts["__default__"];
537
+ if (fallback && typeof fallback === "object" && !Array.isArray(fallback)) {
538
+ return fallback as Record<string, unknown>;
539
+ }
540
+ return undefined;
541
+ };
542
+
543
+ const resolveChannelGroups = (
544
+ inCfg: Record<string, unknown>,
545
+ channel: string,
546
+ accountId?: unknown,
547
+ ): BridgeGroups | undefined => {
548
+ const channelsObj =
549
+ inCfg.channels && typeof inCfg.channels === "object"
550
+ ? (inCfg.channels as Record<string, unknown>)
551
+ : undefined;
552
+ const channelCfg =
553
+ channelsObj && channelsObj[channel] && typeof channelsObj[channel] === "object"
554
+ ? (channelsObj[channel] as Record<string, unknown>)
555
+ : undefined;
556
+ if (!channelCfg) return undefined;
557
+ const accountsObj =
558
+ channelCfg.accounts && typeof channelCfg.accounts === "object"
559
+ ? (channelCfg.accounts as Record<string, unknown>)
560
+ : undefined;
561
+ const accountEntry = resolveAccountEntry(accountsObj, accountId);
562
+ const accountGroups =
563
+ accountEntry && accountEntry.groups && typeof accountEntry.groups === "object"
564
+ ? (accountEntry.groups as BridgeGroups)
565
+ : undefined;
566
+ if (accountGroups) return accountGroups;
567
+ if (channelCfg.groups && typeof channelCfg.groups === "object") {
568
+ return channelCfg.groups as BridgeGroups;
569
+ }
570
+ return undefined;
571
+ };
572
+
573
+ const resolveChannelGroupPolicyMode = (
574
+ inCfg: Record<string, unknown>,
575
+ channel: string,
576
+ accountId?: unknown,
577
+ ): "open" | "allowlist" | "disabled" | undefined => {
578
+ const channelsObj =
579
+ inCfg.channels && typeof inCfg.channels === "object"
580
+ ? (inCfg.channels as Record<string, unknown>)
581
+ : undefined;
582
+ const channelCfg =
583
+ channelsObj && channelsObj[channel] && typeof channelsObj[channel] === "object"
584
+ ? (channelsObj[channel] as Record<string, unknown>)
585
+ : undefined;
586
+ if (!channelCfg) return undefined;
587
+ const accountsObj =
588
+ channelCfg.accounts && typeof channelCfg.accounts === "object"
589
+ ? (channelCfg.accounts as Record<string, unknown>)
590
+ : undefined;
591
+ const accountEntry = resolveAccountEntry(accountsObj, accountId);
592
+ const accountPolicy = String(accountEntry?.groupPolicy || "").trim();
593
+ if (accountPolicy === "open" || accountPolicy === "allowlist" || accountPolicy === "disabled") {
594
+ return accountPolicy;
595
+ }
596
+ const topPolicy = String(channelCfg.groupPolicy || "").trim();
597
+ if (topPolicy === "open" || topPolicy === "allowlist" || topPolicy === "disabled") {
598
+ return topPolicy;
599
+ }
600
+ return undefined;
601
+ };
602
+
603
+ const resolveChannelGroupConfig = (
604
+ groups: BridgeGroups | undefined,
605
+ groupId: string,
606
+ caseInsensitive: boolean,
607
+ ): BridgeGroupConfig | undefined => {
608
+ if (!groups) return undefined;
609
+ const direct = groups[groupId];
610
+ if (direct) return direct;
611
+ if (!caseInsensitive) return undefined;
612
+ const target = groupId.toLowerCase();
613
+ const matchedKey = Object.keys(groups).find(
614
+ (key) => key !== "*" && key.toLowerCase() === target,
615
+ );
616
+ return matchedKey ? groups[matchedKey] : undefined;
617
+ };
618
+
619
+ const resolveBridgeGroupPolicy = (params: {
620
+ cfg: Record<string, unknown>;
621
+ channel: string;
622
+ groupId?: string | null;
623
+ accountId?: string | null;
624
+ groupIdCaseInsensitive?: boolean;
625
+ hasGroupAllowFrom?: boolean;
626
+ }): {
627
+ allowlistEnabled: boolean;
628
+ allowed: boolean;
629
+ groupConfig?: BridgeGroupConfig;
630
+ defaultConfig?: BridgeGroupConfig;
631
+ } => {
632
+ const groups = resolveChannelGroups(params.cfg, params.channel, params.accountId);
633
+ const groupPolicy = resolveChannelGroupPolicyMode(
634
+ params.cfg,
635
+ params.channel,
636
+ params.accountId,
637
+ );
638
+ const hasGroups = Boolean(groups && Object.keys(groups).length > 0);
639
+ const allowlistEnabled = groupPolicy === "allowlist" || hasGroups;
640
+ const normalizedGroupId = String(params.groupId || "").trim();
641
+ const groupConfig = normalizedGroupId
642
+ ? resolveChannelGroupConfig(groups, normalizedGroupId, Boolean(params.groupIdCaseInsensitive))
643
+ : undefined;
644
+ const defaultConfig = groups?.["*"];
645
+ const allowAll = allowlistEnabled && Boolean(groups && Object.hasOwn(groups, "*"));
646
+ const senderFilterBypass =
647
+ groupPolicy === "allowlist" && !hasGroups && Boolean(params.hasGroupAllowFrom);
648
+ const allowed =
649
+ groupPolicy === "disabled"
650
+ ? false
651
+ : !allowlistEnabled || allowAll || Boolean(groupConfig) || senderFilterBypass;
652
+ return {
653
+ allowlistEnabled,
654
+ allowed,
655
+ groupConfig,
656
+ defaultConfig,
657
+ };
658
+ };
659
+
660
+ const resolveBridgeRequireMention = (params: {
661
+ cfg: Record<string, unknown>;
662
+ channel: string;
663
+ groupId?: string | null;
664
+ accountId?: string | null;
665
+ groupIdCaseInsensitive?: boolean;
666
+ requireMentionOverride?: boolean;
667
+ overrideOrder?: "before-config" | "after-config";
668
+ }): boolean => {
669
+ const { requireMentionOverride } = params;
670
+ const overrideOrder = params.overrideOrder || "after-config";
671
+ const { groupConfig, defaultConfig } = resolveBridgeGroupPolicy(params);
672
+ const configMention =
673
+ typeof groupConfig?.requireMention === "boolean"
674
+ ? groupConfig.requireMention
675
+ : typeof defaultConfig?.requireMention === "boolean"
676
+ ? defaultConfig.requireMention
677
+ : undefined;
678
+ if (overrideOrder === "before-config" && typeof requireMentionOverride === "boolean") {
679
+ return requireMentionOverride;
680
+ }
681
+ if (typeof configMention === "boolean") {
682
+ return configMention;
683
+ }
684
+ if (overrideOrder !== "before-config" && typeof requireMentionOverride === "boolean") {
685
+ return requireMentionOverride;
686
+ }
687
+ return true;
688
+ };
689
+
516
690
  return {
517
691
  log: (...args: unknown[]) => console.log("[Feishu]", ...args),
518
692
  error: (...args: unknown[]) => console.error("[Feishu][ERR]", ...args),
@@ -531,6 +705,10 @@ function buildMinimalRuntime(cfg: Record<string, unknown>): unknown {
531
705
  resolveCommandAuthorizedFromAuthorizers: async () => false,
532
706
  isControlCommandMessage: () => false,
533
707
  },
708
+ groups: {
709
+ resolveGroupPolicy: resolveBridgeGroupPolicy,
710
+ resolveRequireMention: resolveBridgeRequireMention,
711
+ },
534
712
  reply: {
535
713
  resolveEnvelopeFormatOptions: () => ({}),
536
714
  finalizeInboundContext: () => ({}),
@@ -1,10 +1,168 @@
1
1
  import fs from "node:fs";
2
+ import fsPromises from "node:fs/promises";
2
3
  import os from "node:os";
3
4
  import path from "node:path";
5
+ import crypto from "node:crypto";
6
+ import { fileTypeFromBuffer } from "file-type";
4
7
 
5
8
  const CHANNEL_KEY = "openclaw-weixin";
6
9
  let bridgeDefaultAccountAlias = "weixin-single";
7
10
  const bridgeAliasLogMemo = new Set<string>();
11
+ const MEDIA_FILE_MODE = 0o644;
12
+ const DEFAULT_MEDIA_MAX_BYTES = 5 * 1024 * 1024;
13
+ const EXT_BY_MIME: Record<string, string> = {
14
+ "image/heic": ".heic",
15
+ "image/heif": ".heif",
16
+ "image/jpeg": ".jpg",
17
+ "image/png": ".png",
18
+ "image/webp": ".webp",
19
+ "image/gif": ".gif",
20
+ "audio/ogg": ".ogg",
21
+ "audio/mpeg": ".mp3",
22
+ "audio/x-m4a": ".m4a",
23
+ "audio/mp4": ".m4a",
24
+ "video/mp4": ".mp4",
25
+ "video/quicktime": ".mov",
26
+ "application/pdf": ".pdf",
27
+ "application/json": ".json",
28
+ "application/zip": ".zip",
29
+ "application/gzip": ".gz",
30
+ "application/x-tar": ".tar",
31
+ "application/x-7z-compressed": ".7z",
32
+ "application/vnd.rar": ".rar",
33
+ "application/msword": ".doc",
34
+ "application/vnd.ms-excel": ".xls",
35
+ "application/vnd.ms-powerpoint": ".ppt",
36
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
37
+ ".docx",
38
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
39
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation":
40
+ ".pptx",
41
+ "text/csv": ".csv",
42
+ "text/plain": ".txt",
43
+ "text/markdown": ".md",
44
+ };
45
+ const MIME_BY_EXT: Record<string, string> = {
46
+ ...Object.fromEntries(Object.entries(EXT_BY_MIME).map(([mime, ext]) => [ext, mime])),
47
+ ".jpeg": "image/jpeg",
48
+ ".js": "text/javascript",
49
+ };
50
+
51
+ function sanitizeFilename(name: string): string {
52
+ const trimmed = name.trim();
53
+ if (!trimmed) return "";
54
+ const sanitized = trimmed.replace(/[^\p{L}\p{N}._-]+/gu, "_");
55
+ return sanitized.replace(/_+/g, "_").replace(/^_|_$/g, "").slice(0, 60);
56
+ }
57
+
58
+ function normalizeMimeType(mime?: string | null): string | undefined {
59
+ if (!mime) return undefined;
60
+ const cleaned = mime.split(";")[0]?.trim().toLowerCase();
61
+ return cleaned || undefined;
62
+ }
63
+
64
+ function extensionForMime(mime?: string | null): string | undefined {
65
+ const normalized = normalizeMimeType(mime);
66
+ if (!normalized) return undefined;
67
+ return EXT_BY_MIME[normalized];
68
+ }
69
+
70
+ function getFileExtension(filePath?: string | null): string | undefined {
71
+ if (!filePath) return undefined;
72
+ try {
73
+ if (/^https?:\/\//i.test(filePath)) {
74
+ const url = new URL(filePath);
75
+ return path.extname(url.pathname).toLowerCase() || undefined;
76
+ }
77
+ } catch {
78
+ // fall back
79
+ }
80
+ const ext = path.extname(filePath).toLowerCase();
81
+ return ext || undefined;
82
+ }
83
+
84
+ function isGenericMime(mime?: string): boolean {
85
+ if (!mime) return true;
86
+ const lower = mime.toLowerCase();
87
+ return lower === "application/octet-stream" || lower === "application/zip";
88
+ }
89
+
90
+ function isMissingPathError(err: unknown): err is NodeJS.ErrnoException {
91
+ return err instanceof Error && "code" in err && err.code === "ENOENT";
92
+ }
93
+
94
+ async function retryAfterRecreatingDir<T>(
95
+ dir: string,
96
+ run: () => Promise<T>,
97
+ ): Promise<T> {
98
+ try {
99
+ return await run();
100
+ } catch (err) {
101
+ if (!isMissingPathError(err)) throw err;
102
+ await fsPromises.mkdir(dir, { recursive: true, mode: 0o700 });
103
+ return await run();
104
+ }
105
+ }
106
+
107
+ async function detectMime(opts: {
108
+ buffer?: Buffer;
109
+ headerMime?: string | null;
110
+ filePath?: string;
111
+ }): Promise<string | undefined> {
112
+ const ext = getFileExtension(opts.filePath);
113
+ const extMime = ext ? MIME_BY_EXT[ext] : undefined;
114
+ const headerMime = normalizeMimeType(opts.headerMime);
115
+ let sniffed: string | undefined;
116
+ if (opts.buffer) {
117
+ try {
118
+ const fileType = await fileTypeFromBuffer(opts.buffer);
119
+ sniffed = fileType?.mime ?? undefined;
120
+ } catch {
121
+ sniffed = undefined;
122
+ }
123
+ }
124
+ if (sniffed && (!isGenericMime(sniffed) || !extMime)) return sniffed;
125
+ if (extMime) return extMime;
126
+ if (headerMime && !isGenericMime(headerMime)) return headerMime;
127
+ if (sniffed) return sniffed;
128
+ if (headerMime) return headerMime;
129
+ return undefined;
130
+ }
131
+
132
+ async function saveMediaBufferFromOpenclaw(
133
+ buffer: Buffer,
134
+ contentType?: string,
135
+ subdir = "inbound",
136
+ maxBytes = DEFAULT_MEDIA_MAX_BYTES,
137
+ originalFilename?: string,
138
+ ): Promise<{ id: string; path: string; size: number; contentType?: string }> {
139
+ if (buffer.byteLength > maxBytes) {
140
+ throw new Error(
141
+ `Media exceeds ${(maxBytes / (1024 * 1024)).toFixed(0)}MB limit`,
142
+ );
143
+ }
144
+ const dir = path.join(os.tmpdir(), "openclaw-media", subdir);
145
+ await fsPromises.mkdir(dir, { recursive: true, mode: 0o700 });
146
+ const uuid = crypto.randomUUID();
147
+ const headerExt = extensionForMime(contentType?.split(";")[0]?.trim());
148
+ const mime = await detectMime({ buffer, headerMime: contentType });
149
+ const ext = headerExt ?? extensionForMime(mime) ?? "";
150
+
151
+ let id: string;
152
+ if (originalFilename) {
153
+ const base = path.parse(originalFilename).name;
154
+ const sanitized = sanitizeFilename(base);
155
+ id = sanitized ? `${sanitized}---${uuid}${ext}` : `${uuid}${ext}`;
156
+ } else {
157
+ id = ext ? `${uuid}${ext}` : uuid;
158
+ }
159
+
160
+ const dest = path.join(dir, id);
161
+ await retryAfterRecreatingDir(dir, () =>
162
+ fsPromises.writeFile(dest, buffer, { mode: MEDIA_FILE_MODE }),
163
+ );
164
+ return { id, path: dest, size: buffer.byteLength, contentType: mime };
165
+ }
8
166
 
9
167
  function logBridgeAlias(from: string, to: string, reason: string): void {
10
168
  const key = `${from}->${to}:${reason}`;
@@ -503,6 +661,9 @@ function buildMinimalRuntime(
503
661
  activity: {
504
662
  record: () => {},
505
663
  },
664
+ media: {
665
+ saveMediaBuffer: saveMediaBufferFromOpenclaw,
666
+ },
506
667
  },
507
668
  system: {
508
669
  enqueueSystemEvent: () => {},