gewe-openclaw 2026.3.13 → 2026.3.23

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.
Files changed (44) hide show
  1. package/README.md +455 -3
  2. package/index.ts +39 -1
  3. package/package.json +12 -1
  4. package/skills/gewe-agent-tools/SKILL.md +113 -0
  5. package/skills/gewe-channel-rules/SKILL.md +7 -0
  6. package/src/accounts.ts +51 -5
  7. package/src/api-tools.ts +1264 -0
  8. package/src/api.ts +37 -2
  9. package/src/binary-command.ts +65 -0
  10. package/src/channel-actions.ts +536 -0
  11. package/src/channel-allowlist.ts +150 -0
  12. package/src/channel-directory.ts +419 -0
  13. package/src/channel-status.ts +186 -0
  14. package/src/channel.ts +155 -58
  15. package/src/config-edit.ts +94 -0
  16. package/src/config-schema.ts +78 -3
  17. package/src/contacts-api.ts +113 -0
  18. package/src/delivery.ts +502 -62
  19. package/src/directory-cache.ts +164 -0
  20. package/src/gewe-account-api.ts +27 -0
  21. package/src/group-allowlist-tool.ts +242 -0
  22. package/src/group-binding-tool.ts +154 -0
  23. package/src/group-binding.ts +405 -0
  24. package/src/groups-api.ts +146 -0
  25. package/src/inbound-batch.ts +5 -2
  26. package/src/inbound.ts +248 -41
  27. package/src/media-server.ts +73 -93
  28. package/src/moments-api.ts +138 -0
  29. package/src/monitor.ts +81 -24
  30. package/src/onboarding.ts +9 -4
  31. package/src/openclaw-compat.ts +1070 -0
  32. package/src/pairing-store.ts +478 -0
  33. package/src/personal-api.ts +45 -0
  34. package/src/policy.ts +130 -22
  35. package/src/quote-context-cache.ts +97 -0
  36. package/src/reply-options.ts +101 -2
  37. package/src/s3.ts +1 -1
  38. package/src/send.ts +235 -16
  39. package/src/setup-wizard-types.ts +162 -0
  40. package/src/setup-wizard.ts +464 -0
  41. package/src/silk.ts +2 -1
  42. package/src/state-paths.ts +55 -14
  43. package/src/types.ts +66 -7
  44. package/src/xml.ts +158 -0
