@clawling/clawchat-plugin-openclaw 2026.6.30-4 → 2026.7.2-1
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/dist/src/api-client.js +13 -0
- package/dist/src/config.js +3 -0
- package/dist/src/friend-request-turn.js +73 -0
- package/dist/src/gate-outcome.js +26 -0
- package/dist/src/inbound.js +11 -0
- package/dist/src/permission-result.js +107 -0
- package/dist/src/permissions.js +6 -0
- package/dist/src/runtime.js +131 -9
- package/dist/src/skill-update.js +139 -69
- package/dist/src/tools.js +43 -10
- package/dist/src/ws-client.js +19 -6
- package/openclaw.plugin.json +2 -0
- package/package.json +1 -1
- package/src/api-client.ts +23 -0
- package/src/api-types.ts +1 -1
- package/src/config.ts +7 -0
- package/src/friend-request-turn.ts +87 -0
- package/src/gate-outcome.ts +44 -0
- package/src/inbound.ts +28 -0
- package/src/permission-result.ts +128 -0
- package/src/permissions.ts +13 -0
- package/src/runtime.ts +146 -10
- package/src/skill-update.ts +170 -85
- package/src/tools.ts +44 -10
- package/src/ws-client.ts +21 -6
package/src/skill-update.ts
CHANGED
|
@@ -30,8 +30,12 @@ import { fileURLToPath } from "node:url";
|
|
|
30
30
|
* implementation in `@clawling/clawchat-plugin-install-cli`
|
|
31
31
|
* (`packages/core/src/skills/check-update.ts`). That package is
|
|
32
32
|
* workspace-private, so the logic is duplicated here rather than imported.
|
|
33
|
-
* -
|
|
34
|
-
* (
|
|
33
|
+
* - Update detection converges on raw sha256 comparison: ANY byte difference
|
|
34
|
+
* from the official source (or a missing local file) is an update —
|
|
35
|
+
* upgrade, rollback, or same-version content fix alike. The strict semver
|
|
36
|
+
* compare (`parseComparableVersion` / `isVersionOlder`, still ported from
|
|
37
|
+
* the reference `parseComparableVersion`, `X.Y[.Z[.W]][-buildnum]`) is kept
|
|
38
|
+
* only for consent-prompt display wording, not for the `hasUpdate` decision.
|
|
35
39
|
* - All network IO goes through an injected `FetchLike`; tests never touch the
|
|
36
40
|
* real network.
|
|
37
41
|
*
|
|
@@ -138,6 +142,9 @@ export interface SkillManifestEntry {
|
|
|
138
142
|
export interface SkillsManifest {
|
|
139
143
|
schema: number;
|
|
140
144
|
skills: Record<string, Record<string, SkillManifestEntry>>;
|
|
145
|
+
/** Tombstoned skill ids per target. An adapter deletes its LOCAL copy of an
|
|
146
|
+
* id ONLY when it appears here — absence from `skills` alone never deletes. */
|
|
147
|
+
removed: Record<string, string[]>;
|
|
141
148
|
}
|
|
142
149
|
|
|
143
150
|
export interface SkillUpdate {
|
|
@@ -156,6 +163,8 @@ export interface CheckSkillUpdateOutcome {
|
|
|
156
163
|
ref: string;
|
|
157
164
|
results: SkillUpdate[];
|
|
158
165
|
hasUpdate: boolean;
|
|
166
|
+
/** Tombstoned skill ids for this target, straight from `manifest.removed`. */
|
|
167
|
+
removedIds: string[];
|
|
159
168
|
}
|
|
160
169
|
|
|
161
170
|
function skillsBase(base: string | undefined, ref: string): string {
|
|
@@ -222,7 +231,25 @@ export function parseSkillsManifest(text: string): SkillsManifest {
|
|
|
222
231
|
skills[target][skillId] = asEntry(entry, `${target}.${skillId}`);
|
|
223
232
|
}
|
|
224
233
|
}
|
|
225
|
-
|
|
234
|
+
const removed: SkillsManifest["removed"] = {};
|
|
235
|
+
if (data.removed !== undefined) {
|
|
236
|
+
if (!data.removed || typeof data.removed !== "object" || Array.isArray(data.removed)) {
|
|
237
|
+
throw new Error("skills manifest `removed` must be an object");
|
|
238
|
+
}
|
|
239
|
+
for (const [target, ids] of Object.entries(data.removed as Record<string, unknown>)) {
|
|
240
|
+
if (!Array.isArray(ids) || !ids.every((i) => typeof i === "string" && (i as string).trim())) {
|
|
241
|
+
throw new Error(`skills manifest removed[${target}] must be a list of skill ids`);
|
|
242
|
+
}
|
|
243
|
+
const cleaned = ids.map((i) => (i as string).trim());
|
|
244
|
+
for (const id of cleaned) {
|
|
245
|
+
if (skills[target]?.[id]) {
|
|
246
|
+
throw new Error(`skill ${target}.${id} is in both skills and removed`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
removed[target] = cleaned;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return { schema: 1, skills, removed };
|
|
226
253
|
}
|
|
227
254
|
|
|
228
255
|
async function fetchText(url: string, fetchFn: FetchLike): Promise<string> {
|
|
@@ -239,17 +266,22 @@ async function fetchText(url: string, fetchFn: FetchLike): Promise<string> {
|
|
|
239
266
|
}
|
|
240
267
|
|
|
241
268
|
export interface CheckSkillUpdateOptions {
|
|
242
|
-
/**
|
|
243
|
-
|
|
269
|
+
/**
|
|
270
|
+
* Effective local state of one skill (managed-over-bundled), or null when
|
|
271
|
+
* not installed. Called once per remote manifest entry — remote-driven, so
|
|
272
|
+
* dynamically delivered extras are covered too.
|
|
273
|
+
*/
|
|
274
|
+
readLocalState: (skillId: string) => Promise<LocalSkillState | null>;
|
|
244
275
|
ref?: string;
|
|
245
276
|
base?: string;
|
|
246
277
|
fetchFn: FetchLike;
|
|
247
278
|
}
|
|
248
279
|
|
|
249
280
|
/**
|
|
250
|
-
* Read the official manifest for the `openclaw` target and compare each
|
|
251
|
-
* offered
|
|
252
|
-
*
|
|
281
|
+
* Read the official manifest for the `openclaw` target and compare each
|
|
282
|
+
* skill's offered content (sha256) against its effective local state, read
|
|
283
|
+
* on demand via `readLocalState`. Any sha256 mismatch — or no local state at
|
|
284
|
+
* all — is reported as `hasUpdate: true`.
|
|
253
285
|
*/
|
|
254
286
|
export async function checkSkillUpdate(
|
|
255
287
|
options: CheckSkillUpdateOptions,
|
|
@@ -264,11 +296,14 @@ export async function checkSkillUpdate(
|
|
|
264
296
|
|
|
265
297
|
const results: SkillUpdate[] = [];
|
|
266
298
|
for (const [skillId, entry] of Object.entries(targetSkills)) {
|
|
267
|
-
const
|
|
268
|
-
|
|
299
|
+
const state = await options.readLocalState(skillId);
|
|
300
|
+
// sha convergence: any byte difference from the official source (or a
|
|
301
|
+
// missing local file) is an update — upgrade, rollback, or same-version
|
|
302
|
+
// content fix alike.
|
|
303
|
+
const hasUpdate = !state || state.sha256 !== entry.sha256;
|
|
269
304
|
results.push({
|
|
270
305
|
skillId,
|
|
271
|
-
current,
|
|
306
|
+
current: state?.version ?? null,
|
|
272
307
|
latest: entry.version,
|
|
273
308
|
hasUpdate,
|
|
274
309
|
path: entry.path,
|
|
@@ -276,7 +311,12 @@ export async function checkSkillUpdate(
|
|
|
276
311
|
bytes: entry.bytes,
|
|
277
312
|
});
|
|
278
313
|
}
|
|
279
|
-
return {
|
|
314
|
+
return {
|
|
315
|
+
ref,
|
|
316
|
+
results,
|
|
317
|
+
hasUpdate: results.some((r) => r.hasUpdate),
|
|
318
|
+
removedIds: manifest.removed[SKILL_TARGET] ?? [],
|
|
319
|
+
};
|
|
280
320
|
}
|
|
281
321
|
|
|
282
322
|
/**
|
|
@@ -318,58 +358,50 @@ export function parseSkillFrontmatterVersion(markdown: string): string | null {
|
|
|
318
358
|
return raw || null;
|
|
319
359
|
}
|
|
320
360
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
} catch {
|
|
327
|
-
return null; // missing / unreadable
|
|
328
|
-
}
|
|
361
|
+
export interface LocalSkillState {
|
|
362
|
+
/** Frontmatter `version` of the effective file, or null when unparsable. */
|
|
363
|
+
version: string | null;
|
|
364
|
+
/** Lowercase hex sha256 of the effective file's raw bytes. */
|
|
365
|
+
sha256: string;
|
|
329
366
|
}
|
|
330
367
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
const versions: Record<string, string> = {};
|
|
341
|
-
for (const skillId of skillIds) {
|
|
342
|
-
const version = await readSingleSkillVersion(skillsDir, skillId);
|
|
343
|
-
if (version) versions[skillId] = version;
|
|
368
|
+
async function readSkillStateFromDir(dir: string, skillId: string): Promise<LocalSkillState | null> {
|
|
369
|
+
try {
|
|
370
|
+
const buf = await fs.readFile(path.join(dir, skillId, "SKILL.md"));
|
|
371
|
+
return {
|
|
372
|
+
version: parseSkillFrontmatterVersion(buf.toString("utf8")),
|
|
373
|
+
sha256: crypto.createHash("sha256").update(buf).digest("hex"),
|
|
374
|
+
};
|
|
375
|
+
} catch {
|
|
376
|
+
return null;
|
|
344
377
|
}
|
|
345
|
-
return versions;
|
|
346
378
|
}
|
|
347
379
|
|
|
348
380
|
/**
|
|
349
|
-
*
|
|
350
|
-
* managed
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
* copy's. A skill present in neither is omitted (→ update available).
|
|
381
|
+
* Effective local state of ONE skill, mirroring the host's managed-over-bundled
|
|
382
|
+
* precedence: managed copy if present, else bundled, else null. Called per
|
|
383
|
+
* remote manifest entry so dynamically delivered extra skills are hashed too
|
|
384
|
+
* (a static bundled-id list would misreport extras as never-installed).
|
|
354
385
|
*/
|
|
355
|
-
export async function
|
|
386
|
+
export async function readLocalSkillState(
|
|
356
387
|
managedDir: string,
|
|
357
388
|
bundledDir: string | null,
|
|
358
|
-
|
|
359
|
-
): Promise<
|
|
360
|
-
const
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
389
|
+
skillId: string,
|
|
390
|
+
): Promise<LocalSkillState | null> {
|
|
391
|
+
const managed = await readSkillStateFromDir(managedDir, skillId);
|
|
392
|
+
if (managed) return managed;
|
|
393
|
+
if (bundledDir) return readSkillStateFromDir(bundledDir, skillId);
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** True when `<managedDir>/<skillId>/SKILL.md` exists (removal precondition). */
|
|
398
|
+
export async function managedSkillExists(managedDir: string, skillId: string): Promise<boolean> {
|
|
399
|
+
try {
|
|
400
|
+
await fs.access(path.join(managedDir, skillId, "SKILL.md"));
|
|
401
|
+
return true;
|
|
402
|
+
} catch {
|
|
403
|
+
return false;
|
|
371
404
|
}
|
|
372
|
-
return versions;
|
|
373
405
|
}
|
|
374
406
|
|
|
375
407
|
/**
|
|
@@ -458,6 +490,19 @@ export async function atomicWriteSkill(
|
|
|
458
490
|
}
|
|
459
491
|
}
|
|
460
492
|
|
|
493
|
+
/**
|
|
494
|
+
* Delete a tombstoned managed skill: remove `<skillsDir>/<skillId>/SKILL.md`
|
|
495
|
+
* (the host chokidar-watches the dir; the unlink triggers a snapshot rebuild
|
|
496
|
+
* that drops the registration) and best-effort remove the now-empty dir.
|
|
497
|
+
* Deletion is the ONE legitimate delete of a skill path, and ONLY for ids the
|
|
498
|
+
* official manifest explicitly tombstones. Idempotent.
|
|
499
|
+
*/
|
|
500
|
+
export async function removeManagedSkill(skillsDir: string, skillId: string): Promise<void> {
|
|
501
|
+
const dir = path.join(skillsDir, skillId);
|
|
502
|
+
await fs.rm(path.join(dir, "SKILL.md"), { force: true });
|
|
503
|
+
await fs.rmdir(dir).catch(() => {});
|
|
504
|
+
}
|
|
505
|
+
|
|
461
506
|
// ---------------------------------------------------------------------------
|
|
462
507
|
// Conservative consent parsing.
|
|
463
508
|
// ---------------------------------------------------------------------------
|
|
@@ -530,11 +575,16 @@ export interface PendingSkillTarget {
|
|
|
530
575
|
bytes: number;
|
|
531
576
|
}
|
|
532
577
|
|
|
578
|
+
export interface PendingSkillRemoval {
|
|
579
|
+
skillId: string;
|
|
580
|
+
}
|
|
581
|
+
|
|
533
582
|
export interface PendingSkillUpdate {
|
|
534
583
|
ownerUserId: string;
|
|
535
584
|
ref: string;
|
|
536
585
|
base?: string;
|
|
537
586
|
updates: PendingSkillTarget[];
|
|
587
|
+
removals: PendingSkillRemoval[];
|
|
538
588
|
createdAt: number;
|
|
539
589
|
expiresAt: number;
|
|
540
590
|
}
|
|
@@ -576,8 +626,13 @@ export interface SkillUpdateLog {
|
|
|
576
626
|
|
|
577
627
|
export interface RunSkillUpdateCheckOptions {
|
|
578
628
|
ownerUserId: string;
|
|
579
|
-
/**
|
|
580
|
-
|
|
629
|
+
/**
|
|
630
|
+
* Effective local state of one skill (managed-over-bundled), or null when
|
|
631
|
+
* not installed. See {@link CheckSkillUpdateOptions.readLocalState}.
|
|
632
|
+
*/
|
|
633
|
+
readLocalState: (skillId: string) => Promise<LocalSkillState | null>;
|
|
634
|
+
/** True when `<managedDir>/<skillId>/SKILL.md` exists (removal precondition). */
|
|
635
|
+
hasManagedSkill: (skillId: string) => Promise<boolean>;
|
|
581
636
|
fetchFn: FetchLike;
|
|
582
637
|
ref?: string;
|
|
583
638
|
base?: string;
|
|
@@ -589,7 +644,16 @@ export interface RunSkillUpdateCheckOptions {
|
|
|
589
644
|
}
|
|
590
645
|
|
|
591
646
|
function describeUpdate(u: PendingSkillTarget): string {
|
|
592
|
-
return `「${u.skillId}」v
|
|
647
|
+
if (u.current === null) return `「${u.skillId}」v无 → v${u.target}`;
|
|
648
|
+
let cmp = -1;
|
|
649
|
+
try {
|
|
650
|
+
cmp = compareVersions(u.current, u.target);
|
|
651
|
+
} catch {
|
|
652
|
+
cmp = -1; // unparsable local version — show as a plain update
|
|
653
|
+
}
|
|
654
|
+
if (cmp === 0) return `「${u.skillId}」v${u.target}(内容修订)`;
|
|
655
|
+
if (cmp > 0) return `「${u.skillId}」v${u.current} → v${u.target}(回滚)`;
|
|
656
|
+
return `「${u.skillId}」v${u.current} → v${u.target}`;
|
|
593
657
|
}
|
|
594
658
|
|
|
595
659
|
/**
|
|
@@ -608,7 +672,7 @@ export async function runSkillUpdateCheck(
|
|
|
608
672
|
}
|
|
609
673
|
|
|
610
674
|
const outcome = await checkSkillUpdate({
|
|
611
|
-
|
|
675
|
+
readLocalState: options.readLocalState,
|
|
612
676
|
...(options.ref ? { ref: options.ref } : {}),
|
|
613
677
|
...(options.base ? { base: options.base } : {}),
|
|
614
678
|
fetchFn: options.fetchFn,
|
|
@@ -616,17 +680,19 @@ export async function runSkillUpdateCheck(
|
|
|
616
680
|
|
|
617
681
|
const updates: PendingSkillTarget[] = outcome.results
|
|
618
682
|
.filter((r) => r.hasUpdate)
|
|
619
|
-
.map((r) => ({
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
683
|
+
.map((r) => ({ skillId: r.skillId, current: r.current, target: r.latest, path: r.path, sha256: r.sha256, bytes: r.bytes }));
|
|
684
|
+
|
|
685
|
+
const removals: PendingSkillRemoval[] = [];
|
|
686
|
+
for (const skillId of outcome.removedIds) {
|
|
687
|
+
if ((OPENCLAW_SKILL_IDS as readonly string[]).includes(skillId)) {
|
|
688
|
+
options.log?.info?.(`clawchat skill-update: ignoring tombstone for bundled skill ${skillId}`);
|
|
689
|
+
continue;
|
|
690
|
+
}
|
|
691
|
+
if (await options.hasManagedSkill(skillId)) removals.push({ skillId });
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
if (updates.length === 0 && removals.length === 0) {
|
|
695
|
+
options.log?.info?.("clawchat skill-update check: nothing to converge");
|
|
630
696
|
return null;
|
|
631
697
|
}
|
|
632
698
|
|
|
@@ -636,15 +702,19 @@ export async function runSkillUpdateCheck(
|
|
|
636
702
|
ref: outcome.ref,
|
|
637
703
|
...(options.base ? { base: options.base } : {}),
|
|
638
704
|
updates,
|
|
705
|
+
removals,
|
|
639
706
|
createdAt: now,
|
|
640
707
|
expiresAt: now + (options.timeoutMs ?? DEFAULT_CONSENT_TIMEOUT_MS),
|
|
641
708
|
};
|
|
642
709
|
options.store.set(record);
|
|
643
710
|
|
|
644
|
-
const
|
|
711
|
+
const segments: string[] = [];
|
|
712
|
+
if (updates.length > 0) segments.push(`我的技能有更新:${updates.map(describeUpdate).join(";")}`);
|
|
713
|
+
if (removals.length > 0) segments.push(`以下技能将下线移除:${removals.map((r) => `「${r.skillId}」`).join("、")}`);
|
|
714
|
+
const text = `${segments.join(";")}。回复「更新」确认,「取消」忽略。`;
|
|
645
715
|
await options.sendOwnerMessage(text);
|
|
646
716
|
options.log?.info?.(
|
|
647
|
-
`clawchat skill-update check: asked owner for consent on ${updates.map((u) => u.skillId).join(",")}`,
|
|
717
|
+
`clawchat skill-update check: asked owner for consent on updates=[${updates.map((u) => u.skillId).join(",")}] removals=[${removals.map((r) => r.skillId).join(",")}]`,
|
|
648
718
|
);
|
|
649
719
|
return record;
|
|
650
720
|
}
|
|
@@ -664,8 +734,8 @@ export interface HandleOwnerConsentReplyOptions {
|
|
|
664
734
|
*/
|
|
665
735
|
skillsDir: string;
|
|
666
736
|
sendOwnerMessage: (text: string) => Promise<void>;
|
|
667
|
-
/** Re-read the on-disk
|
|
668
|
-
|
|
737
|
+
/** Re-read the on-disk sha of a managed skill for idempotency checks. */
|
|
738
|
+
readLocalSha?: (skillId: string) => Promise<string | null>;
|
|
669
739
|
log?: SkillUpdateLog;
|
|
670
740
|
}
|
|
671
741
|
|
|
@@ -703,23 +773,24 @@ export async function handleOwnerConsentReply(
|
|
|
703
773
|
}
|
|
704
774
|
|
|
705
775
|
// verdict === "affirm"
|
|
706
|
-
const
|
|
707
|
-
options.
|
|
776
|
+
const readLocalSha =
|
|
777
|
+
options.readLocalSha ??
|
|
708
778
|
(async (skillId: string) => {
|
|
709
779
|
try {
|
|
710
|
-
const
|
|
711
|
-
return
|
|
780
|
+
const buf = await fs.readFile(path.join(options.skillsDir, skillId, "SKILL.md"));
|
|
781
|
+
return crypto.createHash("sha256").update(buf).digest("hex");
|
|
712
782
|
} catch {
|
|
713
783
|
return null;
|
|
714
784
|
}
|
|
715
785
|
});
|
|
716
786
|
|
|
717
787
|
const applied: PendingSkillTarget[] = [];
|
|
788
|
+
const removed: PendingSkillRemoval[] = [];
|
|
718
789
|
try {
|
|
719
790
|
for (const update of pending.updates) {
|
|
720
|
-
// Idempotency: skip a skill already
|
|
721
|
-
const onDisk = await
|
|
722
|
-
if (onDisk === update.
|
|
791
|
+
// Idempotency: skip a skill whose on-disk bytes already match the target.
|
|
792
|
+
const onDisk = await readLocalSha(update.skillId);
|
|
793
|
+
if (onDisk === update.sha256) {
|
|
723
794
|
applied.push(update);
|
|
724
795
|
continue;
|
|
725
796
|
}
|
|
@@ -733,9 +804,21 @@ export async function handleOwnerConsentReply(
|
|
|
733
804
|
);
|
|
734
805
|
await atomicWriteSkill(options.skillsDir, update.skillId, content);
|
|
735
806
|
applied.push(update);
|
|
736
|
-
options.log?.info?.(
|
|
737
|
-
|
|
738
|
-
|
|
807
|
+
options.log?.info?.(`clawchat skill-update: applied ${update.skillId} -> v${update.target} (atomic overwrite)`);
|
|
808
|
+
}
|
|
809
|
+
for (const removal of pending.removals) {
|
|
810
|
+
// Defensive re-guard: bundled skills must never be deleted, even though
|
|
811
|
+
// runSkillUpdateCheck already filters them out of removal candidates.
|
|
812
|
+
// Unreachable in the normal flow; kept as a cheap safety net.
|
|
813
|
+
if ((OPENCLAW_SKILL_IDS as readonly string[]).includes(removal.skillId)) {
|
|
814
|
+
options.log?.info?.(
|
|
815
|
+
`clawchat skill-update: skipped removal of bundled skill ${removal.skillId} (defensive re-guard)`,
|
|
816
|
+
);
|
|
817
|
+
continue;
|
|
818
|
+
}
|
|
819
|
+
await removeManagedSkill(options.skillsDir, removal.skillId);
|
|
820
|
+
removed.push(removal);
|
|
821
|
+
options.log?.info?.(`clawchat skill-update: removed tombstoned skill ${removal.skillId}`);
|
|
739
822
|
}
|
|
740
823
|
} catch (err) {
|
|
741
824
|
// Keep the pending record so the owner can retry with another "更新"; report
|
|
@@ -746,7 +829,9 @@ export async function handleOwnerConsentReply(
|
|
|
746
829
|
}
|
|
747
830
|
|
|
748
831
|
options.store.clear();
|
|
749
|
-
const
|
|
750
|
-
|
|
832
|
+
const segments: string[] = [];
|
|
833
|
+
if (applied.length > 0) segments.push(`已更新到 ${applied.map((u) => `「${u.skillId}」v${u.target}`).join("、")}`);
|
|
834
|
+
if (removed.length > 0) segments.push(`已移除 ${removed.map((r) => `「${r.skillId}」`).join("、")}`);
|
|
835
|
+
await options.sendOwnerMessage(`✅ ${segments.join(";")}`);
|
|
751
836
|
return true;
|
|
752
837
|
}
|
package/src/tools.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
ClawlingApiError,
|
|
10
10
|
type Profile,
|
|
11
11
|
} from "./api-types.ts";
|
|
12
|
+
import { mapGateOutcome } from "./gate-outcome.ts";
|
|
12
13
|
import { resolveOpenclawClawlingAccount } from "./config.ts";
|
|
13
14
|
import {
|
|
14
15
|
clawChatDbPathForStateDir,
|
|
@@ -389,6 +390,39 @@ export function registerOpenclawClawlingTools(
|
|
|
389
390
|
})();
|
|
390
391
|
}
|
|
391
392
|
|
|
393
|
+
/**
|
|
394
|
+
* Like withClient, but maps gate codes (21001/21003) to structured outcomes
|
|
395
|
+
* via mapGateOutcome so the agent sees a clear pending/forbidden status
|
|
396
|
+
* instead of a generic error. Used for friend and moment mutation tools.
|
|
397
|
+
*/
|
|
398
|
+
function withGatedClient<T>(
|
|
399
|
+
fn: (client: ReturnType<typeof createOpenclawClawlingApiClient>) => Promise<T>,
|
|
400
|
+
): Promise<OpenClawAgentToolResult<unknown>> {
|
|
401
|
+
return (async (): Promise<OpenClawAgentToolResult<unknown>> => {
|
|
402
|
+
const built = buildClient();
|
|
403
|
+
if (!built.ok) return built.error;
|
|
404
|
+
try {
|
|
405
|
+
const data = await fn(built.client);
|
|
406
|
+
return jsonResponse(data);
|
|
407
|
+
} catch (err) {
|
|
408
|
+
if (err instanceof ClawlingApiError) {
|
|
409
|
+
const code = typeof err.meta?.code === "number" ? err.meta.code : null;
|
|
410
|
+
if (code !== null) {
|
|
411
|
+
const rawData = err.meta?.data;
|
|
412
|
+
const gateData =
|
|
413
|
+
rawData !== null && rawData !== undefined && typeof rawData === "object"
|
|
414
|
+
? (rawData as Record<string, unknown>)
|
|
415
|
+
: {};
|
|
416
|
+
const outcome = mapGateOutcome(code, gateData);
|
|
417
|
+
if (outcome !== null) return jsonResponse(outcome);
|
|
418
|
+
}
|
|
419
|
+
return apiError(err);
|
|
420
|
+
}
|
|
421
|
+
return genericError(err);
|
|
422
|
+
}
|
|
423
|
+
})();
|
|
424
|
+
}
|
|
425
|
+
|
|
392
426
|
function workspaceRoot(ctx: OpenClawPluginToolContext): string | null {
|
|
393
427
|
const root = typeof ctx.workspaceDir === "string" ? ctx.workspaceDir.trim() : "";
|
|
394
428
|
return root || null;
|
|
@@ -829,7 +863,7 @@ export function registerOpenclawClawlingTools(
|
|
|
829
863
|
async execute(_callId, params) {
|
|
830
864
|
return await recordClawchatToolCall("clawchat_send_friend_request", params, async () => {
|
|
831
865
|
const p = params as ClawchatSendFriendRequestParams;
|
|
832
|
-
return await
|
|
866
|
+
return await withGatedClient((c) => c.sendFriendRequest(p));
|
|
833
867
|
});
|
|
834
868
|
},
|
|
835
869
|
},
|
|
@@ -869,7 +903,7 @@ export function registerOpenclawClawlingTools(
|
|
|
869
903
|
async execute(_callId, params) {
|
|
870
904
|
return await recordClawchatToolCall("clawchat_accept_friend_request", params, async () => {
|
|
871
905
|
const p = params as ClawchatAcceptFriendRequestParams;
|
|
872
|
-
return await
|
|
906
|
+
return await withGatedClient((c) => c.acceptFriendRequest(p.requestId));
|
|
873
907
|
});
|
|
874
908
|
},
|
|
875
909
|
},
|
|
@@ -889,7 +923,7 @@ export function registerOpenclawClawlingTools(
|
|
|
889
923
|
async execute(_callId, params) {
|
|
890
924
|
return await recordClawchatToolCall("clawchat_reject_friend_request", params, async () => {
|
|
891
925
|
const p = params as ClawchatRejectFriendRequestParams;
|
|
892
|
-
return await
|
|
926
|
+
return await withGatedClient((c) => c.rejectFriendRequest(p.requestId));
|
|
893
927
|
});
|
|
894
928
|
},
|
|
895
929
|
},
|
|
@@ -909,7 +943,7 @@ export function registerOpenclawClawlingTools(
|
|
|
909
943
|
async execute(_callId, params) {
|
|
910
944
|
return await recordClawchatToolCall("clawchat_remove_friend", params, async () => {
|
|
911
945
|
const p = params as ClawchatRemoveFriendParams;
|
|
912
|
-
return await
|
|
946
|
+
return await withGatedClient((c) => c.removeFriend(p.friendUserId));
|
|
913
947
|
});
|
|
914
948
|
},
|
|
915
949
|
},
|
|
@@ -1084,7 +1118,7 @@ export function registerOpenclawClawlingTools(
|
|
|
1084
1118
|
if (!text && (!images || images.length === 0)) {
|
|
1085
1119
|
return validationError("clawchat-plugin-openclaw: at least one of text or images is required");
|
|
1086
1120
|
}
|
|
1087
|
-
return await
|
|
1121
|
+
return await withGatedClient((c) =>
|
|
1088
1122
|
c.createMoment({
|
|
1089
1123
|
...(text !== undefined ? { text } : {}),
|
|
1090
1124
|
...(images !== undefined ? { images } : {}),
|
|
@@ -1109,7 +1143,7 @@ export function registerOpenclawClawlingTools(
|
|
|
1109
1143
|
async execute(_callId, params) {
|
|
1110
1144
|
return await recordClawchatToolCall("clawchat_delete_moment", params, async () => {
|
|
1111
1145
|
const p = params as ClawchatDeleteMomentParams;
|
|
1112
|
-
return await
|
|
1146
|
+
return await withGatedClient((c) => c.deleteMoment(p.momentId));
|
|
1113
1147
|
});
|
|
1114
1148
|
},
|
|
1115
1149
|
},
|
|
@@ -1132,7 +1166,7 @@ export function registerOpenclawClawlingTools(
|
|
|
1132
1166
|
if (!p.emoji?.trim()) {
|
|
1133
1167
|
return validationError("clawchat-plugin-openclaw: emoji is required");
|
|
1134
1168
|
}
|
|
1135
|
-
return await
|
|
1169
|
+
return await withGatedClient((c) =>
|
|
1136
1170
|
c.toggleMomentReaction({ momentId: p.momentId, emoji: p.emoji }),
|
|
1137
1171
|
);
|
|
1138
1172
|
});
|
|
@@ -1157,7 +1191,7 @@ export function registerOpenclawClawlingTools(
|
|
|
1157
1191
|
if (!p.text?.trim()) {
|
|
1158
1192
|
return validationError("clawchat-plugin-openclaw: text is required");
|
|
1159
1193
|
}
|
|
1160
|
-
return await
|
|
1194
|
+
return await withGatedClient((c) =>
|
|
1161
1195
|
c.createMomentComment({ momentId: p.momentId, text: p.text }),
|
|
1162
1196
|
);
|
|
1163
1197
|
});
|
|
@@ -1182,7 +1216,7 @@ export function registerOpenclawClawlingTools(
|
|
|
1182
1216
|
if (!p.text?.trim()) {
|
|
1183
1217
|
return validationError("clawchat-plugin-openclaw: text is required");
|
|
1184
1218
|
}
|
|
1185
|
-
return await
|
|
1219
|
+
return await withGatedClient((c) =>
|
|
1186
1220
|
c.replyMomentComment({
|
|
1187
1221
|
momentId: p.momentId,
|
|
1188
1222
|
replyToCommentId: p.replyToCommentId,
|
|
@@ -1208,7 +1242,7 @@ export function registerOpenclawClawlingTools(
|
|
|
1208
1242
|
async execute(_callId, params) {
|
|
1209
1243
|
return await recordClawchatToolCall("clawchat_delete_moment_comment", params, async () => {
|
|
1210
1244
|
const p = params as ClawchatDeleteMomentCommentParams;
|
|
1211
|
-
return await
|
|
1245
|
+
return await withGatedClient((c) =>
|
|
1212
1246
|
c.deleteMomentComment({ momentId: p.momentId, commentId: p.commentId }),
|
|
1213
1247
|
);
|
|
1214
1248
|
});
|
package/src/ws-client.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
TransportError,
|
|
10
10
|
isBusinessDispatchEvent,
|
|
11
11
|
type ConnState,
|
|
12
|
+
type ConnectCapabilities,
|
|
12
13
|
type ConnectPayload,
|
|
13
14
|
type EmptyPayload,
|
|
14
15
|
type Envelope,
|
|
@@ -146,6 +147,24 @@ type PendingAck = {
|
|
|
146
147
|
reject: (err: Error) => void;
|
|
147
148
|
};
|
|
148
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Returns the capability set advertised in the connect handshake.
|
|
152
|
+
* Exported so tests can assert on the exact fields without a full WS setup.
|
|
153
|
+
*
|
|
154
|
+
* permission_events: forward-compat: advertises readiness for a future native
|
|
155
|
+
* `permission.receipt` wire frame; today the permission-decision receipt is
|
|
156
|
+
* delivered as a system message until msghub emits the native frame.
|
|
157
|
+
*/
|
|
158
|
+
export function buildConnectCapabilities(): ConnectCapabilities {
|
|
159
|
+
return {
|
|
160
|
+
multi_device: false,
|
|
161
|
+
device_replay: true,
|
|
162
|
+
chat_meta_events: true,
|
|
163
|
+
notify_signals: true,
|
|
164
|
+
permission_events: true,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
149
168
|
export class ClawChatClient extends EventEmitter {
|
|
150
169
|
private currentState: ConnState = "idle";
|
|
151
170
|
private connectResolve?: () => void;
|
|
@@ -398,12 +417,8 @@ export class ClawChatClient extends EventEmitter {
|
|
|
398
417
|
// *send* history.transit, which a single-device agent never does, and we
|
|
399
418
|
// do not handle inbound history.transit — advertising it would invite
|
|
400
419
|
// frames we cannot process. This is a deliberate, spec-legal omission.
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
device_replay: true,
|
|
404
|
-
chat_meta_events: true,
|
|
405
|
-
notify_signals: true,
|
|
406
|
-
},
|
|
420
|
+
// permission_events: forward-compat; see buildConnectCapabilities().
|
|
421
|
+
capabilities: buildConnectCapabilities(),
|
|
407
422
|
};
|
|
408
423
|
const traceId = this.nextTraceId();
|
|
409
424
|
this.expectedConnectTraceId = traceId;
|