@clawling/clawchat-plugin-openclaw 2026.9.14-2 → 2026.9.17-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/src/runtime.ts CHANGED
@@ -17,7 +17,9 @@ import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
17
17
  import { createOpenclawClawlingClient, resolveOpenclawClawlingDeviceId } from "./client.ts";
18
18
  import { createOpenclawClawlingApiClient } from "./api-client.ts";
19
19
  import { buildActivationBootstrapText } from "./activation-greeting.ts";
20
+ import { buildFriendGreetingEnvelope } from "./friend-greeting.ts";
20
21
  import { reportPluginVersionSafe, resolvePluginVersion } from "./plugin-report.ts";
22
+ import { readOnboardingReport } from "./onboarding-report.ts";
21
23
  import path from "node:path";
22
24
  import {
23
25
  ensureLivewareCli,
@@ -32,6 +34,7 @@ import {
32
34
  } from "./liveware-sample.ts";
33
35
  import { ClawlingApiError } from "./api-types.ts";
34
36
  import { RefreshManager } from "./refresh-manager.ts";
37
+ import { RECONNECT_GUIDE_URL } from "./onboarding-context.ts";
35
38
  import {
36
39
  runOpenclawClawlingLogin,
37
40
  type LoginParams,
@@ -209,9 +212,13 @@ const OPENCLAW_CONFIRM_SLASH_COMMANDS = new Set([
209
212
  ]);
210
213
  const GROUP_OWNER_ATTENTION_TITLE = "requires owner attention";
211
214
  // §C.1 — user-visible message emitted on permanent token expiry. Kept
212
- // byte-identical to the Hermes plugin (parity spec §C.1.4).
213
- const CLAWCHAT_TOKEN_EXPIRED_MESSAGE =
214
- "ClawChat token expired and could not be refreshed. Re-pair with `/clawchat-activate <code>`.";
215
+ // byte-identical to the Hermes plugin (parity spec §C.1.4). Points the owner
216
+ // at the reconnect prompt (a code bound to this identity) rather than at a
217
+ // slash command: a fresh create code spent here would mint a second agent.
218
+ export const CLAWCHAT_TOKEN_EXPIRED_MESSAGE =
219
+ "ClawChat token expired and could not be refreshed. Ask your owner to send you the reconnect prompt from the ClawChat app, then follow " +
220
+ RECONNECT_GUIDE_URL +
221
+ ".";
215
222
  const CLAWCHAT_TOKEN_EXPIRED_LAST_ERROR = "token expired — re-pair required";
216
223
 
217
224
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -1066,6 +1073,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1066
1073
  pluginVersion,
1067
1074
  agentVersion,
1068
1075
  authenticated: false,
1076
+ onboarding: readOnboardingReport(),
1069
1077
  log,
1070
1078
  });
1071
1079
  // Ensure the liveware CLI is installed (async, never blocks startup).
@@ -1138,6 +1146,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1138
1146
  pluginVersion,
1139
1147
  agentVersion,
1140
1148
  authenticated: true,
1149
+ onboarding: readOnboardingReport(),
1141
1150
  log,
1142
1151
  });
1143
1152
  // §A.0 — fallback expiry source. Prefer the SQLite `activated_at`; null for a
@@ -2368,6 +2377,39 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
2368
2377
  ],
2369
2378
  }),
2370
2379
  );