@@ -0,0 +1,1070 @@
1
+ import type {
2
+ ChannelConfigSchema,
3
+ ChannelPlugin,
4
+ ChannelSetupInput,
5
+ OpenClawConfig,
6
+ OpenClawPluginApi,
7
+ PluginRuntime,
8
+ ReplyPayload,
9
+ RuntimeEnv,
10
+ WizardPrompter,
11
+ } from "openclaw/plugin-sdk";
12
+ import type { IncomingMessage } from "node:http";
13
+ import path from "node:path";
14
+ import { z, type RefinementCtx, type ZodTypeAny } from "zod";
15
+
16
+ export type {
17
+ ChannelPlugin,
18
+ ChannelSetupInput,
19
+ OpenClawConfig,
20
+ OpenClawPluginApi,
21
+ PluginRuntime,
22
+ ReplyPayload,
23
+ RuntimeEnv,
24
+ WizardPrompter,
25
+ };
26
+
27
+ export const DEFAULT_ACCOUNT_ID = "default";
28
+ export const DEFAULT_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024;
29
+ export const DEFAULT_WEBHOOK_BODY_TIMEOUT_MS = 30_000;
30
+
31
+ const BLOCKED_OBJECT_KEYS = new Set(["__proto__", "constructor", "prototype"]);
32
+ const VALID_ACCOUNT_ID_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
33
+ const INVALID_ACCOUNT_ID_CHARS_RE = /[^a-z0-9_-]+/g;
34
+ const LEADING_DASH_RE = /^-+/;
35
+ const TRAILING_DASH_RE = /-+$/;
36
+ const NORMALIZE_ACCOUNT_ID_CACHE_MAX = 512;
37
+ const normalizeAccountIdCache = new Map<string, string>();
38
+
39
+ function setNormalizeCache<T>(cache: Map<string, T>, key: string, value: T): void {
40
+ cache.set(key, value);
41
+ if (cache.size <= NORMALIZE_ACCOUNT_ID_CACHE_MAX) {
42
+ return;
43
+ }
44
+ const oldest = cache.keys().next();
45
+ if (!oldest.done) {
46
+ cache.delete(oldest.value);
47
+ }
48
+ }
49
+
50
+ function canonicalizeAccountId(value: string): string {
51
+ if (VALID_ACCOUNT_ID_RE.test(value)) {
52
+ return value.toLowerCase();
53
+ }
54
+ return value
55
+ .toLowerCase()
56
+ .replace(INVALID_ACCOUNT_ID_CHARS_RE, "-")
57
+ .replace(LEADING_DASH_RE, "")
58
+ .replace(TRAILING_DASH_RE, "")
59
+ .slice(0, 64);
60
+ }
61
+
62
+ export function normalizeAccountId(value: string | undefined | null): string {
63
+ const trimmed = (value ?? "").trim();
64
+ if (!trimmed) {
65
+ return DEFAULT_ACCOUNT_ID;
66
+ }
67
+ const cached = normalizeAccountIdCache.get(trimmed);
68
+ if (cached) {
69
+ return cached;
70
+ }
71
+ const canonical = canonicalizeAccountId(trimmed);
72
+ const normalized = canonical && !BLOCKED_OBJECT_KEYS.has(canonical) ? canonical : DEFAULT_ACCOUNT_ID;
73
+ setNormalizeCache(normalizeAccountIdCache, trimmed, normalized);
74
+ return normalized;
75
+ }
76
+
77
+ export type AllowlistMatchSource =
78
+ | "wildcard"
79
+ | "id"
80
+ | "name"
81
+ | "tag"
82
+ | "username"
83
+ | "prefixed-id"
84
+ | "prefixed-user"
85
+ | "prefixed-name"
86
+ | "slug"
87
+ | "localpart";
88
+
89
+ export type AllowlistMatch<TSource extends string = AllowlistMatchSource> = {
90
+ allowed: boolean;
91
+ matchKey?: string;
92
+ matchSource?: TSource;
93
+ };
94
+
95
+ export type DmPolicy = "pairing" | "allowlist" | "open" | "disabled";
96
+ export type GroupPolicy = "open" | "disabled" | "allowlist";
97
+
98
+ export type DmConfig = {
99
+ historyLimit?: number;
100
+ };
101
+
102
+ export type GroupToolPolicyConfig = {
103
+ allow?: string[];
104
+ alsoAllow?: string[];
105
+ deny?: string[];
106
+ };
107
+
108
+ export type BlockStreamingCoalesceConfig = {
109
+ minChars?: number;
110
+ maxChars?: number;
111
+ idleMs?: number;
112
+ };
113
+
114
+ export type ChannelGroupContext = {
115
+ cfg: OpenClawConfig;
116
+ groupId?: string | null;
117
+ groupChannel?: string | null;
118
+ groupSpace?: string | null;
119
+ accountId?: string | null;
120
+ senderId?: string | null;
121
+ senderName?: string | null;
122
+ senderUsername?: string | null;
123
+ senderE164?: string | null;
124
+ };
125
+
126
+ type ZodSchemaWithToJsonSchema = ZodTypeAny & {
127
+ toJSONSchema?: (params?: Record<string, unknown>) => unknown;
128
+ };
129
+
130
+ type ChannelSection = {
131
+ accounts?: Record<string, Record<string, unknown>>;
132
+ enabled?: boolean;
133
+ name?: string;
134
+ };
135
+
136
+ function channelHasAccounts(cfg: OpenClawConfig, channelKey: string): boolean {
137
+ const channels = cfg.channels as Record<string, unknown> | undefined;
138
+ const base = channels?.[channelKey] as ChannelSection | undefined;
139
+ return Boolean(base?.accounts && Object.keys(base.accounts).length > 0);
140
+ }
141
+
142
+ function shouldStoreNameInAccounts(params: {
143
+ cfg: OpenClawConfig;
144
+ channelKey: string;
145
+ accountId: string;
146
+ alwaysUseAccounts?: boolean;
147
+ }): boolean {
148
+ if (params.alwaysUseAccounts) {
149
+ return true;
150
+ }
151
+ if (params.accountId !== DEFAULT_ACCOUNT_ID) {
152
+ return true;
153
+ }
154
+ return channelHasAccounts(params.cfg, params.channelKey);
155
+ }
156
+
157
+ export function applyAccountNameToChannelSection(params: {
158
+ cfg: OpenClawConfig;
159
+ channelKey: string;
160
+ accountId: string;
161
+ name?: string;
162
+ alwaysUseAccounts?: boolean;
163
+ }): OpenClawConfig {
164
+ const trimmed = params.name?.trim();
165
+ if (!trimmed) {
166
+ return params.cfg;
167
+ }
168
+ const accountId = normalizeAccountId(params.accountId);
169
+ const channels = params.cfg.channels as Record<string, unknown> | undefined;
170
+ const baseConfig = channels?.[params.channelKey];
171
+ const base =
172
+ typeof baseConfig === "object" && baseConfig ? (baseConfig as ChannelSection) : undefined;
173
+ const useAccounts = shouldStoreNameInAccounts({
174
+ cfg: params.cfg,
175
+ channelKey: params.channelKey,
176
+ accountId,
177
+ alwaysUseAccounts: params.alwaysUseAccounts,
178
+ });
179
+ if (!useAccounts && accountId === DEFAULT_ACCOUNT_ID) {
180
+ const safeBase = base ?? {};
181
+ return {
182
+ ...params.cfg,
183
+ channels: {
184
+ ...params.cfg.channels,
185
+ [params.channelKey]: {
186
+ ...safeBase,
187
+ name: trimmed,
188
+ },
189
+ },
190
+ } as OpenClawConfig;
191
+ }
192
+
193
+ const baseAccounts: Record<string, Record<string, unknown>> = base?.accounts ?? {};
194
+ const existingAccount = baseAccounts[accountId] ?? {};
195
+ const baseWithoutName =
196
+ accountId === DEFAULT_ACCOUNT_ID
197
+ ? (({ name: _ignored, ...rest }) => rest)(base ?? {})
198
+ : (base ?? {});
199
+
200
+ return {
201
+ ...params.cfg,
202
+ channels: {
203
+ ...params.cfg.channels,
204
+ [params.channelKey]: {
205
+ ...baseWithoutName,
206
+ accounts: {
207
+ ...baseAccounts,
208
+ [accountId]: {
209
+ ...existingAccount,
210
+ name: trimmed,
211
+ },
212
+ },
213
+ },
214
+ },
215
+ } as OpenClawConfig;
216
+ }
217
+
218
+ export function setAccountEnabledInConfigSection(params: {
219
+ cfg: OpenClawConfig;
220
+ sectionKey: string;
221
+ accountId: string;
222
+ enabled: boolean;
223
+ allowTopLevel?: boolean;
224
+ }): OpenClawConfig {
225
+ const accountKey = params.accountId || DEFAULT_ACCOUNT_ID;
226
+ const channels = params.cfg.channels as Record<string, unknown> | undefined;
227
+ const base = channels?.[params.sectionKey] as ChannelSection | undefined;
228
+ const hasAccounts = Boolean(base?.accounts);
229
+
230
+ if (params.allowTopLevel && accountKey === DEFAULT_ACCOUNT_ID && !hasAccounts) {
231
+ return {
232
+ ...params.cfg,
233
+ channels: {
234
+ ...params.cfg.channels,
235
+ [params.sectionKey]: {
236
+ ...base,
237
+ enabled: params.enabled,
238
+ },
239
+ },
240
+ } as OpenClawConfig;
241
+ }
242
+
243
+ const baseAccounts = base?.accounts ?? {};
244
+ const existing = baseAccounts[accountKey] ?? {};
245
+ return {
246
+ ...params.cfg,
247
+ channels: {
248
+ ...params.cfg.channels,
249
+ [params.sectionKey]: {
250
+ ...base,
251
+ accounts: {
252
+ ...baseAccounts,
253
+ [accountKey]: {
254
+ ...existing,
255
+ enabled: params.enabled,
256
+ },
257
+ },
258
+ },
259
+ },
260
+ } as OpenClawConfig;
261
+ }
262
+
263
+ export function deleteAccountFromConfigSection(params: {
264
+ cfg: OpenClawConfig;
265
+ sectionKey: string;
266
+ accountId: string;
267
+ clearBaseFields?: string[];
268
+ }): OpenClawConfig {
269
+ const accountKey = params.accountId || DEFAULT_ACCOUNT_ID;
270
+ const channels = params.cfg.channels as Record<string, unknown> | undefined;
271
+ const base = channels?.[params.sectionKey] as ChannelSection | undefined;
272
+ if (!base) {
273
+ return params.cfg;
274
+ }
275
+
276
+ const baseAccounts =
277
+ base.accounts && typeof base.accounts === "object" ? { ...base.accounts } : undefined;
278
+
279
+ if (accountKey !== DEFAULT_ACCOUNT_ID) {
280
+ const accounts = baseAccounts ? { ...baseAccounts } : {};
281
+ delete accounts[accountKey];
282
+ return {
283
+ ...params.cfg,
284
+ channels: {
285
+ ...params.cfg.channels,
286
+ [params.sectionKey]: {
287
+ ...base,
288
+ accounts: Object.keys(accounts).length ? accounts : undefined,
289
+ },
290
+ },
291
+ } as OpenClawConfig;
292
+ }
293
+
294
+ if (baseAccounts && Object.keys(baseAccounts).length > 0) {
295
+ delete baseAccounts[accountKey];
296
+ const baseRecord = { ...(base as Record<string, unknown>) };
297
+ for (const field of params.clearBaseFields ?? []) {
298
+ if (field in baseRecord) {
299
+ baseRecord[field] = undefined;
300
+ }
301
+ }
302
+ return {
303
+ ...params.cfg,
304
+ channels: {
305
+ ...params.cfg.channels,
306
+ [params.sectionKey]: {
307
+ ...baseRecord,
308
+ accounts: Object.keys(baseAccounts).length ? baseAccounts : undefined,
309
+ },
310
+ },
311
+ } as OpenClawConfig;
312
+ }
313
+
314
+ const nextChannels = { ...params.cfg.channels } as Record<string, unknown>;
315
+ delete nextChannels[params.sectionKey];
316
+ const nextCfg = { ...params.cfg } as OpenClawConfig;
317
+ if (Object.keys(nextChannels).length > 0) {
318
+ nextCfg.channels = nextChannels as OpenClawConfig["channels"];
319
+ } else {
320
+ delete nextCfg.channels;
321
+ }
322
+ return nextCfg;
323
+ }
324
+
325
+ export function mapAllowFromEntries(
326
+ allowFrom: Array<string | number> | null | undefined,
327
+ ): string[] {
328
+ return (allowFrom ?? []).map((entry) => String(entry));
329
+ }
330
+
331
+ type ChannelMatchSource = "direct" | "parent" | "wildcard";
332
+
333
+ type ChannelEntryMatch<T> = {
334
+ entry?: T;
335
+ key?: string;
336
+ wildcardEntry?: T;
337
+ wildcardKey?: string;
338
+ parentEntry?: T;
339
+ parentKey?: string;
340
+ matchKey?: string;
341
+ matchSource?: ChannelMatchSource;
342
+ };
343
+
344
+ export function normalizeChannelSlug(value: string): string {
345
+ return value
346
+ .trim()
347
+ .toLowerCase()
348
+ .replace(/^#/, "")
349
+ .replace(/[^a-z0-9]+/g, "-")
350
+ .replace(/^-+|-+$/g, "");
351
+ }
352
+
353
+ export function buildChannelKeyCandidates(...keys: Array<string | undefined | null>): string[] {
354
+ const seen = new Set<string>();
355
+ const candidates: string[] = [];
356
+ for (const key of keys) {
357
+ if (typeof key !== "string") {
358
+ continue;
359
+ }
360
+ const trimmed = key.trim();
361
+ if (!trimmed || seen.has(trimmed)) {
362
+ continue;
363
+ }
364
+ seen.add(trimmed);
365
+ candidates.push(trimmed);
366
+ }
367
+ return candidates;
368
+ }
369
+
370
+ function resolveChannelEntryMatch<T>(params: {
371
+ entries?: Record<string, T>;
372
+ keys: string[];
373
+ wildcardKey?: string;
374
+ }): ChannelEntryMatch<T> {
375
+ const entries = params.entries ?? {};
376
+ const match: ChannelEntryMatch<T> = {};
377
+ for (const key of params.keys) {
378
+ if (!Object.prototype.hasOwnProperty.call(entries, key)) {
379
+ continue;
380
+ }
381
+ match.entry = entries[key];
382
+ match.key = key;
383
+ break;
384
+ }
385
+ if (params.wildcardKey && Object.prototype.hasOwnProperty.call(entries, params.wildcardKey)) {
386
+ match.wildcardEntry = entries[params.wildcardKey];
387
+ match.wildcardKey = params.wildcardKey;
388
+ }
389
+ return match;
390
+ }
391
+
392
+ export function resolveChannelEntryMatchWithFallback<T>(params: {
393
+ entries?: Record<string, T>;
394
+ keys: string[];
395
+ parentKeys?: string[];
396
+ wildcardKey?: string;
397
+ normalizeKey?: (value: string) => string;
398
+ }): ChannelEntryMatch<T> {
399
+ const direct = resolveChannelEntryMatch({
400
+ entries: params.entries,
401
+ keys: params.keys,
402
+ wildcardKey: params.wildcardKey,
403
+ });
404
+
405
+ if (direct.entry && direct.key) {
406
+ return { ...direct, matchKey: direct.key, matchSource: "direct" };
407
+ }
408
+
409
+ const normalizeKey = params.normalizeKey;
410
+ if (normalizeKey) {
411
+ const normalizedKeys = params.keys.map((key) => normalizeKey(key)).filter(Boolean);
412
+ if (normalizedKeys.length > 0) {
413
+ for (const [entryKey, entry] of Object.entries(params.entries ?? {})) {
414
+ const normalizedEntry = normalizeKey(entryKey);
415
+ if (normalizedEntry && normalizedKeys.includes(normalizedEntry)) {
416
+ return {
417
+ ...direct,
418
+ entry,
419
+ key: entryKey,
420
+ matchKey: entryKey,
421
+ matchSource: "direct",
422
+ };
423
+ }
424
+ }
425
+ }
426
+ }
427
+
428
+ const parentKeys = params.parentKeys ?? [];
429
+ if (parentKeys.length > 0) {
430
+ const parent = resolveChannelEntryMatch({ entries: params.entries, keys: parentKeys });
431
+ if (parent.entry && parent.key) {
432
+ return {
433
+ ...direct,
434
+ entry: parent.entry,
435
+ key: parent.key,
436
+ parentEntry: parent.entry,
437
+ parentKey: parent.key,
438
+ matchKey: parent.key,
439
+ matchSource: "parent",
440
+ };
441
+ }
442
+ if (normalizeKey) {
443
+ const normalizedParentKeys = parentKeys.map((key) => normalizeKey(key)).filter(Boolean);
444
+ if (normalizedParentKeys.length > 0) {
445
+ for (const [entryKey, entry] of Object.entries(params.entries ?? {})) {
446
+ const normalizedEntry = normalizeKey(entryKey);
447
+ if (normalizedEntry && normalizedParentKeys.includes(normalizedEntry)) {
448
+ return {
449
+ ...direct,
450
+ entry,
451
+ key: entryKey,
452
+ parentEntry: entry,
453
+ parentKey: entryKey,
454
+ matchKey: entryKey,
455
+ matchSource: "parent",
456
+ };
457
+ }
458
+ }
459
+ }
460
+ }
461
+ }
462
+
463
+ if (direct.wildcardEntry && direct.wildcardKey) {
464
+ return {
465
+ ...direct,
466
+ entry: direct.wildcardEntry,
467
+ key: direct.wildcardKey,
468
+ matchKey: direct.wildcardKey,
469
+ matchSource: "wildcard",
470
+ };
471
+ }
472
+
473
+ return direct;
474
+ }
475
+
476
+ export function resolveNestedAllowlistDecision(params: {
477
+ outerConfigured: boolean;
478
+ outerMatched: boolean;
479
+ innerConfigured: boolean;
480
+ innerMatched: boolean;
481
+ }): boolean {
482
+ if (!params.outerConfigured) {
483
+ return true;
484
+ }
485
+ if (!params.outerMatched) {
486
+ return false;
487
+ }
488
+ if (!params.innerConfigured) {
489
+ return true;
490
+ }
491
+ return params.innerMatched;
492
+ }
493
+
494
+ export function resolveMentionGatingWithBypass(params: {
495
+ isGroup: boolean;
496
+ requireMention: boolean;
497
+ canDetectMention: boolean;
498
+ wasMentioned: boolean;
499
+ implicitMention?: boolean;
500
+ hasAnyMention?: boolean;
501
+ allowTextCommands: boolean;
502
+ hasControlCommand: boolean;
503
+ commandAuthorized: boolean;
504
+ }): { effectiveWasMentioned: boolean; shouldSkip: boolean; shouldBypassMention: boolean } {
505
+ const shouldBypassMention =
506
+ params.isGroup &&
507
+ params.requireMention &&
508
+ !params.wasMentioned &&
509
+ !(params.hasAnyMention ?? false) &&
510
+ params.allowTextCommands &&
511
+ params.commandAuthorized &&
512
+ params.hasControlCommand;
513
+ const effectiveWasMentioned =
514
+ params.wasMentioned || params.implicitMention === true || shouldBypassMention;
515
+ return {
516
+ effectiveWasMentioned,
517
+ shouldSkip: params.requireMention && params.canDetectMention && !effectiveWasMentioned,
518
+ shouldBypassMention,
519
+ };
520
+ }
521
+
522
+ type CommandAuthorizer = {
523
+ configured: boolean;
524
+ allowed: boolean;
525
+ };
526
+
527
+ export function resolveControlCommandGate(params: {
528
+ useAccessGroups: boolean;
529
+ authorizers: CommandAuthorizer[];
530
+ allowTextCommands: boolean;
531
+ hasControlCommand: boolean;
532
+ modeWhenAccessGroupsOff?: "allow" | "deny" | "configured";
533
+ }): { commandAuthorized: boolean; shouldBlock: boolean } {
534
+ const mode = params.modeWhenAccessGroupsOff ?? "allow";
535
+ let commandAuthorized = false;
536
+
537
+ if (!params.useAccessGroups) {
538
+ if (mode === "allow") {
539
+ commandAuthorized = true;
540
+ } else if (mode === "deny") {
541
+ commandAuthorized = false;
542
+ } else {
543
+ const anyConfigured = params.authorizers.some((entry) => entry.configured);
544
+ commandAuthorized = !anyConfigured
545
+ ? true
546
+ : params.authorizers.some((entry) => entry.configured && entry.allowed);
547
+ }
548
+ } else {
549
+ commandAuthorized = params.authorizers.some((entry) => entry.configured && entry.allowed);
550
+ }
551
+
552
+ return {
553
+ commandAuthorized,
554
+ shouldBlock:
555
+ params.allowTextCommands && params.hasControlCommand && !commandAuthorized,
556
+ };
557
+ }
558
+
559
+ export function logInboundDrop(params: {
560
+ log: (message: string) => void;
561
+ channel: string;
562
+ reason: string;
563
+ target?: string;
564
+ }): void {
565
+ const target = params.target ? ` target=${params.target}` : "";
566
+ params.log(`${params.channel}: drop ${params.reason}${target}`);
567
+ }
568
+
569
+ export function buildChannelConfigSchema(schema: ZodTypeAny): ChannelConfigSchema {
570
+ const schemaWithJson = schema as ZodSchemaWithToJsonSchema;
571
+ if (typeof schemaWithJson.toJSONSchema === "function") {
572
+ return {
573
+ schema: schemaWithJson.toJSONSchema({
574
+ target: "draft-07",
575
+ unrepresentable: "any",
576
+ }) as Record<string, unknown>,
577
+ };
578
+ }
579
+ return {
580
+ schema: {
581
+ type: "object",
582
+ additionalProperties: true,
583
+ },
584
+ };
585
+ }
586
+
587
+ export const DmPolicySchema = z.enum(["pairing", "allowlist", "open", "disabled"]);
588
+ export const GroupPolicySchema = z.enum(["open", "disabled", "allowlist"]);
589
+
590
+ export const DmConfigSchema = z
591
+ .object({
592
+ historyLimit: z.number().int().min(0).optional(),
593
+ })
594
+ .strict();
595
+
596
+ export const BlockStreamingCoalesceSchema = z
597
+ .object({
598
+ minChars: z.number().int().positive().optional(),
599
+ maxChars: z.number().int().positive().optional(),
600
+ idleMs: z.number().int().nonnegative().optional(),
601
+ })
602
+ .strict();
603
+
604
+ export const MarkdownConfigSchema = z
605
+ .object({
606
+ tables: z.enum(["off", "bullets", "code"]).optional(),
607
+ })
608
+ .strict()
609
+ .optional();
610
+
611
+ export const ToolPolicySchema = z
612
+ .object({
613
+ allow: z.array(z.string()).optional(),
614
+ alsoAllow: z.array(z.string()).optional(),
615
+ deny: z.array(z.string()).optional(),
616
+ })
617
+ .strict()
618
+ .superRefine((value, ctx) => {
619
+ if (value.allow && value.allow.length > 0 && value.alsoAllow && value.alsoAllow.length > 0) {
620
+ ctx.addIssue({
621
+ code: z.ZodIssueCode.custom,
622
+ message:
623
+ "tools policy cannot set both allow and alsoAllow in the same scope",
624
+ });
625
+ }
626
+ });
627
+
628
+ function normalizeAllowFrom(values?: Array<string | number>): string[] {
629
+ return (values ?? []).map((value) => String(value).trim()).filter(Boolean);
630
+ }
631
+
632
+ export function requireOpenAllowFrom(params: {
633
+ policy?: string;
634
+ allowFrom?: Array<string | number>;
635
+ ctx: RefinementCtx;
636
+ path: Array<string | number>;
637
+ message: string;
638
+ }): void {
639
+ if (params.policy !== "open") {
640
+ return;
641
+ }
642
+ const allow = normalizeAllowFrom(params.allowFrom);
643
+ if (allow.includes("*")) {
644
+ return;
645
+ }
646
+ params.ctx.addIssue({
647
+ code: z.ZodIssueCode.custom,
648
+ path: params.path,
649
+ message: params.message,
650
+ });
651
+ }
652
+
653
+ export type AgentMediaPayload = {
654
+ MediaPath?: string;
655
+ MediaType?: string;
656
+ MediaUrl?: string;
657
+ MediaPaths?: string[];
658
+ MediaUrls?: string[];
659
+ MediaTypes?: string[];
660
+ };
661
+
662
+ export function buildAgentMediaPayload(
663
+ mediaList: Array<{ path: string; contentType?: string | null }>,
664
+ ): AgentMediaPayload {
665
+ const first = mediaList[0];
666
+ const mediaPaths = mediaList.map((media) => media.path);
667
+ const mediaTypes = mediaList.map((media) => media.contentType).filter(Boolean) as string[];
668
+ return {
669
+ MediaPath: first?.path,
670
+ MediaType: first?.contentType ?? undefined,
671
+ MediaUrl: first?.path,
672
+ MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
673
+ MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined,
674
+ MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
675
+ };
676
+ }
677
+
678
+ export const PAIRING_APPROVED_MESSAGE =
679
+ "✅ OpenClaw access approved. Send a message to start chatting.";
680
+
681
+ export function missingTargetError(provider: string, hint?: string): Error {
682
+ const normalizedHint = hint?.trim();
683
+ return new Error(
684
+ `Delivering to ${provider} requires target${normalizedHint ? ` ${normalizedHint}` : ""}`,
685
+ );
686
+ }
687
+
688
+ type RequestBodyLimitErrorCode =
689
+ | "PAYLOAD_TOO_LARGE"
690
+ | "REQUEST_BODY_TIMEOUT"
691
+ | "CONNECTION_CLOSED";
692
+
693
+ const DEFAULT_REQUEST_BODY_ERROR_MESSAGE: Record<RequestBodyLimitErrorCode, string> = {
694
+ PAYLOAD_TOO_LARGE: "PayloadTooLarge",
695
+ REQUEST_BODY_TIMEOUT: "RequestBodyTimeout",
696
+ CONNECTION_CLOSED: "RequestBodyConnectionClosed",
697
+ };
698
+
699
+ const DEFAULT_REQUEST_BODY_RESPONSE_MESSAGE: Record<RequestBodyLimitErrorCode, string> = {
700
+ PAYLOAD_TOO_LARGE: "Payload too large",
701
+ REQUEST_BODY_TIMEOUT: "Request body timeout",
702
+ CONNECTION_CLOSED: "Connection closed",
703
+ };
704
+
705
+ class RequestBodyLimitError extends Error {
706
+ readonly code: RequestBodyLimitErrorCode;
707
+
708
+ constructor(code: RequestBodyLimitErrorCode, message?: string) {
709
+ super(message ?? DEFAULT_REQUEST_BODY_ERROR_MESSAGE[code]);
710
+ this.name = "RequestBodyLimitError";
711
+ this.code = code;
712
+ }
713
+ }
714
+
715
+ function isRequestBodyLimitError(error: unknown): error is RequestBodyLimitError {
716
+ return error instanceof RequestBodyLimitError;
717
+ }
718
+
719
+ function requestBodyErrorToText(code: RequestBodyLimitErrorCode): string {
720
+ return DEFAULT_REQUEST_BODY_RESPONSE_MESSAGE[code];
721
+ }
722
+
723
+ function parseContentLengthHeader(req: IncomingMessage): number | null {
724
+ const header = req.headers["content-length"];
725
+ const raw = Array.isArray(header) ? header[0] : header;
726
+ if (typeof raw !== "string") {
727
+ return null;
728
+ }
729
+ const parsed = Number.parseInt(raw, 10);
730
+ if (!Number.isFinite(parsed) || parsed < 0) {
731
+ return null;
732
+ }
733
+ return parsed;
734
+ }
735
+
736
+ async function readRequestBodyWithLimit(
737
+ req: IncomingMessage,
738
+ options: { maxBytes: number; timeoutMs?: number; encoding?: BufferEncoding },
739
+ ): Promise<string> {
740
+ const maxBytes = Number.isFinite(options.maxBytes)
741
+ ? Math.max(1, Math.floor(options.maxBytes))
742
+ : 1;
743
+ const timeoutMs =
744
+ typeof options.timeoutMs === "number" && Number.isFinite(options.timeoutMs)
745
+ ? Math.max(1, Math.floor(options.timeoutMs))
746
+ : DEFAULT_WEBHOOK_BODY_TIMEOUT_MS;
747
+ const encoding = options.encoding ?? "utf-8";
748
+
749
+ const declaredLength = parseContentLengthHeader(req);
750
+ if (declaredLength !== null && declaredLength > maxBytes) {
751
+ if (!req.destroyed) {
752
+ req.destroy();
753
+ }
754
+ throw new RequestBodyLimitError("PAYLOAD_TOO_LARGE");
755
+ }
756
+
757
+ return await new Promise((resolve, reject) => {
758
+ let done = false;
759
+ let ended = false;
760
+ let totalBytes = 0;
761
+ const chunks: Buffer[] = [];
762
+
763
+ const cleanup = () => {
764
+ req.removeListener("data", onData);
765
+ req.removeListener("end", onEnd);
766
+ req.removeListener("error", onError);
767
+ req.removeListener("close", onClose);
768
+ clearTimeout(timer);
769
+ };
770
+
771
+ const finish = (cb: () => void) => {
772
+ if (done) {
773
+ return;
774
+ }
775
+ done = true;
776
+ cleanup();
777
+ cb();
778
+ };
779
+
780
+ const fail = (error: Error) => {
781
+ finish(() => reject(error));
782
+ };
783
+
784
+ const timer = setTimeout(() => {
785
+ if (!req.destroyed) {
786
+ req.destroy();
787
+ }
788
+ fail(new RequestBodyLimitError("REQUEST_BODY_TIMEOUT"));
789
+ }, timeoutMs);
790
+
791
+ const onData = (chunk: Buffer | string) => {
792
+ if (done) {
793
+ return;
794
+ }
795
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
796
+ totalBytes += buffer.length;
797
+ if (totalBytes > maxBytes) {
798
+ if (!req.destroyed) {
799
+ req.destroy();
800
+ }
801
+ fail(new RequestBodyLimitError("PAYLOAD_TOO_LARGE"));
802
+ return;
803
+ }
804
+ chunks.push(buffer);
805
+ };
806
+
807
+ const onEnd = () => {
808
+ ended = true;
809
+ finish(() => resolve(Buffer.concat(chunks).toString(encoding)));
810
+ };
811
+
812
+ const onError = (error: Error) => {
813
+ if (!done) {
814
+ fail(error);
815
+ }
816
+ };
817
+
818
+ const onClose = () => {
819
+ if (!done && !ended) {
820
+ fail(new RequestBodyLimitError("CONNECTION_CLOSED"));
821
+ }
822
+ };
823
+
824
+ req.on("data", onData);
825
+ req.on("end", onEnd);
826
+ req.on("error", onError);
827
+ req.on("close", onClose);
828
+ });
829
+ }
830
+
831
+ export async function readJsonBodyWithLimit(
832
+ req: IncomingMessage,
833
+ options: { maxBytes: number; timeoutMs?: number; emptyObjectOnEmpty?: boolean },
834
+ ): Promise<
835
+ | { ok: true; value: unknown; raw: string }
836
+ | { ok: false; error: string; code: RequestBodyLimitErrorCode | "INVALID_JSON" }
837
+ > {
838
+ try {
839
+ const raw = await readRequestBodyWithLimit(req, options);
840
+ const trimmed = raw.trim();
841
+ if (!trimmed) {
842
+ if (options.emptyObjectOnEmpty === false) {
843
+ return { ok: false, code: "INVALID_JSON", error: "empty payload" };
844
+ }
845
+ return { ok: true, value: {}, raw: trimmed };
846
+ }
847
+ try {
848
+ return { ok: true, value: JSON.parse(trimmed) as unknown, raw: trimmed };
849
+ } catch (error) {
850
+ return {
851
+ ok: false,
852
+ code: "INVALID_JSON",
853
+ error: error instanceof Error ? error.message : String(error),
854
+ };
855
+ }
856
+ } catch (error) {
857
+ if (isRequestBodyLimitError(error)) {
858
+ return { ok: false, code: error.code, error: requestBodyErrorToText(error.code) };
859
+ }
860
+ return {
861
+ ok: false,
862
+ code: "INVALID_JSON",
863
+ error: error instanceof Error ? error.message : String(error),
864
+ };
865
+ }
866
+ }
867
+
868
+ const EXT_BY_MIME: Record<string, string> = {
869
+ "image/heic": ".heic",
870
+ "image/heif": ".heif",
871
+ "image/jpeg": ".jpg",
872
+ "image/png": ".png",
873
+ "image/webp": ".webp",
874
+ "image/gif": ".gif",
875
+ "audio/ogg": ".ogg",
876
+ "audio/mpeg": ".mp3",
877
+ "audio/wav": ".wav",
878
+ "audio/flac": ".flac",
879
+ "audio/aac": ".aac",
880
+ "audio/opus": ".opus",
881
+ "audio/x-m4a": ".m4a",
882
+ "audio/mp4": ".m4a",
883
+ "video/mp4": ".mp4",
884
+ "video/quicktime": ".mov",
885
+ "application/pdf": ".pdf",
886
+ "application/json": ".json",
887
+ "application/zip": ".zip",
888
+ "application/gzip": ".gz",
889
+ "application/x-tar": ".tar",
890
+ "application/x-7z-compressed": ".7z",
891
+ "application/vnd.rar": ".rar",
892
+ "application/msword": ".doc",
893
+ "application/vnd.ms-excel": ".xls",
894
+ "application/vnd.ms-powerpoint": ".ppt",
895
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
896
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
897
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
898
+ "text/csv": ".csv",
899
+ "text/plain": ".txt",
900
+ "text/markdown": ".md",
901
+ };
902
+
903
+ const MIME_BY_EXT: Record<string, string> = {
904
+ ...Object.fromEntries(Object.entries(EXT_BY_MIME).map(([mime, ext]) => [ext, mime])),
905
+ ".jpeg": "image/jpeg",
906
+ ".js": "text/javascript",
907
+ };
908
+
909
+ function normalizeMimeType(mime?: string | null): string | undefined {
910
+ if (!mime) {
911
+ return undefined;
912
+ }
913
+ const cleaned = mime.split(";")[0]?.trim().toLowerCase();
914
+ return cleaned || undefined;
915
+ }
916
+
917
+ function getFileExtension(filePath?: string | null): string | undefined {
918
+ if (!filePath) {
919
+ return undefined;
920
+ }
921
+ try {
922
+ if (/^https?:\/\//i.test(filePath)) {
923
+ const url = new URL(filePath);
924
+ return path.extname(url.pathname).toLowerCase() || undefined;
925
+ }
926
+ } catch {
927
+ // ignore malformed URLs and fall back to path parsing
928
+ }
929
+ const ext = path.extname(filePath).toLowerCase();
930
+ return ext || undefined;
931
+ }
932
+
933
+ function detectMimeFromBuffer(buffer?: Buffer): string | undefined {
934
+ if (!buffer || buffer.length < 4) {
935
+ return undefined;
936
+ }
937
+ if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
938
+ return "image/jpeg";
939
+ }
940
+ if (
941
+ buffer.length >= 8 &&
942
+ buffer[0] === 0x89 &&
943
+ buffer[1] === 0x50 &&
944
+ buffer[2] === 0x4e &&
945
+ buffer[3] === 0x47 &&
946
+ buffer[4] === 0x0d &&
947
+ buffer[5] === 0x0a &&
948
+ buffer[6] === 0x1a &&
949
+ buffer[7] === 0x0a
950
+ ) {
951
+ return "image/png";
952
+ }
953
+ if (buffer.subarray(0, 4).toString("ascii") === "GIF8") {
954
+ return "image/gif";
955
+ }
956
+ if (
957
+ buffer.length >= 12 &&
958
+ buffer.subarray(0, 4).toString("ascii") === "RIFF" &&
959
+ buffer.subarray(8, 12).toString("ascii") === "WEBP"
960
+ ) {
961
+ return "image/webp";
962
+ }
963
+ if (buffer.subarray(0, 5).toString("ascii") === "%PDF-") {
964
+ return "application/pdf";
965
+ }
966
+ if (
967
+ buffer.length >= 12 &&
968
+ buffer.subarray(4, 8).toString("ascii") === "ftyp"
969
+ ) {
970
+ const brand = buffer.subarray(8, 12).toString("ascii");
971
+ if (brand === "qt ") {
972
+ return "video/quicktime";
973
+ }
974
+ if (brand === "M4A " || brand === "M4B " || brand === "M4P ") {
975
+ return "audio/mp4";
976
+ }
977
+ return "video/mp4";
978
+ }
979
+ if (
980
+ buffer.length >= 12 &&
981
+ buffer.subarray(0, 4).toString("ascii") === "RIFF" &&
982
+ buffer.subarray(8, 12).toString("ascii") === "WAVE"
983
+ ) {
984
+ return "audio/wav";
985
+ }
986
+ if (buffer.subarray(0, 4).toString("ascii") === "OggS") {
987
+ return "audio/ogg";
988
+ }
989
+ if (buffer.subarray(0, 4).toString("ascii") === "fLaC") {
990
+ return "audio/flac";
991
+ }
992
+ if (buffer.subarray(0, 3).toString("ascii") === "ID3") {
993
+ return "audio/mpeg";
994
+ }
995
+ if (
996
+ buffer.length >= 2 &&
997
+ buffer[0] === 0xff &&
998
+ (buffer[1] & 0xe0) === 0xe0
999
+ ) {
1000
+ return "audio/mpeg";
1001
+ }
1002
+ if (
1003
+ buffer.length >= 2 &&
1004
+ buffer[0] === 0xff &&
1005
+ (buffer[1] === 0xf1 || buffer[1] === 0xf9)
1006
+ ) {
1007
+ return "audio/aac";
1008
+ }
1009
+ if (buffer.length >= 4 && buffer[0] === 0x50 && buffer[1] === 0x4b) {
1010
+ return "application/zip";
1011
+ }
1012
+ return undefined;
1013
+ }
1014
+
1015
+ function isGenericMime(mime?: string): boolean {
1016
+ if (!mime) {
1017
+ return true;
1018
+ }
1019
+ const normalized = mime.toLowerCase();
1020
+ return normalized === "application/octet-stream" || normalized === "application/zip";
1021
+ }
1022
+
1023
+ export async function detectMime(opts: {
1024
+ buffer?: Buffer;
1025
+ headerMime?: string | null;
1026
+ filePath?: string;
1027
+ }): Promise<string | undefined> {
1028
+ const ext = getFileExtension(opts.filePath);
1029
+ const extMime = ext ? MIME_BY_EXT[ext] : undefined;
1030
+ const headerMime = normalizeMimeType(opts.headerMime);
1031
+ const sniffed = detectMimeFromBuffer(opts.buffer);
1032
+
1033
+ if (sniffed && (!isGenericMime(sniffed) || !extMime)) {
1034
+ return sniffed;
1035
+ }
1036
+ if (extMime) {
1037
+ return extMime;
1038
+ }
1039
+ if (headerMime && !isGenericMime(headerMime)) {
1040
+ return headerMime;
1041
+ }
1042
+ if (sniffed) {
1043
+ return sniffed;
1044
+ }
1045
+ return headerMime;
1046
+ }
1047
+
1048
+ export function extensionForMime(mime?: string | null): string | undefined {
1049
+ const normalized = normalizeMimeType(mime);
1050
+ if (!normalized) {
1051
+ return undefined;
1052
+ }
1053
+ return EXT_BY_MIME[normalized];
1054
+ }
1055
+
1056
+ export function extractOriginalFilename(filePath: string): string {
1057
+ const basename = path.basename(filePath);
1058
+ if (!basename) {
1059
+ return "file.bin";
1060
+ }
1061
+ const ext = path.extname(basename);
1062
+ const nameWithoutExt = path.basename(basename, ext);
1063
+ const match = nameWithoutExt.match(
1064
+ /^(.+)---[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i,
1065
+ );
1066
+ if (match?.[1]) {
1067
+ return `${match[1]}${ext}`;
1068
+ }
1069
+ return basename;
1070
+ }