@clawling/clawchat-plugin-openclaw 2026.8.2-1 → 2026.8.4-2

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.
@@ -398,6 +398,16 @@ export function createOpenclawClawlingApiClient(opts) {
398
398
  assertNonBlankId(conversationId, "leaveConversation: conversationId");
399
399
  return await call("POST", `/v1/conversations/${encodeURIComponent(conversationId)}/leave`);
400
400
  },
401
+ async addConversationMember(conversationId, userId) {
402
+ assertNonBlankId(conversationId, "addConversationMember: conversationId");
403
+ assertNonBlankId(userId, "addConversationMember: userId");
404
+ const cid = conversationId.trim();
405
+ const uid = userId.trim();
406
+ return await call("POST", `/v1/conversations/${encodeURIComponent(cid)}/members`, {
407
+ body: JSON.stringify({ user_id: uid }),
408
+ headers: { "content-type": "application/json" },
409
+ });
410
+ },
401
411
  async patchConversation(conversationId, patch) {
402
412
  assertNonBlankId(conversationId, "patchConversation: conversationId");
403
413
  const body = pickPatch(patch, ["title", "description"], "patchConversation");
@@ -15,6 +15,35 @@ export const LIVEWARE_SAMPLE_APP_NAME = "Liveware Sample";
15
15
  export const MAX_SAMPLE_FILE_BYTES = 512 * 1024;
16
16
  /** Files the agent owns at runtime; preserved across sample upgrades. */
17
17
  const USER_DATA_FILES = ["state.json", "events.jsonl"];
18
+ /**
19
+ * Windows refuses to unlink or rename a directory while ANY file inside it is
20
+ * still open, and reports it as a sharing violation (EPERM/EBUSY/EACCES) or a
21
+ * non-empty directory. A still-draining sample server, an antivirus scan or the
22
+ * search indexer is enough to trigger it, and all of those clear within
23
+ * milliseconds — so a short bounded retry turns a hard install failure into a
24
+ * brief pause. On POSIX these codes essentially never appear here and the first
25
+ * attempt always wins, so the retry costs nothing.
26
+ */
27
+ const FS_SWAP_RETRY_DELAYS_MS = [50, 150, 400];
28
+ const TRANSIENT_FS_CODES = new Set(["EPERM", "EACCES", "EBUSY", "ENOTEMPTY"]);
29
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
30
+ async function withFsSwapRetry(op) {
31
+ let lastError;
32
+ for (let attempt = 0; attempt <= FS_SWAP_RETRY_DELAYS_MS.length; attempt += 1) {
33
+ try {
34
+ return await op();
35
+ }
36
+ catch (err) {
37
+ lastError = err;
38
+ const code = err.code ?? "";
39
+ const delay = FS_SWAP_RETRY_DELAYS_MS[attempt];
40
+ if (!TRANSIENT_FS_CODES.has(code) || delay === undefined)
41
+ break;
42
+ await sleep(delay);
43
+ }
44
+ }
45
+ throw lastError;
46
+ }
18
47
  function livewaresBase(base, ref) {
19
48
  return `${(base ?? OFFICIAL_SKILLS_BASE).replace(/\/+$/, "")}/${ref}/livewares`;
20
49
  }
@@ -116,6 +145,14 @@ export async function downloadLivewareSample(opts) {
116
145
  if (await localInstallMatches(appDir, entry)) {
117
146
  return { version: entry.version, appDir, reused: true };
118
147
  }
148
+ // Sweep app.old-* left behind when a previous swap could not delete the old
149
+ // tree (a handle was still open then, but is not now). Best effort: a dir we
150
+ // still cannot remove is simply retried next run.
151
+ for (const name of await fs.promises.readdir(opts.sampleRoot).catch(() => [])) {
152
+ if (name.startsWith("app.old-")) {
153
+ await fs.promises.rm(path.join(opts.sampleRoot, name), { recursive: true, force: true }).catch(() => { });
154
+ }
155
+ }
119
156
  const tmpDir = path.join(opts.sampleRoot, `app.tmp-${process.pid}`);
120
157
  await fs.promises.rm(tmpDir, { recursive: true, force: true });
121
158
  await fs.promises.mkdir(tmpDir, { recursive: true });
@@ -138,8 +175,18 @@ export async function downloadLivewareSample(opts) {
138
175
  await fs.promises.copyFile(prev, path.join(tmpDir, name));
139
176
  }
140
177
  }
141
- await fs.promises.rm(appDir, { recursive: true, force: true });
142
- await fs.promises.rename(tmpDir, appDir);
178
+ // Swap by moving the old tree ASIDE rather than deleting it first. On
179
+ // Windows the delete is the step most likely to lose a race with an open
180
+ // handle, and doing it first leaves no app/ at all when it fails. Renaming
181
+ // aside puts the new tree in place first and makes the delete best-effort:
182
+ // a leftover app.old-<pid> is swept on the next run, never a broken install.
183
+ const oldDir = path.join(opts.sampleRoot, `app.old-${process.pid}`);
184
+ await fs.promises.rm(oldDir, { recursive: true, force: true }).catch(() => { });
185
+ if (fs.existsSync(appDir)) {
186
+ await withFsSwapRetry(() => fs.promises.rename(appDir, oldDir));
187
+ }
188
+ await withFsSwapRetry(() => fs.promises.rename(tmpDir, appDir));
189
+ await fs.promises.rm(oldDir, { recursive: true, force: true }).catch(() => { });
143
190
  return { version: entry.version, appDir };
144
191
  }