2380
+ } else if (env.event === "history.truncated") {
2381
+ // Replay-start boundary frame (docs/client-integration.md §11.7): part
2382
+ // of the retained history will not be replayed to this device. This
2383
+ // plugin advertises no reliable-delivery flag, so the server does not
2384
+ // send it today; if one arrives it is logged explicitly. Never acked (no
2385
+ // seq/dseq), never dispatched, no user-visible action. An absent or
2386
+ // unrecognised `reason` means "pruned"; `oldest_seq` is an opaque token.
2387
+ const current = wsLogContext();
2388
+ const truncatedPayload = env.payload && typeof env.payload === "object"
2389
+ ? (env.payload as { oldest_seq?: unknown; reason?: unknown })
2390
+ : undefined;
2391
+ const rawReason = truncatedPayload?.reason;
2392
+ const knownReason = rawReason === "pruned" || rawReason === "cursor_started_above_zero";
2393
+ const fields: Array<[string, string | number]> = [
2394
+ ["event_name", env.event],
2395
+ ["trace_id", env.trace_id],
2396
+ ["oldest_seq", typeof truncatedPayload?.oldest_seq === "number" ? truncatedPayload.oldest_seq : "-"],
2397
+ ["reason", knownReason ? (rawReason as string) : "pruned"],
2398
+ ];
2399
+ if (!knownReason && typeof rawReason === "string" && rawReason) {
2400
+ fields.push(["raw_reason", rawReason]);
2401
+ }
2402
+ log?.info?.(
2403
+ formatWsLog({
2404
+ event: "inbound_control",
2405
+ accountId,
2406
+ attempt: current.attempt,
2407
+ reconnectCount: current.reconnectCount,
2408
+ state: "ready",
2409
+ action: "history_truncated",
2410
+ fields,
2411
+ }),
2412
+ );
2371
2413
  } else if (env.event !== "ping" && env.event !== "pong") {
2372
2414
  const current = wsLogContext();
2373
2415
  log?.info?.(
@@ -2527,6 +2569,93 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
2527
2569
  // A single queueMicrotask coalesces all awareness signals that arrive in
2528
2570
  // the same synchronous turn into exactly one handleInboundEnvelope call.
2529
2571
  let pendingAwarenessNote = false;
2572
+ /**
2573
+ * First message to a newly added NON-owner friend (`friend.added`).
2574
+ *
2575
+ * The signal only names the counterparty; the direct conversation the
2576
+ * server created inside the accept transaction is resolved through
2577
+ * `POST /v1/conversations/direct` and ONE synthetic greeting turn runs in
2578
+ * it. Dedupe is persisted through the message ledger keyed on the signal's
2579
+ * `event_id` (`message_id` is `notify:friend.added:<userId>` and would
2580
+ * collide on remove + re-add, so it is only the fallback key) — a
2581
+ * reconnect replay of the same signal cannot greet twice. The claim uses
2582
+ * kind "message" because the ledger's once-only unique index is partial on
2583
+ * kind = 'message'; chat_id stays null so the row never feeds a transcript.
2584
+ * The owner's own `friend.added` is skipped: the activation bootstrap greets
2585
+ * the owner.
2586
+ */
2587
+ const scheduleFriendGreeting = (payload: Record<string, unknown> | undefined): void => {
2588
+ if (!account.friendGreeting) return;
2589
+ const friendUserId = typeof payload?.entity_id === "string" ? payload.entity_id.trim() : "";
2590
+ if (!friendUserId) return;
2591
+ const ownerUserId = account.ownerUserId?.trim();
2592
+ if (ownerUserId && friendUserId === ownerUserId) return;
2593
+ const eventId = typeof payload?.event_id === "string" ? payload.event_id.trim() : "";
2594
+ const messageId = typeof payload?.message_id === "string" ? payload.message_id.trim() : "";
2595
+ const dedupeKey = eventId || messageId;
2596
+ if (!dedupeKey) {
2597
+ log?.info?.(
2598
+ `[${accountId}] clawchat-plugin-openclaw friend greeting skipped friend=${friendUserId} reason=no_dedupe_key`,
2599
+ );
2600
+ return;
2601
+ }
2602
+ if (store?.claimMessageOnce) {
2603
+ const claimed = recordConnection("friend greeting claim", () =>
2604
+ store.claimMessageOnce?.({
2605
+ platform: "openclaw",
2606
+ accountId,
2607
+ kind: "message",
2608
+ direction: "inbound",
2609
+ eventType: "friend.greeting",
2610
+ traceId: null,
2611
+ chatId: null,
2612
+ messageId: `friend.greeting:${dedupeKey}`,
2613
+ text: null,
2614
+ raw: { friend_user_id: friendUserId },
2615
+ }),
2616
+ );
2617
+ if (claimed !== true) {
2618
+ // false = already greeted (replay); null = ledger undecided — fail
2619
+ // closed rather than risk a duplicate unsolicited message.
2620
+ log?.info?.(
2621
+ `[${accountId}] clawchat-plugin-openclaw friend greeting skipped friend=${friendUserId} key=${dedupeKey} claimed=${String(claimed)}`,
2622
+ );
2623
+ return;
2624
+ }
2625
+ }
2626
+ void (async () => {
2627
+ let conversationId = "";
2628
+ try {
2629
+ const result = await getConversationApiClient().getDirectConversation(friendUserId);
2630
+ conversationId = result?.conversation?.id?.trim() ?? "";
2631
+ } catch (err) {
2632
+ log?.error?.(
2633
+ `[${accountId}] clawchat-plugin-openclaw friend greeting conversation lookup failed friend=${friendUserId}: ${
2634
+ err instanceof Error ? err.message : String(err)
2635
+ }`,
2636
+ );
2637
+ return;
2638
+ }
2639
+ if (!isValidChatId(conversationId)) {
2640
+ log?.error?.(
2641
+ `[${accountId}] clawchat-plugin-openclaw friend greeting skipped friend=${friendUserId} reason=no_conversation_id`,
2642
+ );
2643
+ return;
2644
+ }
2645
+ log?.info?.(
2646
+ `[${accountId}] clawchat-plugin-openclaw friend greeting dispatch friend=${friendUserId} chat_id=${conversationId}`,
2647
+ );
2648
+ await handleInboundEnvelope(
2649
+ buildFriendGreetingEnvelope({ account, conversationId, friendUserId }),
2650
+ );
2651
+ })().catch((err) => {
2652
+ log?.error?.(
2653
+ `[${accountId}] clawchat-plugin-openclaw friend greeting failed friend=${friendUserId}: ${
2654
+ err instanceof Error ? err.message : String(err)
2655
+ }`,
2656
+ );
2657
+ });
2658
+ };
2530
2659
 
2531
2660
  client.on("notify:signal", (env: Envelope) => {
2532
2661
  // §9.4 reliable system notification. The plugin holds no friend/roster
@@ -2641,6 +2770,9 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
2641
2770
  );
2642
2771
  });
2643
2772
  }
