@clawling/clawchat-plugin-openclaw 2026.9.14-2 → 2026.9.16-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,6 +17,7 @@ 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";
21
22
  import path from "node:path";
22
23
  import {
@@ -2368,6 +2369,39 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
2368
2369
  ],
2369
2370
  }),
2370
2371
  );
2372
+ } else if (env.event === "history.truncated") {
2373
+ // Replay-start boundary frame (docs/client-integration.md §11.7): part
2374
+ // of the retained history will not be replayed to this device. This
2375
+ // plugin advertises no reliable-delivery flag, so the server does not
2376
+ // send it today; if one arrives it is logged explicitly. Never acked (no
2377
+ // seq/dseq), never dispatched, no user-visible action. An absent or
2378
+ // unrecognised `reason` means "pruned"; `oldest_seq` is an opaque token.
2379
+ const current = wsLogContext();
2380
+ const truncatedPayload = env.payload && typeof env.payload === "object"
2381
+ ? (env.payload as { oldest_seq?: unknown; reason?: unknown })
2382
+ : undefined;
2383
+ const rawReason = truncatedPayload?.reason;
2384
+ const knownReason = rawReason === "pruned" || rawReason === "cursor_started_above_zero";
2385
+ const fields: Array<[string, string | number]> = [
2386
+ ["event_name", env.event],
2387
+ ["trace_id", env.trace_id],
2388
+ ["oldest_seq", typeof truncatedPayload?.oldest_seq === "number" ? truncatedPayload.oldest_seq : "-"],
2389
+ ["reason", knownReason ? (rawReason as string) : "pruned"],
2390
+ ];
2391
+ if (!knownReason && typeof rawReason === "string" && rawReason) {
2392
+ fields.push(["raw_reason", rawReason]);
2393
+ }
2394
+ log?.info?.(
2395
+ formatWsLog({
2396
+ event: "inbound_control",
2397
+ accountId,
2398
+ attempt: current.attempt,
2399
+ reconnectCount: current.reconnectCount,
2400
+ state: "ready",
2401
+ action: "history_truncated",
2402
+ fields,
2403
+ }),
2404
+ );
2371
2405
  } else if (env.event !== "ping" && env.event !== "pong") {
2372
2406
  const current = wsLogContext();
2373
2407
  log?.info?.(
@@ -2527,6 +2561,93 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
2527
2561
  // A single queueMicrotask coalesces all awareness signals that arrive in
2528
2562
  // the same synchronous turn into exactly one handleInboundEnvelope call.
2529
2563
  let pendingAwarenessNote = false;
2564
+ /**
2565
+ * First message to a newly added NON-owner friend (`friend.added`).
2566
+ *
2567
+ * The signal only names the counterparty; the direct conversation the
2568
+ * server created inside the accept transaction is resolved through
2569
+ * `POST /v1/conversations/direct` and ONE synthetic greeting turn runs in
2570
+ * it. Dedupe is persisted through the message ledger keyed on the signal's
2571
+ * `event_id` (`message_id` is `notify:friend.added:<userId>` and would
2572
+ * collide on remove + re-add, so it is only the fallback key) — a
2573
+ * reconnect replay of the same signal cannot greet twice. The claim uses
2574
+ * kind "message" because the ledger's once-only unique index is partial on
2575
+ * kind = 'message'; chat_id stays null so the row never feeds a transcript.
2576
+ * The owner's own `friend.added` is skipped: the activation bootstrap greets
2577
+ * the owner.
2578
+ */
2579
+ const scheduleFriendGreeting = (payload: Record<string, unknown> | undefined): void => {
2580
+ if (!account.friendGreeting) return;
2581
+ const friendUserId = typeof payload?.entity_id === "string" ? payload.entity_id.trim() : "";
2582
+ if (!friendUserId) return;
2583
+ const ownerUserId = account.ownerUserId?.trim();
2584
+ if (ownerUserId && friendUserId === ownerUserId) return;
2585
+ const eventId = typeof payload?.event_id === "string" ? payload.event_id.trim() : "";
2586
+ const messageId = typeof payload?.message_id === "string" ? payload.message_id.trim() : "";
2587
+ const dedupeKey = eventId || messageId;
2588
+ if (!dedupeKey) {
2589
+ log?.info?.(
2590
+ `[${accountId}] clawchat-plugin-openclaw friend greeting skipped friend=${friendUserId} reason=no_dedupe_key`,
2591
+ );
2592
+ return;
2593
+ }
2594
+ if (store?.claimMessageOnce) {
2595
+ const claimed = recordConnection("friend greeting claim", () =>
2596
+ store.claimMessageOnce?.({
2597
+ platform: "openclaw",
2598
+ accountId,
2599
+ kind: "message",
2600
+ direction: "inbound",
2601
+ eventType: "friend.greeting",
2602
+ traceId: null,
2603
+ chatId: null,
2604
+ messageId: `friend.greeting:${dedupeKey}`,
2605
+ text: null,
2606
+ raw: { friend_user_id: friendUserId },
2607
+ }),
2608
+ );
2609
+ if (claimed !== true) {
2610
+ // false = already greeted (replay); null = ledger undecided — fail
2611
+ // closed rather than risk a duplicate unsolicited message.
2612
+ log?.info?.(
2613
+ `[${accountId}] clawchat-plugin-openclaw friend greeting skipped friend=${friendUserId} key=${dedupeKey} claimed=${String(claimed)}`,
2614
+ );
2615
+ return;
2616
+ }
2617
+ }
2618
+ void (async () => {
2619
+ let conversationId = "";
2620
+ try {
2621
+ const result = await getConversationApiClient().getDirectConversation(friendUserId);
2622
+ conversationId = result?.conversation?.id?.trim() ?? "";
2623
+ } catch (err) {
2624
+ log?.error?.(
2625
+ `[${accountId}] clawchat-plugin-openclaw friend greeting conversation lookup failed friend=${friendUserId}: ${
2626
+ err instanceof Error ? err.message : String(err)
2627
+ }`,
2628
+ );
2629
+ return;
2630
+ }
2631
+ if (!isValidChatId(conversationId)) {
2632
+ log?.error?.(
2633
+ `[${accountId}] clawchat-plugin-openclaw friend greeting skipped friend=${friendUserId} reason=no_conversation_id`,
2634
+ );
2635
+ return;
2636
+ }
2637
+ log?.info?.(
2638
+ `[${accountId}] clawchat-plugin-openclaw friend greeting dispatch friend=${friendUserId} chat_id=${conversationId}`,
2639
+ );
2640
+ await handleInboundEnvelope(
2641
+ buildFriendGreetingEnvelope({ account, conversationId, friendUserId }),
2642
+ );
2643
+ })().catch((err) => {
2644
+ log?.error?.(
2645
+ `[${accountId}] clawchat-plugin-openclaw friend greeting failed friend=${friendUserId}: ${
2646
+ err instanceof Error ? err.message : String(err)
2647
+ }`,
2648
+ );
2649
+ });
2650
+ };
2530
2651
 
2531
2652
  client.on("notify:signal", (env: Envelope) => {
2532
2653
  // §9.4 reliable system notification. The plugin holds no friend/roster
@@ -2641,6 +2762,9 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
2641
2762
  );
2642
2763
  });
2643
2764
  }
2765
+ if (type === "friend.added") {
2766
+ scheduleFriendGreeting(payload as Record<string, unknown> | undefined);
2767
+ }
2644
2768
  }
2645
2769
  }
2646
2770
  });
@@ -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.9.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