145
192
  catch (err) {
@@ -147,6 +194,35 @@ export async function downloadLivewareSample(opts) {
147
194
  throw err;
148
195
  }
149
196
  }
197
+ /**
198
+ * Terminate a child and everything it spawned.
199
+ *
200
+ * POSIX: `SIGTERM` to the child is what we want and what the child expects.
201
+ *
202
+ * Windows: there are no signals. `child.kill()` maps to TerminateProcess on
203
+ * that one process, so any grandchild the liveware agent spawned survives —
204
+ * orphaned, still holding the sample port, and invisible to the next boot,
205
+ * which then fails to bind. `taskkill /T /F` takes the whole tree down.
206
+ * Fire-and-forget: we never wait on it, and a failure (already dead, no such
207
+ * pid) falls through to the ordinary kill().
208
+ */
209
+ export function killProcessTree(child, execFileFn = nodeExecFile) {
210
+ if (!child)
211
+ return;
212
+ if (process.platform === "win32" && typeof child.pid === "number") {
213
+ try {
214
+ execFileFn("taskkill", ["/pid", String(child.pid), "/T", "/F"], () => { });
215
+ return;
216
+ }
217
+ catch {
218
+ // taskkill missing or spawn refused — fall back to the plain kill below.
219
+ }
220
+ }
221
+ try {
222
+ child.kill("SIGTERM");
223
+ }
224
+ catch { /* already dead */ }
225
+ }
150
226
  const SERVER_START_TIMEOUT_MS = 10_000;
151
227
  const TUNNEL_START_TIMEOUT_MS = 30_000;
152
228
  const CLI_TIMEOUT_MS = 30_000;