2773
+ if (type === "friend.added") {
2774
+ scheduleFriendGreeting(payload as Record<string, unknown> | undefined);
2775
+ }
2644
2776
  }
2645
2777
  }
2646
2778
  });
@@ -71,7 +71,7 @@ export const OFFICIAL_SKILLS_BASE =
71
71
  * in the install-cli repo, bump this constant, ship it. `liveware-sample.ts`
72
72
  * imports the same ref, so the `livewares` tree at that tag is pinned too.
73
73
  */
74
- export const DEFAULT_SKILLS_REF = "skills-v1.8.0";
74
+ export const DEFAULT_SKILLS_REF = "skills-v1.10.0";
75
75
 
76
76
  /** Refuse to treat an absurdly large response as a skill file (defence in depth). */
77
77
  export const MAX_SKILL_BYTES = 256 * 1024;
@@ -201,6 +201,14 @@ export const ClawchatGetConversationSchema = Type.Object({
201
201
  });
202
202
  export type ClawchatGetConversationParams = Static<typeof ClawchatGetConversationSchema>;
203
203
 
204
+ export const ClawchatGetDirectConversationSchema = Type.Object({
205
+ userId: Type.String({
206
+ minLength: 1,
207
+ description: "Concrete ClawChat user id (usr_...) of the friend",
208
+ }),
209
+ });
210
+ export type ClawchatGetDirectConversationParams = Static<typeof ClawchatGetDirectConversationSchema>;
211
+
204
212
  export const ClawchatLeaveGroupSchema = Type.Object({
205
213
  conversationId: Type.String({
206
214
  description: "Concrete ClawChat group conversation id to leave",
package/src/tools.ts CHANGED
@@ -44,6 +44,7 @@ import {
44
44
  import {
45
45
  ClawchatGetAccountProfileSchema,
46
46
  ClawchatGetConversationSchema,
47
+ ClawchatGetDirectConversationSchema,
47
48
  ClawchatGetMomentSchema,
48
49
  ClawchatGetUserProfileSchema,
49
50
  ClawchatLeaveGroupSchema,
@@ -88,6 +89,7 @@ import {
88
89
  type ClawchatDeleteMomentParams,
89
90
  type ClawchatAcceptFriendRequestParams,
90
91
  type ClawchatGetConversationParams,
92
+ type ClawchatGetDirectConversationParams,
91
93
  type ClawchatGetMomentParams,
92
94
  type ClawchatGetUserProfileParams,
93
95
  type ClawchatLeaveGroupParams,
@@ -1090,6 +1092,41 @@ export function registerOpenclawClawlingTools(
1090
1092
  { name: "clawchat_get_conversation" },
1091
1093
  );
1092
1094
 
1095
+ api.registerTool(
1096
+ (ctx) => {
1097
+ const accountId = resolveOpenclawClawlingToolAccountId(ctx);
1098
+ return {
1099
+ name: "clawchat_get_direct_conversation",
1100
+ label: "Get ClawChat Direct Conversation With User",
1101
+ description: toolDescription(
1102
+ "Resolve the direct (1:1) ClawChat conversation with a specific user to its conversation id (cnv_...), creating it if needed. " +
1103
+ "TRIGGER - invoke when you need to send a message to a ClawChat user and only know their userId (usr_...), for example to start a conversation with a newly added friend. " +
1104
+ "The user must already be your friend; otherwise the server rejects the call. " +
1105
+ "Use the returned conversation.id as chatId for clawchat_mention_message. " +
1106
+ "Never pass a userId or a name as a chatId.",
1107
+ ),
1108
+ parameters: ClawchatGetDirectConversationSchema,
1109
+ async execute(_callId, params) {
1110
+ return await recordClawchatToolCall(accountId, "clawchat_get_direct_conversation", params, async () => {
1111
+ const p = params as ClawchatGetDirectConversationParams;
1112
+ const built = buildClient(accountId);
1113
+ if (!built.ok) return built.error;
1114
+ try {
1115
+ const data = await built.client.getDirectConversation(p.userId);
1116
+ return jsonResponse(data);
1117
+ } catch (err) {
1118
+ if (err instanceof ClawlingApiError) {
1119
+ return apiError(err);
1120
+ }
1121
+ return genericError(err);
1122
+ }
1123
+ });
1124
+ },
1125
+ };
1126
+ },
1127
+ { name: "clawchat_get_direct_conversation" },
1128
+ );
1129
+
1093
1130
  api.registerTool(
1094
1131
  (ctx) => {
1095
1132
  const accountId = resolveOpenclawClawlingToolAccountId(ctx);
package/src/ws-client.ts CHANGED
@@ -733,6 +733,10 @@ export class ClawChatClient extends EventEmitter {
733
733
  if (env.event === EVENT.NOTIFY_SIGNAL) this.noteConversationSignalAlive(env);
734
734
  if (env.event === EVENT.NOTIFY_SIGNAL) this.emit("notify:signal", env);
735
735
  if (env.event === EVENT.REPLAY_DONE) this.emit("replay:done", env);
736
+ // Replay-start boundary frame (docs/client-integration.md §11.7). Control
737
+ // only: it carries no seq/dseq, so it is never acked and never becomes a
738
+ // `message`. Its own emitter keeps it out of the unknown-event path.
739
+ if (env.event === EVENT.HISTORY_TRUNCATED) this.emit("history:truncated", env);
736
740
  if (env.event === EVENT.OFFLINE_DONE) this.emit("offline:done");
737
741
  }
738
742