@clawling/clawchat-plugin-openclaw 2026.6.30-3 → 2026.7.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/reply-dispatcher.js +9 -0
- package/dist/src/runtime.js +124 -16
- 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/reply-dispatcher.ts +10 -0
- package/src/runtime.ts +135 -16
- package/src/tools.ts +44 -10
- package/src/ws-client.ts +21 -6
package/dist/src/tools.js
CHANGED
|
@@ -4,6 +4,7 @@ import { execFile } from "node:child_process";
|
|
|
4
4
|
import { createOpenclawClawlingApiClient } from "./api-client.js";
|
|
5
5
|
import { resolveLivewarePath } from "./liveware-cli.js";
|
|
6
6
|
import { ClawlingApiError, } from "./api-types.js";
|
|
7
|
+
import { mapGateOutcome } from "./gate-outcome.js";
|
|
7
8
|
import { resolveOpenclawClawlingAccount } from "./config.js";
|
|
8
9
|
import { clawChatDbPathForStateDir, getClawChatStore, } from "./storage.js";
|
|
9
10
|
import { sendOpenclawClawlingMentionMessage } from "./outbound.js";
|
|
@@ -272,6 +273,38 @@ export function registerOpenclawClawlingTools(api, options = {}) {
|
|
|
272
273
|
}
|
|
273
274
|
})();
|
|
274
275
|
}
|
|
276
|
+
/**
|
|
277
|
+
* Like withClient, but maps gate codes (21001/21003) to structured outcomes
|
|
278
|
+
* via mapGateOutcome so the agent sees a clear pending/forbidden status
|
|
279
|
+
* instead of a generic error. Used for friend and moment mutation tools.
|
|
280
|
+
*/
|
|
281
|
+
function withGatedClient(fn) {
|
|
282
|
+
return (async () => {
|
|
283
|
+
const built = buildClient();
|
|
284
|
+
if (!built.ok)
|
|
285
|
+
return built.error;
|
|
286
|
+
try {
|
|
287
|
+
const data = await fn(built.client);
|
|
288
|
+
return jsonResponse(data);
|
|
289
|
+
}
|
|
290
|
+
catch (err) {
|
|
291
|
+
if (err instanceof ClawlingApiError) {
|
|
292
|
+
const code = typeof err.meta?.code === "number" ? err.meta.code : null;
|
|
293
|
+
if (code !== null) {
|
|
294
|
+
const rawData = err.meta?.data;
|
|
295
|
+
const gateData = rawData !== null && rawData !== undefined && typeof rawData === "object"
|
|
296
|
+
? rawData
|
|
297
|
+
: {};
|
|
298
|
+
const outcome = mapGateOutcome(code, gateData);
|
|
299
|
+
if (outcome !== null)
|
|
300
|
+
return jsonResponse(outcome);
|
|
301
|
+
}
|
|
302
|
+
return apiError(err);
|
|
303
|
+
}
|
|
304
|
+
return genericError(err);
|
|
305
|
+
}
|
|
306
|
+
})();
|
|
307
|
+
}
|
|
275
308
|
function workspaceRoot(ctx) {
|
|
276
309
|
const root = typeof ctx.workspaceDir === "string" ? ctx.workspaceDir.trim() : "";
|
|
277
310
|
return root || null;
|
|
@@ -640,7 +673,7 @@ export function registerOpenclawClawlingTools(api, options = {}) {
|
|
|
640
673
|
async execute(_callId, params) {
|
|
641
674
|
return await recordClawchatToolCall("clawchat_send_friend_request", params, async () => {
|
|
642
675
|
const p = params;
|
|
643
|
-
return await
|
|
676
|
+
return await withGatedClient((c) => c.sendFriendRequest(p));
|
|
644
677
|
});
|
|
645
678
|
},
|
|
646
679
|
}, { name: "clawchat_send_friend_request" });
|
|
@@ -668,7 +701,7 @@ export function registerOpenclawClawlingTools(api, options = {}) {
|
|
|
668
701
|
async execute(_callId, params) {
|
|
669
702
|
return await recordClawchatToolCall("clawchat_accept_friend_request", params, async () => {
|
|
670
703
|
const p = params;
|
|
671
|
-
return await
|
|
704
|
+
return await withGatedClient((c) => c.acceptFriendRequest(p.requestId));
|
|
672
705
|
});
|
|
673
706
|
},
|
|
674
707
|
}, { name: "clawchat_accept_friend_request" });
|
|
@@ -682,7 +715,7 @@ export function registerOpenclawClawlingTools(api, options = {}) {
|
|
|
682
715
|
async execute(_callId, params) {
|
|
683
716
|
return await recordClawchatToolCall("clawchat_reject_friend_request", params, async () => {
|
|
684
717
|
const p = params;
|
|
685
|
-
return await
|
|
718
|
+
return await withGatedClient((c) => c.rejectFriendRequest(p.requestId));
|
|
686
719
|
});
|
|
687
720
|
},
|
|
688
721
|
}, { name: "clawchat_reject_friend_request" });
|
|
@@ -696,7 +729,7 @@ export function registerOpenclawClawlingTools(api, options = {}) {
|
|
|
696
729
|
async execute(_callId, params) {
|
|
697
730
|
return await recordClawchatToolCall("clawchat_remove_friend", params, async () => {
|
|
698
731
|
const p = params;
|
|
699
|
-
return await
|
|
732
|
+
return await withGatedClient((c) => c.removeFriend(p.friendUserId));
|
|
700
733
|
});
|
|
701
734
|
},
|
|
702
735
|
}, { name: "clawchat_remove_friend" });
|
|
@@ -841,7 +874,7 @@ export function registerOpenclawClawlingTools(api, options = {}) {
|
|
|
841
874
|
if (!text && (!images || images.length === 0)) {
|
|
842
875
|
return validationError("clawchat-plugin-openclaw: at least one of text or images is required");
|
|
843
876
|
}
|
|
844
|
-
return await
|
|
877
|
+
return await withGatedClient((c) => c.createMoment({
|
|
845
878
|
...(text !== undefined ? { text } : {}),
|
|
846
879
|
...(images !== undefined ? { images } : {}),
|
|
847
880
|
}));
|
|
@@ -858,7 +891,7 @@ export function registerOpenclawClawlingTools(api, options = {}) {
|
|
|
858
891
|
async execute(_callId, params) {
|
|
859
892
|
return await recordClawchatToolCall("clawchat_delete_moment", params, async () => {
|
|
860
893
|
const p = params;
|
|
861
|
-
return await
|
|
894
|
+
return await withGatedClient((c) => c.deleteMoment(p.momentId));
|
|
862
895
|
});
|
|
863
896
|
},
|
|
864
897
|
}, { name: "clawchat_delete_moment" });
|
|
@@ -875,7 +908,7 @@ export function registerOpenclawClawlingTools(api, options = {}) {
|
|
|
875
908
|
if (!p.emoji?.trim()) {
|
|
876
909
|
return validationError("clawchat-plugin-openclaw: emoji is required");
|
|
877
910
|
}
|
|
878
|
-
return await
|
|
911
|
+
return await withGatedClient((c) => c.toggleMomentReaction({ momentId: p.momentId, emoji: p.emoji }));
|
|
879
912
|
});
|
|
880
913
|
},
|
|
881
914
|
}, { name: "clawchat_toggle_moment_reaction" });
|
|
@@ -892,7 +925,7 @@ export function registerOpenclawClawlingTools(api, options = {}) {
|
|
|
892
925
|
if (!p.text?.trim()) {
|
|
893
926
|
return validationError("clawchat-plugin-openclaw: text is required");
|
|
894
927
|
}
|
|
895
|
-
return await
|
|
928
|
+
return await withGatedClient((c) => c.createMomentComment({ momentId: p.momentId, text: p.text }));
|
|
896
929
|
});
|
|
897
930
|
},
|
|
898
931
|
}, { name: "clawchat_create_moment_comment" });
|
|
@@ -909,7 +942,7 @@ export function registerOpenclawClawlingTools(api, options = {}) {
|
|
|
909
942
|
if (!p.text?.trim()) {
|
|
910
943
|
return validationError("clawchat-plugin-openclaw: text is required");
|
|
911
944
|
}
|
|
912
|
-
return await
|
|
945
|
+
return await withGatedClient((c) => c.replyMomentComment({
|
|
913
946
|
momentId: p.momentId,
|
|
914
947
|
replyToCommentId: p.replyToCommentId,
|
|
915
948
|
text: p.text,
|
|
@@ -927,7 +960,7 @@ export function registerOpenclawClawlingTools(api, options = {}) {
|
|
|
927
960
|
async execute(_callId, params) {
|
|
928
961
|
return await recordClawchatToolCall("clawchat_delete_moment_comment", params, async () => {
|
|
929
962
|
const p = params;
|
|
930
|
-
return await
|
|
963
|
+
return await withGatedClient((c) => c.deleteMomentComment({ momentId: p.momentId, commentId: p.commentId }));
|
|
931
964
|
});
|
|
932
965
|
},
|
|
933
966
|
}, { name: "clawchat_delete_moment_comment" });
|
package/dist/src/ws-client.js
CHANGED
|
@@ -84,6 +84,23 @@ export function createWebSocketTransport(WebSocketCtor = globalThis.WebSocket) {
|
|
|
84
84
|
},
|
|
85
85
|
};
|
|
86
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* Returns the capability set advertised in the connect handshake.
|
|
89
|
+
* Exported so tests can assert on the exact fields without a full WS setup.
|
|
90
|
+
*
|
|
91
|
+
* permission_events: forward-compat: advertises readiness for a future native
|
|
92
|
+
* `permission.receipt` wire frame; today the permission-decision receipt is
|
|
93
|
+
* delivered as a system message until msghub emits the native frame.
|
|
94
|
+
*/
|
|
95
|
+
export function buildConnectCapabilities() {
|
|
96
|
+
return {
|
|
97
|
+
multi_device: false,
|
|
98
|
+
device_replay: true,
|
|
99
|
+
chat_meta_events: true,
|
|
100
|
+
notify_signals: true,
|
|
101
|
+
permission_events: true,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
87
104
|
export class ClawChatClient extends EventEmitter {
|
|
88
105
|
opts;
|
|
89
106
|
currentState = "idle";
|
|
@@ -333,12 +350,8 @@ export class ClawChatClient extends EventEmitter {
|
|
|
333
350
|
// *send* history.transit, which a single-device agent never does, and we
|
|
334
351
|
// do not handle inbound history.transit — advertising it would invite
|
|
335
352
|
// frames we cannot process. This is a deliberate, spec-legal omission.
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
device_replay: true,
|
|
339
|
-
chat_meta_events: true,
|
|
340
|
-
notify_signals: true,
|
|
341
|
-
},
|
|
353
|
+
// permission_events: forward-compat; see buildConnectCapabilities().
|
|
354
|
+
capabilities: buildConnectCapabilities(),
|
|
342
355
|
};
|
|
343
356
|
const traceId = this.nextTraceId();
|
|
344
357
|
this.expectedConnectTraceId = traceId;
|
package/openclaw.plugin.json
CHANGED
|
@@ -107,6 +107,7 @@
|
|
|
107
107
|
"forwardThinking": { "type": "boolean" },
|
|
108
108
|
"forwardToolCalls": { "type": "boolean" },
|
|
109
109
|
"richInteractions": { "type": "boolean" },
|
|
110
|
+
"awarenessNote": { "type": "boolean" },
|
|
110
111
|
"reconnect": {
|
|
111
112
|
"type": "object",
|
|
112
113
|
"additionalProperties": false,
|
|
@@ -180,6 +181,7 @@
|
|
|
180
181
|
"forwardThinking": { "type": "boolean" },
|
|
181
182
|
"forwardToolCalls": { "type": "boolean" },
|
|
182
183
|
"richInteractions": { "type": "boolean" },
|
|
184
|
+
"awarenessNote": { "type": "boolean" },
|
|
183
185
|
"reconnect": {
|
|
184
186
|
"type": "object",
|
|
185
187
|
"additionalProperties": false,
|
package/package.json
CHANGED
package/src/api-client.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
type UserSearchHit,
|
|
18
18
|
} from "./api-types.ts";
|
|
19
19
|
import type { GroupSettings, GroupSettingsFetchResult } from "./group-settings.ts";
|
|
20
|
+
import type { PermissionPolicy, PermState } from "./permissions.ts";
|
|
20
21
|
import { CHANNEL_ID } from "./config.ts";
|
|
21
22
|
|
|
22
23
|
export interface PluginReportInput {
|
|
@@ -179,6 +180,17 @@ export interface OpenclawClawlingApiClient {
|
|
|
179
180
|
* error still throws.
|
|
180
181
|
*/
|
|
181
182
|
getMyGroupSettings(): Promise<GroupSettingsFetchResult>;
|
|
183
|
+
/**
|
|
184
|
+
* Fetch the agent's permission policy from `GET /v1/agents/me/permissions`.
|
|
185
|
+
*
|
|
186
|
+
* The response `data` is a flat object mapping operation name → state
|
|
187
|
+
* (`"ask"|"allow"|"deny"`), with one special key `"moment.visibility"` whose
|
|
188
|
+
* value is a string array. Returns a `PermissionPolicy` with `byOperation`
|
|
189
|
+
* (all non-visibility keys) and `momentVisibility` (the array). Throws on
|
|
190
|
+
* network errors or non-2xx responses; callers must catch and keep the stale
|
|
191
|
+
* cache (never throw / never block message handling).
|
|
192
|
+
*/
|
|
193
|
+
getMyPermissions(): Promise<PermissionPolicy>;
|
|
182
194
|
registerApp(params: { name: string; appId: string; url: string }): Promise<{ app: AgentAppView }>;
|
|
183
195
|
listApps(): Promise<{ apps: AgentAppView[] }>;
|
|
184
196
|
unregisterApp(appId: string): Promise<{ deleted: boolean }>;
|
|
@@ -362,6 +374,7 @@ export function createOpenclawClawlingApiClient(opts: ApiClientOptions): Opencla
|
|
|
362
374
|
code,
|
|
363
375
|
status: res.status,
|
|
364
376
|
path,
|
|
377
|
+
...(env.data !== undefined ? { data: env.data } : {}),
|
|
365
378
|
});
|
|
366
379
|
}
|
|
367
380
|
return env.data as T;
|
|
@@ -759,5 +772,15 @@ export function createOpenclawClawlingApiClient(opts: ApiClientOptions): Opencla
|
|
|
759
772
|
}
|
|
760
773
|
return { authoritative: true, rows };
|
|
761
774
|
},
|
|
775
|
+
async getMyPermissions(): Promise<PermissionPolicy> {
|
|
776
|
+
const data = await call<Record<string, unknown>>("GET", "/v1/agents/me/permissions");
|
|
777
|
+
const byOperation: Record<string, PermState> = {};
|
|
778
|
+
let momentVisibility: string[] = [];
|
|
779
|
+
for (const [k, v] of Object.entries(data ?? {})) {
|
|
780
|
+
if (k === "moment.visibility") momentVisibility = (v as string[]) ?? [];
|
|
781
|
+
else byOperation[k] = v as PermState;
|
|
782
|
+
}
|
|
783
|
+
return { byOperation, momentVisibility };
|
|
784
|
+
},
|
|
762
785
|
};
|
|
763
786
|
}
|
package/src/api-types.ts
CHANGED
|
@@ -157,7 +157,7 @@ export class ClawlingApiError extends Error {
|
|
|
157
157
|
constructor(
|
|
158
158
|
public readonly kind: ClawlingApiErrorKind,
|
|
159
159
|
message: string,
|
|
160
|
-
public readonly meta?: { code?: number; status?: number; path?: string },
|
|
160
|
+
public readonly meta?: { code?: number; status?: number; path?: string; data?: unknown },
|
|
161
161
|
) {
|
|
162
162
|
super(message);
|
|
163
163
|
this.name = "ClawlingApiError";
|
package/src/config.ts
CHANGED
|
@@ -106,6 +106,8 @@ export type OpenclawClawlingConfig = {
|
|
|
106
106
|
forwardToolCalls?: boolean;
|
|
107
107
|
/** Emit approval/action rich fragments instead of plain fallback text. */
|
|
108
108
|
richInteractions?: boolean;
|
|
109
|
+
/** Emit ONE consolidated awareness note to the agent when friend/conversation signals arrive. */
|
|
110
|
+
awarenessNote?: boolean;
|
|
109
111
|
reconnect?: OpenclawClawlingReconnectConfig;
|
|
110
112
|
heartbeat?: OpenclawClawlingHeartbeatConfig;
|
|
111
113
|
ack?: OpenclawClawlingAckConfig;
|
|
@@ -152,6 +154,7 @@ export const openclawClawlingConfigSchema = {
|
|
|
152
154
|
forwardThinking: { type: "boolean" },
|
|
153
155
|
forwardToolCalls: { type: "boolean" },
|
|
154
156
|
richInteractions: { type: "boolean" },
|
|
157
|
+
awarenessNote: { type: "boolean" },
|
|
155
158
|
reconnect: {
|
|
156
159
|
type: "object",
|
|
157
160
|
additionalProperties: false,
|
|
@@ -274,6 +277,7 @@ export type ResolvedOpenclawClawlingAccount = {
|
|
|
274
277
|
forwardThinking: boolean;
|
|
275
278
|
forwardToolCalls: boolean;
|
|
276
279
|
richInteractions: boolean;
|
|
280
|
+
awarenessNote: boolean;
|
|
277
281
|
allowFrom: string[];
|
|
278
282
|
reconnect: Required<OpenclawClawlingReconnectConfig>;
|
|
279
283
|
heartbeat: Required<OpenclawClawlingHeartbeatConfig>;
|
|
@@ -482,6 +486,8 @@ export function resolveOpenclawClawlingAccount(
|
|
|
482
486
|
typeof channel.forwardToolCalls === "boolean" ? channel.forwardToolCalls : false;
|
|
483
487
|
const richInteractions =
|
|
484
488
|
typeof channel.richInteractions === "boolean" ? channel.richInteractions : false;
|
|
489
|
+
const awarenessNote =
|
|
490
|
+
typeof channel.awarenessNote === "boolean" ? channel.awarenessNote : false;
|
|
485
491
|
|
|
486
492
|
return {
|
|
487
493
|
accountId: DEFAULT_ACCOUNT_ID,
|
|
@@ -508,6 +514,7 @@ export function resolveOpenclawClawlingAccount(
|
|
|
508
514
|
forwardThinking,
|
|
509
515
|
forwardToolCalls,
|
|
510
516
|
richInteractions,
|
|
517
|
+
awarenessNote,
|
|
511
518
|
allowFrom: [],
|
|
512
519
|
reconnect: readReconnect(channel.reconnect),
|
|
513
520
|
heartbeat: readHeartbeat(channel.heartbeat),
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synthetic reasoning-turn builder for `friend.request` notify.signal events.
|
|
3
|
+
*
|
|
4
|
+
* When the server fires a `friend.request` signal the runtime dispatches one
|
|
5
|
+
* deduped synthetic `message.send` envelope so the agent can reason about the
|
|
6
|
+
* pending request. The prompt text varies by the current "friend.add" permission
|
|
7
|
+
* policy state: a deny policy yields a decline/inform prompt; ask or allow
|
|
8
|
+
* yields a prompt that instructs the agent to review the request via the
|
|
9
|
+
* `clawchat_list_friend_requests` tool.
|
|
10
|
+
*/
|
|
11
|
+
import { EVENT, type Envelope } from "./protocol-types.ts";
|
|
12
|
+
import type { PermState } from "./permissions.ts";
|
|
13
|
+
import type { ResolvedOpenclawClawlingAccount } from "./config.ts";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Returns canned prompt text for a friend-request reasoning turn based on the
|
|
17
|
+
* current "friend.add" permission policy state.
|
|
18
|
+
*
|
|
19
|
+
* - `deny` → inform the agent it should decline; no accept instruction.
|
|
20
|
+
* - `ask` / `allow` → instruct the agent to review the pending request.
|
|
21
|
+
*/
|
|
22
|
+
export function friendRequestPromptFor(state: PermState): string {
|
|
23
|
+
if (state === "deny") {
|
|
24
|
+
return [
|
|
25
|
+
"A new friend request has arrived.",
|
|
26
|
+
"Your current friend-add policy is set to deny.",
|
|
27
|
+
"Do not add this contact.",
|
|
28
|
+
"You may inform the requester that you cannot add them at this time.",
|
|
29
|
+
].join(" ");
|
|
30
|
+
}
|
|
31
|
+
return [
|
|
32
|
+
"A new friend request has arrived.",
|
|
33
|
+
"Please review the pending request by calling `clawchat_list_friend_requests`",
|
|
34
|
+
"and decide whether to accept it.",
|
|
35
|
+
].join(" ");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface BuildFriendRequestEnvelopeParams {
|
|
39
|
+
account: ResolvedOpenclawClawlingAccount;
|
|
40
|
+
/** Current "friend.add" permission policy state. */
|
|
41
|
+
state: PermState;
|
|
42
|
+
/** entity_id from the notify.signal payload (the requesting user). */
|
|
43
|
+
entityId: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Builds a synthetic `message.send` envelope that triggers one agent reasoning
|
|
48
|
+
* turn for a pending friend request. The envelope shape mirrors the
|
|
49
|
+
* activation-bootstrap envelope; it targets the owner's direct conversation so
|
|
50
|
+
* the agent has context about who it is reasoning for.
|
|
51
|
+
*/
|
|
52
|
+
export function buildFriendRequestEnvelope(
|
|
53
|
+
params: BuildFriendRequestEnvelopeParams,
|
|
54
|
+
): Envelope {
|
|
55
|
+
const { account, state, entityId } = params;
|
|
56
|
+
const text = friendRequestPromptFor(state);
|
|
57
|
+
const now = Date.now();
|
|
58
|
+
return {
|
|
59
|
+
version: "2",
|
|
60
|
+
event: EVENT.MESSAGE_SEND,
|
|
61
|
+
trace_id: `clawchat-plugin-openclaw-friend-request-${entityId}-${now}`,
|
|
62
|
+
emitted_at: now,
|
|
63
|
+
chat_id: account.ownerUserId,
|
|
64
|
+
chat_type: "direct",
|
|
65
|
+
to: { id: account.userId, type: "direct" },
|
|
66
|
+
sender: {
|
|
67
|
+
id: "clawchat-friend-request",
|
|
68
|
+
type: "direct",
|
|
69
|
+
nick_name: "ClawChat",
|
|
70
|
+
},
|
|
71
|
+
payload: {
|
|
72
|
+
message_id: `clawchat-plugin-openclaw-friend-request-${entityId}-${now}`,
|
|
73
|
+
message_mode: "normal",
|
|
74
|
+
message: {
|
|
75
|
+
body: { fragments: [{ kind: "text", text }] },
|
|
76
|
+
context: { mentions: [], reply: null },
|
|
77
|
+
streaming: {
|
|
78
|
+
status: "static",
|
|
79
|
+
sequence: 0,
|
|
80
|
+
mutation_policy: "sealed",
|
|
81
|
+
started_at: null,
|
|
82
|
+
completed_at: null,
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maps backend owner-approval gate codes to structured tool outcomes.
|
|
3
|
+
*
|
|
4
|
+
* Backend gate codes (confirmed live contract):
|
|
5
|
+
* 21001 — pending owner approval (payload: { request_id, operation, expires_at })
|
|
6
|
+
* 21003 — policy forbidden (payload: { operation })
|
|
7
|
+
*
|
|
8
|
+
* Returns null for any other code so callers can fall through to normal handling.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type PendingApprovalOutcome = {
|
|
12
|
+
status: "pending_owner_approval";
|
|
13
|
+
request_id: string;
|
|
14
|
+
operation: string;
|
|
15
|
+
expires_at: number;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type ForbiddenOutcome = {
|
|
19
|
+
status: "forbidden_by_owner";
|
|
20
|
+
operation: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type GateOutcome = PendingApprovalOutcome | ForbiddenOutcome;
|
|
24
|
+
|
|
25
|
+
export function mapGateOutcome(
|
|
26
|
+
code: number,
|
|
27
|
+
data: Record<string, unknown>,
|
|
28
|
+
): GateOutcome | null {
|
|
29
|
+
if (code === 21001) {
|
|
30
|
+
return {
|
|
31
|
+
status: "pending_owner_approval",
|
|
32
|
+
request_id: typeof data.request_id === "string" ? data.request_id : "",
|
|
33
|
+
operation: typeof data.operation === "string" ? data.operation : "",
|
|
34
|
+
expires_at: typeof data.expires_at === "number" ? data.expires_at : 0,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
if (code === 21003) {
|
|
38
|
+
return {
|
|
39
|
+
status: "forbidden_by_owner",
|
|
40
|
+
operation: typeof data.operation === "string" ? data.operation : "",
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
package/src/inbound.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type { ResolvedOpenclawClawlingAccount } from "./config.ts";
|
|
|
8
8
|
import type { MediaItem } from "./media-runtime.ts";
|
|
9
9
|
import { extractMediaFragments, fragmentsToText, type MentionedUser } from "./message-mapper.ts";
|
|
10
10
|
import { hasRenderableText, isInboundMessagePayload } from "./protocol.ts";
|
|
11
|
+
import type { PermissionResultMetadata } from "./permission-result.ts";
|
|
11
12
|
|
|
12
13
|
export interface IngestTurnParams {
|
|
13
14
|
channel: "clawchat-plugin-openclaw";
|
|
@@ -81,6 +82,20 @@ export interface DispatchInboundParams {
|
|
|
81
82
|
text: string;
|
|
82
83
|
chatId: string;
|
|
83
84
|
}) => Promise<boolean>;
|
|
85
|
+
/**
|
|
86
|
+
* Optional consumer for `permission_result` system-message receipts.
|
|
87
|
+
*
|
|
88
|
+
* When `sender.id === "system"` and `payload.metadata.kind ===
|
|
89
|
+
* "permission_result"`, the guard calls this callback with the metadata
|
|
90
|
+
* object instead of performing the generic system-message drop. The
|
|
91
|
+
* callback is responsible for deduplication and synthetic-turn dispatch
|
|
92
|
+
* (see `src/permission-result.ts`).
|
|
93
|
+
*
|
|
94
|
+
* When absent, `permission_result` frames fall through to the existing
|
|
95
|
+
* system-message drop (safe default — no regression for callers that have
|
|
96
|
+
* not yet wired the consumer).
|
|
97
|
+
*/
|
|
98
|
+
consumePermissionResult?: (metadata: PermissionResultMetadata) => Promise<void>;
|
|
84
99
|
log?: { info?: (m: string) => void; error?: (m: string) => void };
|
|
85
100
|
}
|
|
86
101
|
|
|
@@ -241,6 +256,19 @@ export async function dispatchOpenclawClawlingInbound(
|
|
|
241
256
|
return;
|
|
242
257
|
}
|
|
243
258
|
if (sender.id === "system") {
|
|
259
|
+
// Intercept `permission_result` receipts before the generic system-message
|
|
260
|
+
// drop. The discriminator is `payload.metadata.kind === "permission_result"`;
|
|
261
|
+
// all other system messages (group-join notifications, etc.) still drop.
|
|
262
|
+
const rawPayload = envelope.payload as Record<string, unknown>;
|
|
263
|
+
const meta = rawPayload.metadata as Record<string, unknown> | undefined;
|
|
264
|
+
if (
|
|
265
|
+
params.consumePermissionResult &&
|
|
266
|
+
meta != null &&
|
|
267
|
+
meta.kind === "permission_result"
|
|
268
|
+
) {
|
|
269
|
+
await params.consumePermissionResult(meta as unknown as PermissionResultMetadata);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
244
272
|
log?.info?.(
|
|
245
273
|
`[${account.accountId}] clawchat-plugin-openclaw skip system message msg=${payload.message_id}`,
|
|
246
274
|
);
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Consumer for `permission_result` system-message receipts.
|
|
3
|
+
*
|
|
4
|
+
* When the backend delivers a `permission_result` outcome as a
|
|
5
|
+
* `message.send` system frame (`sender.id="system"`, `payload.metadata.kind
|
|
6
|
+
* ="permission_result"`), this module deduplicates by `request_id` and
|
|
7
|
+
* produces a synthetic context-note turn so the agent can close the loop
|
|
8
|
+
* on the original permission request.
|
|
9
|
+
*
|
|
10
|
+
* Wire shape (authoritative):
|
|
11
|
+
* ```
|
|
12
|
+
* payload.metadata = {
|
|
13
|
+
* kind: "permission_result",
|
|
14
|
+
* operation: "friend.add",
|
|
15
|
+
* outcome: "approved" | "denied" | "expired" | "failed",
|
|
16
|
+
* reason: "owner_allowed" | …,
|
|
17
|
+
* request_id: "prq_…",
|
|
18
|
+
* }
|
|
19
|
+
* ```
|
|
20
|
+
* The discriminator is `payload.metadata.kind === "permission_result"`.
|
|
21
|
+
*/
|
|
22
|
+
import { EVENT, type Envelope } from "./protocol-types.ts";
|
|
23
|
+
import type { ResolvedOpenclawClawlingAccount } from "./config.ts";
|
|
24
|
+
|
|
25
|
+
export interface PermissionResultMetadata {
|
|
26
|
+
kind: "permission_result";
|
|
27
|
+
operation: string;
|
|
28
|
+
outcome: "approved" | "denied" | "expired" | "failed";
|
|
29
|
+
reason: string;
|
|
30
|
+
request_id: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Module-level dedup set — `request_id` values are stable backend identifiers.
|
|
34
|
+
// A duplicate receipt (e.g. from WS replay on reconnect) must not re-trigger
|
|
35
|
+
// an agent turn.
|
|
36
|
+
const _seenRequestIds = new Set<string>();
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @internal Test helper — reset the dedup set between isolated test runs.
|
|
40
|
+
* Never call this in production code.
|
|
41
|
+
*/
|
|
42
|
+
export function _resetPermissionResultDedup(): void {
|
|
43
|
+
_seenRequestIds.clear();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function buildOutcomeNote(metadata: PermissionResultMetadata): string {
|
|
47
|
+
const { operation, outcome, reason } = metadata;
|
|
48
|
+
const outcomeLabel =
|
|
49
|
+
outcome === "approved"
|
|
50
|
+
? "approved"
|
|
51
|
+
: outcome === "denied"
|
|
52
|
+
? "denied"
|
|
53
|
+
: outcome === "expired"
|
|
54
|
+
? "expired"
|
|
55
|
+
: "failed";
|
|
56
|
+
const resolution =
|
|
57
|
+
outcome === "approved"
|
|
58
|
+
? "The requested action has been completed."
|
|
59
|
+
: "No further action was taken.";
|
|
60
|
+
return [
|
|
61
|
+
`Permission request for operation "${operation}" has been ${outcomeLabel}.`,
|
|
62
|
+
`Reason: ${reason}.`,
|
|
63
|
+
resolution,
|
|
64
|
+
].join(" ");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Builds a synthetic `message.send` envelope that delivers a
|
|
69
|
+
* `permission_result` context note to the agent.
|
|
70
|
+
*
|
|
71
|
+
* Mirrors the `buildFriendRequestEnvelope` pattern: a synthetic inbound
|
|
72
|
+
* `message.send` targeting the owner's direct conversation, routed through
|
|
73
|
+
* `handleInboundEnvelope` so the agent can reason about the outcome.
|
|
74
|
+
*/
|
|
75
|
+
export function buildPermissionResultEnvelope(params: {
|
|
76
|
+
account: Pick<ResolvedOpenclawClawlingAccount, "userId" | "ownerUserId">;
|
|
77
|
+
metadata: PermissionResultMetadata;
|
|
78
|
+
}): Envelope {
|
|
79
|
+
const { account, metadata } = params;
|
|
80
|
+
const text = buildOutcomeNote(metadata);
|
|
81
|
+
const now = Date.now();
|
|
82
|
+
return {
|
|
83
|
+
version: "2",
|
|
84
|
+
event: EVENT.MESSAGE_SEND,
|
|
85
|
+
trace_id: `clawchat-plugin-openclaw-permission-result-${metadata.request_id}-${now}`,
|
|
86
|
+
emitted_at: now,
|
|
87
|
+
chat_id: account.ownerUserId,
|
|
88
|
+
chat_type: "direct",
|
|
89
|
+
to: { id: account.userId, type: "direct" },
|
|
90
|
+
sender: {
|
|
91
|
+
id: "clawchat-permission-result",
|
|
92
|
+
type: "direct",
|
|
93
|
+
nick_name: "ClawChat",
|
|
94
|
+
},
|
|
95
|
+
payload: {
|
|
96
|
+
message_id: `clawchat-plugin-openclaw-permission-result-${metadata.request_id}-${now}`,
|
|
97
|
+
message_mode: "normal",
|
|
98
|
+
message: {
|
|
99
|
+
body: { fragments: [{ kind: "text", text }] },
|
|
100
|
+
context: { mentions: [], reply: null },
|
|
101
|
+
streaming: {
|
|
102
|
+
status: "static",
|
|
103
|
+
sequence: 0,
|
|
104
|
+
mutation_policy: "sealed",
|
|
105
|
+
started_at: null,
|
|
106
|
+
completed_at: null,
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Handles a `permission_result` receipt. Deduplicates by `request_id`
|
|
115
|
+
* (idempotent — WS replay on reconnect may deliver the same receipt again).
|
|
116
|
+
*
|
|
117
|
+
* Returns a synthetic context-note {@link Envelope} on a fresh receipt so
|
|
118
|
+
* the caller can feed it to `handleInboundEnvelope`; returns `null` when
|
|
119
|
+
* the `request_id` has already been processed.
|
|
120
|
+
*/
|
|
121
|
+
export function handlePermissionResult(
|
|
122
|
+
metadata: PermissionResultMetadata,
|
|
123
|
+
account: Pick<ResolvedOpenclawClawlingAccount, "userId" | "ownerUserId">,
|
|
124
|
+
): Envelope | null {
|
|
125
|
+
if (_seenRequestIds.has(metadata.request_id)) return null;
|
|
126
|
+
_seenRequestIds.add(metadata.request_id);
|
|
127
|
+
return buildPermissionResultEnvelope({ account, metadata });
|
|
128
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type PermState = "ask" | "allow" | "deny";
|
|
2
|
+
|
|
3
|
+
export interface PermissionPolicy {
|
|
4
|
+
byOperation: Record<string, PermState>;
|
|
5
|
+
momentVisibility: string[];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export class PermissionCache {
|
|
9
|
+
private p: PermissionPolicy = { byOperation: {}, momentVisibility: [] };
|
|
10
|
+
get(): PermissionPolicy { return this.p; }
|
|
11
|
+
set(p: PermissionPolicy): void { this.p = p; }
|
|
12
|
+
stateOf(op: string): PermState { return this.p.byOperation[op] ?? "ask"; }
|
|
13
|
+
}
|
package/src/reply-dispatcher.ts
CHANGED
|
@@ -375,6 +375,8 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
|
|
|
375
375
|
dispatcher: TypedReplyDispatcherResult["dispatcher"];
|
|
376
376
|
replyOptions: ClawChatReplyOptions;
|
|
377
377
|
markDispatchIdle: TypedReplyDispatcherResult["markDispatchIdle"];
|
|
378
|
+
/** Number of outbound messages actually delivered to the target so far. */
|
|
379
|
+
getOutboundDeliveredCount: () => number;
|
|
378
380
|
} {
|
|
379
381
|
const {
|
|
380
382
|
cfg,
|
|
@@ -430,6 +432,12 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
|
|
|
430
432
|
let typingActive = false;
|
|
431
433
|
let terminalReplySuppressed = false;
|
|
432
434
|
let finalDeliverySeen = false;
|
|
435
|
+
// Count of outbound messages that were actually delivered to the target
|
|
436
|
+
// (a real, successful send — not a suppressed/duplicate/failed one). This is
|
|
437
|
+
// the reliable "did anything actually reach the user?" signal; host-side
|
|
438
|
+
// `counts.block`/`counts.final` only reflect what the agent emitted, not
|
|
439
|
+
// whether the send succeeded (a cancelled send still counts as a block).
|
|
440
|
+
let outboundDeliveredCount = 0;
|
|
433
441
|
|
|
434
442
|
const outboundEventType = () => (replyCtx ? "message.reply" : "message.send");
|
|
435
443
|
const outboundRaw = () => ({ target, replyCtx: replyCtx ?? null });
|
|
@@ -646,6 +654,7 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
|
|
|
646
654
|
log,
|
|
647
655
|
});
|
|
648
656
|
recordOutboundAck("message", messageId, result);
|
|
657
|
+
if (result) outboundDeliveredCount += 1;
|
|
649
658
|
log?.info?.(
|
|
650
659
|
`[${account.accountId}] clawchat-plugin-openclaw send complete to=${target.chatId}`,
|
|
651
660
|
);
|
|
@@ -835,6 +844,7 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
|
|
|
835
844
|
|
|
836
845
|
return {
|
|
837
846
|
dispatcher: base.dispatcher,
|
|
847
|
+
getOutboundDeliveredCount: () => outboundDeliveredCount,
|
|
838
848
|
replyOptions: {
|
|
839
849
|
...base.replyOptions,
|
|
840
850
|
sourceReplyDeliveryMode: "automatic",
|