@@ -164,10 +240,7 @@ function waitForOutput(child, match, timeoutMs, label) {
164
240
  child.stderr?.removeListener("data", onData);
165
241
  child.removeListener("exit", onExit);
166
242
  if (err) {
167
- try {
168
- child.kill("SIGTERM");
169
- }
170
- catch { /* already dead */ }
243
+ killProcessTree(child);
171
244
  reject(err);
172
245
  }
173
246
  else {
@@ -535,10 +608,7 @@ export class LivewareSampleSupervisor {
535
608
  killChildren() {
536
609
  this.generation += 1; // invalidate pending exit handlers before killing
537
610
  for (const child of [this.serverChild, this.tunnelChild]) {
538
- try {
539
- child?.kill("SIGTERM");
540
- }
541
- catch { /* already dead */ }
611
+ killProcessTree(child, this.deps.execFileFn);
542
612
  }
543
613
  this.serverChild = null;
544
614
  this.tunnelChild = null;
@@ -38,9 +38,11 @@ import { fileURLToPath } from "node:url";
38
38
  * - All network IO goes through an injected `FetchLike`; tests never touch the
39
39
  * real network.
40
40
  *
41
- * Cross-language contract / spec: see
42
- * `ops/agent-plugin/skill-dynamic-update-plan.md` (§2, §3 phase 4,
43
- * §4 #5, §6.4, §6.7, §7.2).
41
+ * Cross-language contract / spec: see `docs/clawchat-plugin-openclaw.md`
42
+ * §"Conversational skill hot-update" for this adapter's end of the flow,
43
+ * `docs/client-integration.md` for the `clawchat.skill.update.check` signal
44
+ * shape, and the install-cli repo's `skills/README.md` for the canonical skill
45
+ * source and `manifest.json` contract.
44
46
  */
45
47
  // ---------------------------------------------------------------------------
46
48
  // Constants — fixed contract (must match the install-cli `config.ts`).
@@ -128,6 +128,16 @@ export const ClawchatLeaveGroupSchema = Type.Object({
128
128
  description: "Concrete ClawChat group conversation id to leave",
129
129
  }),
130
130
  });
131
+ export const ClawchatAddGroupMemberSchema = Type.Object({
132
+ conversationId: Type.String({
133
+ minLength: 1,
134
+ description: "Concrete ClawChat group conversation id to add the user to",
135
+ }),
136
+ userId: Type.String({
137
+ minLength: 1,
138
+ description: "Concrete ClawChat user id (usr_...) to add to the group",
139
+ }),
140
+ });
131
141
  export const ClawchatMentionMessageSchema = Type.Object({
132
142
  chatId: Type.String({
133
143
  minLength: 1,
package/dist/src/tools.js CHANGED
@@ -12,7 +12,7 @@ import { getOpenclawClawlingClient, } from "./runtime.js";
12
12
  import { markTerminalClawChatSend, getCurrentTerminalSendScope } from "./terminal-send.js";
13
13
  import { editClawChatMemoryBody, readClawChatMemoryFile, resolveClawChatMemoryPath, searchClawChatMemory, writeClawChatMemoryBody, } from "./clawchat-memory.js";
14
14
  import { pullGroupMetadata, pullOwnerMetadata, pullUserMetadata, pushMetadata, updateMetadata, } from "./clawchat-metadata.js";
15
- import { ClawchatGetAccountProfileSchema, ClawchatGetConversationSchema, ClawchatGetMomentSchema, ClawchatGetUserProfileSchema, ClawchatLeaveGroupSchema, ClawchatAcceptFriendRequestSchema, ClawchatMemoryEditSchema, ClawchatMemoryReadSchema, ClawchatMemorySearchSchema, ClawchatMemoryWriteSchema, ClawchatMetadataSyncSchema, ClawchatMetadataUpdateSchema, ClawchatCreateMomentCommentSchema, ClawchatCreateMomentSchema, ClawchatDeleteMomentCommentSchema, ClawchatDeleteMomentSchema, ClawchatListAccountFriendsSchema, ClawchatListFriendRequestsSchema, ClawchatListMomentsSchema, ClawchatMentionMessageSchema, ClawchatReactMessageSchema, ClawchatReplyMomentCommentSchema, ClawchatRejectFriendRequestSchema, ClawchatRemoveFriendSchema, ClawchatSearchUsersSchema, ClawchatSendFriendRequestSchema, ClawchatToggleMomentReactionSchema, ClawchatUpdateAccountProfileSchema, ClawchatUploadAvatarImageSchema, ClawchatRegisterAppSchema, ClawchatListAppsSchema, ClawchatUnregisterAppSchema, ClawchatLivewareLoginSchema, } from "./tools-schema.js";
15
+ import { ClawchatGetAccountProfileSchema, ClawchatGetConversationSchema, ClawchatGetMomentSchema, ClawchatGetUserProfileSchema, ClawchatLeaveGroupSchema, ClawchatAddGroupMemberSchema, ClawchatAcceptFriendRequestSchema, ClawchatMemoryEditSchema, ClawchatMemoryReadSchema, ClawchatMemorySearchSchema, ClawchatMemoryWriteSchema, ClawchatMetadataSyncSchema, ClawchatMetadataUpdateSchema, ClawchatCreateMomentCommentSchema, ClawchatCreateMomentSchema, ClawchatDeleteMomentCommentSchema, ClawchatDeleteMomentSchema, ClawchatListAccountFriendsSchema, ClawchatListFriendRequestsSchema, ClawchatListMomentsSchema, ClawchatMentionMessageSchema, ClawchatReactMessageSchema, ClawchatReplyMomentCommentSchema, ClawchatRejectFriendRequestSchema, ClawchatRemoveFriendSchema, ClawchatSearchUsersSchema, ClawchatSendFriendRequestSchema, ClawchatToggleMomentReactionSchema, ClawchatUpdateAccountProfileSchema, ClawchatUploadAvatarImageSchema, ClawchatRegisterAppSchema, ClawchatListAppsSchema, ClawchatUnregisterAppSchema, ClawchatLivewareLoginSchema, } from "./tools-schema.js";
16
16
  const MAX_UPLOAD_BYTES = 20 * 1024 * 1024;
17
17
  // Owner-approval gate business codes (must match the ClawChat backend's owner-approval codes).
18
18
  const CODE_PENDING_APPROVAL = 21001;
@@ -808,6 +808,34 @@ export function registerOpenclawClawlingTools(api, options = {}) {
808
808
  });
809
809
  },
810
810
  }, { name: "clawchat_leave_group" });
811
+ api.registerTool({
812
+ name: "clawchat_add_group_member",
813
+ label: "Add ClawChat Group Member",
814
+ description: toolDescription("Add a specified ClawChat user to a group conversation the agent is a member of. " +
815
+ "TRIGGER - invoke only when the user explicitly asks the agent to add, invite, or pull a specific person into a specific ClawChat group, and both a concrete conversationId and a concrete userId are available. " +
816
+ "Never guess a userId from a name or nickname — use ids from ClawChat Group Message Metadata, clawchat_list_account_friends, or clawchat_search_users. " +
817
+ "Groups only: direct conversations are rejected by the server. " +
818
+ "The target must already be the agent's ClawChat friend, and the agent needs the group-management permission granted by its owner; if the call fails for either reason, explain that to the user and do not retry."),
819
+ parameters: ClawchatAddGroupMemberSchema,
820
+ async execute(_callId, params) {
821
+ return await recordClawchatToolCall("clawchat_add_group_member", params, async () => {
822
+ const p = params;
823
+ const built = buildClient();
824
+ if (!built.ok)
825
+ return built.error;
826
+ try {
827
+ const data = await built.client.addConversationMember(p.conversationId, p.userId);
828
+ return jsonResponse(data);
829
+ }
830
+ catch (err) {
831
+ if (err instanceof ClawlingApiError) {
832
+ return apiError(err);
833
+ }
834
+ return genericError(err);
835
+ }
836
+ });
837
+ },
838
+ }, { name: "clawchat_add_group_member" });
811
839
  api.registerTool({
812
840
  name: "clawchat_mention_message",
813
841
  label: "Send ClawChat Mention Message",
@@ -47,6 +47,7 @@
47
47
  "clawchat_react_message",
48
48
  "clawchat_get_conversation",
49
49
  "clawchat_leave_group",
50
+ "clawchat_add_group_member",
50
51
  "clawchat_list_moments",
51
52
  "clawchat_get_moment",
52
53
  "clawchat_create_moment",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawling/clawchat-plugin-openclaw",
3
- "version": "2026.8.2-1",
3
+ "version": "2026.8.4-2",
4
4
  "description": "OpenClaw ClawChat channel plugin",
5
5
  "license": "MIT",
6
6
  "author": "CLAWLING PTE. LTD.",
package/src/api-client.ts CHANGED
@@ -135,6 +135,10 @@ export interface OpenclawClawlingApiClient {
135
135
  deleteMomentComment(params: { momentId: number; commentId: number }): Promise<{ ok: boolean }>;
136
136
  getConversation(conversationId: string): Promise<{ conversation: ConversationDetails }>;
137
137
  leaveConversation(conversationId: string): Promise<{ ok: boolean }>;
138
+ addConversationMember(
139
+ conversationId: string,
140
+ userId: string,
141
+ ): Promise<{ conversation: ConversationDetails }>;
138
142
  patchConversation(
139
143
  conversationId: string,
140
144
  patch: ConversationMetadataPatch,
@@ -643,6 +647,23 @@ export function createOpenclawClawlingApiClient(opts: ApiClientOptions): Opencla
643
647
  `/v1/conversations/${encodeURIComponent(conversationId)}/leave`,
644
648
  );
645
649
  },
650
+ async addConversationMember(
651
+ conversationId,
652
+ userId,
653
+ ): Promise<{ conversation: ConversationDetails }> {
654
+ assertNonBlankId(conversationId, "addConversationMember: conversationId");
655
+ assertNonBlankId(userId, "addConversationMember: userId");
656
+ const cid = conversationId.trim();
657
+ const uid = userId.trim();
658
+ return await call<{ conversation: ConversationDetails }>(
659
+ "POST",
660
+ `/v1/conversations/${encodeURIComponent(cid)}/members`,
661
+ {
662
+ body: JSON.stringify({ user_id: uid }),
663
+ headers: { "content-type": "application/json" },
664
+ },
665
+ );
666
+ },
646
667
  async patchConversation(
647
668
  conversationId: string,
648
669
  patch: ConversationMetadataPatch,
@@ -20,6 +20,36 @@ export const MAX_SAMPLE_FILE_BYTES = 512 * 1024;
20
20
  /** Files the agent owns at runtime; preserved across sample upgrades. */
21
21
  const USER_DATA_FILES = ["state.json", "events.jsonl"];
22
22
 
23
+ /**
24
+ * Windows refuses to unlink or rename a directory while ANY file inside it is
25
+ * still open, and reports it as a sharing violation (EPERM/EBUSY/EACCES) or a
26
+ * non-empty directory. A still-draining sample server, an antivirus scan or the
27
+ * search indexer is enough to trigger it, and all of those clear within
28
+ * milliseconds — so a short bounded retry turns a hard install failure into a
29
+ * brief pause. On POSIX these codes essentially never appear here and the first
30
+ * attempt always wins, so the retry costs nothing.
31
+ */
32
+ const FS_SWAP_RETRY_DELAYS_MS = [50, 150, 400];
33
+ const TRANSIENT_FS_CODES = new Set(["EPERM", "EACCES", "EBUSY", "ENOTEMPTY"]);
34
+
35
+ const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
36
+
37
+ async function withFsSwapRetry<T>(op: () => Promise<T>): Promise<T> {
38
+ let lastError: unknown;
39
+ for (let attempt = 0; attempt <= FS_SWAP_RETRY_DELAYS_MS.length; attempt += 1) {
40
+ try {
41
+ return await op();
42
+ } catch (err) {
43
+ lastError = err;
44
+ const code = (err as NodeJS.ErrnoException).code ?? "";
45
+ const delay = FS_SWAP_RETRY_DELAYS_MS[attempt];
46
+ if (!TRANSIENT_FS_CODES.has(code) || delay === undefined) break;
47
+ await sleep(delay);
48
+ }
49
+ }
50
+ throw lastError;
51
+ }
52
+
23
53
  export interface LivewareSampleManifestFile {
24
54
  path: string;
25
55
  sha256: string;
@@ -151,6 +181,15 @@ export async function downloadLivewareSample(opts: {
151
181
  if (await localInstallMatches(appDir, entry)) {
152
182
  return { version: entry.version, appDir, reused: true };
153
183
  }
184
+ // Sweep app.old-* left behind when a previous swap could not delete the old
185
+ // tree (a handle was still open then, but is not now). Best effort: a dir we
186
+ // still cannot remove is simply retried next run.
187
+ for (const name of await fs.promises.readdir(opts.sampleRoot).catch(() => [])) {
188
+ if (name.startsWith("app.old-")) {
189
+ await fs.promises.rm(path.join(opts.sampleRoot, name), { recursive: true, force: true }).catch(() => {});
190
+ }
191
+ }
192
+
154
193
  const tmpDir = path.join(opts.sampleRoot, `app.tmp-${process.pid}`);
155
194
  await fs.promises.rm(tmpDir, { recursive: true, force: true });
156
195
  await fs.promises.mkdir(tmpDir, { recursive: true });
@@ -173,8 +212,18 @@ export async function downloadLivewareSample(opts: {
173
212
  await fs.promises.copyFile(prev, path.join(tmpDir, name));
174
213
  }
175
214
  }
176
- await fs.promises.rm(appDir, { recursive: true, force: true });
177
- await fs.promises.rename(tmpDir, appDir);
215
+ // Swap by moving the old tree ASIDE rather than deleting it first. On
216
+ // Windows the delete is the step most likely to lose a race with an open
217
+ // handle, and doing it first leaves no app/ at all when it fails. Renaming
218
+ // aside puts the new tree in place first and makes the delete best-effort:
219
+ // a leftover app.old-<pid> is swept on the next run, never a broken install.
220
+ const oldDir = path.join(opts.sampleRoot, `app.old-${process.pid}`);
221
+ await fs.promises.rm(oldDir, { recursive: true, force: true }).catch(() => {});
222
+ if (fs.existsSync(appDir)) {
223
+ await withFsSwapRetry(() => fs.promises.rename(appDir, oldDir));
224
+ }
225
+ await withFsSwapRetry(() => fs.promises.rename(tmpDir, appDir));
226
+ await fs.promises.rm(oldDir, { recursive: true, force: true }).catch(() => {});
178
227
  return { version: entry.version, appDir };
179
228
  } catch (err) {
180
229
  await fs.promises.rm(tmpDir, { recursive: true, force: true });
@@ -185,6 +234,34 @@ export async function downloadLivewareSample(opts: {
185
234
  export type SpawnLike = typeof nodeSpawn;
186
235
  export type ExecFileLike = typeof nodeExecFile;
187
236
 
237
+ /**
238
+ * Terminate a child and everything it spawned.
239
+ *
240
+ * POSIX: `SIGTERM` to the child is what we want and what the child expects.
241
+ *
242
+ * Windows: there are no signals. `child.kill()` maps to TerminateProcess on
243
+ * that one process, so any grandchild the liveware agent spawned survives —
244
+ * orphaned, still holding the sample port, and invisible to the next boot,
245
+ * which then fails to bind. `taskkill /T /F` takes the whole tree down.
246
+ * Fire-and-forget: we never wait on it, and a failure (already dead, no such
247
+ * pid) falls through to the ordinary kill().
248
+ */
249
+ export function killProcessTree(
250
+ child: ChildProcess | null | undefined,
251
+ execFileFn: ExecFileLike = nodeExecFile,
252
+ ): void {
253
+ if (!child) return;
254
+ if (process.platform === "win32" && typeof child.pid === "number") {
255
+ try {
256
+ execFileFn("taskkill", ["/pid", String(child.pid), "/T", "/F"], () => {});
257
+ return;
258
+ } catch {
259
+ // taskkill missing or spawn refused — fall back to the plain kill below.
260
+ }
261
+ }
262
+ try { child.kill("SIGTERM"); } catch { /* already dead */ }
263
+ }
264
+
188
265
  const SERVER_START_TIMEOUT_MS = 10_000;
189
266
  const TUNNEL_START_TIMEOUT_MS = 30_000;
190
267
  const CLI_TIMEOUT_MS = 30_000;
@@ -207,7 +284,7 @@ function waitForOutput(
207
284
  child.stderr?.removeListener("data", onData);
208
285
  child.removeListener("exit", onExit);
209
286
  if (err) {
210
- try { child.kill("SIGTERM"); } catch { /* already dead */ }
287
+ killProcessTree(child);
211
288
  reject(err);
212
289
  } else {
213
290
  resolve(value as string);
@@ -670,7 +747,7 @@ export class LivewareSampleSupervisor {
670
747
  private killChildren(): void {
671
748
  this.generation += 1; // invalidate pending exit handlers before killing
672
749
  for (const child of [this.serverChild, this.tunnelChild]) {
673
- try { child?.kill("SIGTERM"); } catch { /* already dead */ }
750
+ killProcessTree(child, this.deps.execFileFn);
674
751
  }
675
752
  this.serverChild = null;
676
753
  this.tunnelChild = null;
@@ -39,9 +39,11 @@ import { fileURLToPath } from "node:url";
39
39
  * - All network IO goes through an injected `FetchLike`; tests never touch the
40
40
  * real network.
41
41
  *
42
- * Cross-language contract / spec: see
43
- * `ops/agent-plugin/skill-dynamic-update-plan.md` (§2, §3 phase 4,
44
- * §4 #5, §6.4, §6.7, §7.2).
42
+ * Cross-language contract / spec: see `docs/clawchat-plugin-openclaw.md`
43
+ * §"Conversational skill hot-update" for this adapter's end of the flow,
44
+ * `docs/client-integration.md` for the `clawchat.skill.update.check` signal
45
+ * shape, and the install-cli repo's `skills/README.md` for the canonical skill
46
+ * source and `manifest.json` contract.
45
47
  */
46
48
 
47
49
  // ---------------------------------------------------------------------------
@@ -208,6 +208,18 @@ export const ClawchatLeaveGroupSchema = Type.Object({
208
208
  });
209
209
  export type ClawchatLeaveGroupParams = Static<typeof ClawchatLeaveGroupSchema>;
210
210
 
211
+ export const ClawchatAddGroupMemberSchema = Type.Object({
212
+ conversationId: Type.String({
213
+ minLength: 1,
214
+ description: "Concrete ClawChat group conversation id to add the user to",
215
+ }),
216
+ userId: Type.String({
217
+ minLength: 1,
218
+ description: "Concrete ClawChat user id (usr_...) to add to the group",
219
+ }),
220
+ });
221
+ export type ClawchatAddGroupMemberParams = Static<typeof ClawchatAddGroupMemberSchema>;
222
+
211
223
  export const ClawchatMentionMessageSchema = Type.Object({
212
224
  chatId: Type.String({
213
225
  minLength: 1,
package/src/tools.ts CHANGED
@@ -43,6 +43,7 @@ import {
43
43
  ClawchatGetMomentSchema,
44
44
  ClawchatGetUserProfileSchema,
45
45
  ClawchatLeaveGroupSchema,
46
+ ClawchatAddGroupMemberSchema,
46
47
  ClawchatAcceptFriendRequestSchema,
47
48
  ClawchatMemoryEditSchema,
48
49
  ClawchatMemoryReadSchema,
@@ -86,6 +87,7 @@ import {
86
87
  type ClawchatGetMomentParams,
87
88
  type ClawchatGetUserProfileParams,
88
89
  type ClawchatLeaveGroupParams,
90
+ type ClawchatAddGroupMemberParams,
89
91
  type ClawchatListFriendRequestsParams,
90
92
  type ClawchatListMomentsParams,
91
93
  type ClawchatMentionMessageParams,
@@ -1048,6 +1050,38 @@ export function registerOpenclawClawlingTools(
1048
1050
  { name: "clawchat_leave_group" },
1049
1051
  );
1050
1052
 
1053
+ api.registerTool(
1054
+ {
1055
+ name: "clawchat_add_group_member",
1056
+ label: "Add ClawChat Group Member",
1057
+ description: toolDescription(
1058
+ "Add a specified ClawChat user to a group conversation the agent is a member of. " +
1059
+ "TRIGGER - invoke only when the user explicitly asks the agent to add, invite, or pull a specific person into a specific ClawChat group, and both a concrete conversationId and a concrete userId are available. " +
1060
+ "Never guess a userId from a name or nickname — use ids from ClawChat Group Message Metadata, clawchat_list_account_friends, or clawchat_search_users. " +
1061
+ "Groups only: direct conversations are rejected by the server. " +
1062
+ "The target must already be the agent's ClawChat friend, and the agent needs the group-management permission granted by its owner; if the call fails for either reason, explain that to the user and do not retry.",
1063
+ ),
1064
+ parameters: ClawchatAddGroupMemberSchema,
1065
+ async execute(_callId, params) {
1066
+ return await recordClawchatToolCall("clawchat_add_group_member", params, async () => {
1067
+ const p = params as ClawchatAddGroupMemberParams;
1068
+ const built = buildClient();
1069
+ if (!built.ok) return built.error;
1070
+ try {
1071
+ const data = await built.client.addConversationMember(p.conversationId, p.userId);
1072
+ return jsonResponse(data);
1073
+ } catch (err) {
1074
+ if (err instanceof ClawlingApiError) {
1075
+ return apiError(err);
1076
+ }
1077
+ return genericError(err);
1078
+ }
1079
+ });
1080
+ },
1081
+ },
1082
+ { name: "clawchat_add_group_member" },
1083
+ );
1084
+
1051
1085
  api.registerTool(
1052
1086
  {
1053
1087
  name: "clawchat_mention_message",