@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/api-client.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import {
2
2
  ClawlingApiError,
3
3
  type AgentAppView,
4
+ type AgentConnectCheckInput,
5
+ type AgentConnectCheckResult,
4
6
  type AgentMetadataPatch,
5
7
  type AgentProfile,
6
8
  type AgentConnectResult,
@@ -17,9 +19,24 @@ import {
17
19
  type UserSearchHit,
18
20
  } from "./api-types.ts";
19
21
  import type { GroupSettings, GroupSettingsFetchResult } from "./group-settings.ts";
22
+ import type { OnboardingReportFields } from "./onboarding-report.ts";
20
23
  import type { PermissionPolicy, PermState } from "./permissions.ts";
21
24
  import { CHANNEL_ID } from "./config.ts";
22
25
 
26
+ /** Params for `agentsConnect` — exchange an invite code for an agent token. */
27
+ export interface AgentsConnectInput {
28
+ /** The invite code entered by the operator. */
29
+ code: string;
30
+ /** Platform the agent is attaching from (e.g. "openclaw"). */
31
+ platform: string;
32
+ /** Agent type tag (e.g. "bot"). */
33
+ type: string;
34
+ /** Existing configured ClawChat user id, when re-activating an account. */
35
+ user_id?: string;
36
+ /** agent_kind / os / lane / wiki_version / matched_install — see onboarding-context.ts */
37
+ context?: Record<string, string>;
38
+ }
39
+
23
40
  export interface PluginReportInput {
24
41
  deviceId: string;
25
42
  platform: string;
@@ -27,10 +44,12 @@ export interface PluginReportInput {
27
44
  agentVersion: string;
28
45
  runtimeName: string;
29
46
  runtimeVersion: string;
47
+ /** Agent-written onboarding facts (`~/clawchat/onboarding.json`); optional. */
48
+ onboarding?: OnboardingReportFields | null;
30
49
  }
31
50
 
32
- export function buildPluginReportBody(input: PluginReportInput): Record<string, string> {
33
- return {
51
+ export function buildPluginReportBody(input: PluginReportInput): Record<string, unknown> {
52
+ const body: Record<string, unknown> = {
34
53
  device_id: input.deviceId,
35
54
  platform: input.platform,
36
55
  plugin_version: input.pluginVersion,
@@ -38,6 +57,12 @@ export function buildPluginReportBody(input: PluginReportInput): Record<string,
38
57
  runtime_name: input.runtimeName,
39
58
  runtime_version: input.runtimeVersion,
40
59
  };
60
+ const ob = input.onboarding;
61
+ if (ob?.wiki_report_id !== undefined) body.wiki_report_id = ob.wiki_report_id;
62
+ if (ob?.capability_tier !== undefined) body.capability_tier = ob.capability_tier;
63
+ if (ob?.capability_ceiling !== undefined) body.capability_ceiling = ob.capability_ceiling;
64
+ if (ob?.capabilities !== undefined) body.capabilities = ob.capabilities;
65
+ return body;
41
66
  }
42
67
 
43
68
  export interface ApiClientOptions {
@@ -133,6 +158,8 @@ export interface OpenclawClawlingApiClient {
133
158
  text: string;
134
159
  }): Promise<{ comment: MomentComment }>;
135
160
  deleteMomentComment(params: { momentId: number; commentId: number }): Promise<{ ok: boolean }>;
161
+ /** Find-or-create the direct conversation with a friend (`POST /v1/conversations/direct`); 19012 when not a friend. */
162
+ getDirectConversation(peerId: string): Promise<{ conversation: { id: string; type: string } }>;
136
163
  getConversation(conversationId: string): Promise<{ conversation: ConversationDetails }>;
137
164
  leaveConversation(conversationId: string): Promise<{ ok: boolean }>;
138
165
  addConversationMember(
@@ -147,19 +174,16 @@ export interface OpenclawClawlingApiClient {
147
174
  uploadMedia(params: { buffer: Buffer; filename: string; mime?: string }): Promise<UploadResult>;
148
175
  /**
149
176
  * Exchange an invite code for an agent token.
150
- * Request body shape: `{ code, platform, type, user_id?, plugin_version? }`.
151
- * `plugin_version` is included when `ApiClientOptions.pluginVersion` is set.
177
+ * Request body shape: `{ code, platform, type, user_id?, plugin_version?, agent_kind?, os?, lane?, wiki_version?, matched_install? }`.
178
+ * `plugin_version` is included when `ApiClientOptions.pluginVersion` is set;
179
+ * `context` is flattened onto the top-level body (see `onboarding-context.ts`).
152
180
  */
153
- agentsConnect(params: {
154
- /** The invite code entered by the operator. */
155
- code: string;
156
- /** Platform the agent is attaching from (e.g. "openclaw"). */
157
- platform: string;
158
- /** Agent type tag (e.g. "bot"). */
159
- type: string;
160
- /** Existing configured ClawChat user id, when re-activating an account. */
161
- user_id?: string;
162
- }): Promise<AgentConnectResult>;
181
+ agentsConnect(params: AgentsConnectInput): Promise<AgentConnectResult>;
182
+ /**
183
+ * POST /v1/agents/connect/check — non-consuming pairability pre-check. Same
184
+ * `X-Device-Id` as `agentsConnect` so the funnel row is keyed identically.
185
+ */
186
+ agentsConnectCheck(input: AgentConnectCheckInput): Promise<AgentConnectCheckResult>;
163
187
  /**
164
188
  * Upload an avatar image via `POST /v1/files/upload-url`. The resulting
165
189
  * `url` is what you then pass to `updateMyProfile({ avatar_url: url })`.
@@ -211,14 +235,25 @@ const CODE_INTERNAL = 1; // CodeInternal — transient.
211
235
  const CODE_BAD_REQUEST = 400; // bad body / device id — permanent (client bug).
212
236
  /**
213
237
  * CodeInvalidRefresh — returned for BOTH a genuinely revoked/invalid refresh
214
- * token AND a single-use refresh token that was already CONSUMED by a prior
215
- * successful rotation (a duplicate-supervisor / concurrent-refresh / stale-store
216
- * race). The stateless `authRefresh` cannot tell the two apart, so it reports
217
- * `permanent`; the stateful `RefreshManager` re-classifies a consumed-rotation
218
- * race back to transient (see §B race) before any auto-logout.
238
+ * token AND a refresh token that was already CONSUMED by a prior successful
239
+ * rotation (a duplicate-supervisor / concurrent-refresh / stale-store race), once
240
+ * the backend's short replay grace window no longer covers it. The stateless
241
+ * `authRefresh` cannot tell the two apart, so it reports `permanent`; the
242
+ * stateful `RefreshManager` re-classifies a consumed-rotation race back to
243
+ * transient (see §B race) before any auto-logout.
219
244
  */
220
245
  export const CODE_INVALID_REFRESH = 10003;
221
246
 
247
+ /**
248
+ * §B attempt deadline — total wall clock for one refresh attempt (request AND
249
+ * response body). A hit aborts the request and is TRANSIENT. Kept below
250
+ * `MIN_REFRESH_INTERVAL_MS` (30s) so the min-interval floor, not the deadline,
251
+ * sets the retry cadence: a retry of the same refresh token then lands inside
252
+ * the backend refresh grace window if the server rotated but the response was
253
+ * lost.
254
+ */
255
+ export const REFRESH_REQUEST_TIMEOUT_MS = 20_000;
256
+
222
257
  /**
223
258
  * §0/§B — call `POST /v1/auth/refresh` to rotate the access+refresh token.
224
259
  *
@@ -228,9 +263,14 @@ export const CODE_INVALID_REFRESH = 10003;
228
263
  * always HTTP 200 — branch on the envelope `code`, NOT on HTTP status. This is
229
264
  * a standalone function (not a method on the token-bearing client) precisely
230
265
  * because no bearer token participates.
266
+ *
267
+ * The whole attempt is bounded by `REFRESH_REQUEST_TIMEOUT_MS` (§B attempt
268
+ * deadline): the request is aborted via its signal, and the deadline is also
269
+ * raced against the attempt so a fetch implementation that ignores the signal
270
+ * cannot hang the caller.
231
271
  */
232
272
  export async function authRefresh(
233
- opts: { baseUrl: string; fetchImpl?: typeof fetch },
273
+ opts: { baseUrl: string; fetchImpl?: typeof fetch; timeoutMs?: number },
234
274
  params: AuthRefreshParams,
235
275
  ): Promise<AuthRefreshResult> {
236
276
  const baseUrl = opts.baseUrl.replace(/\/+$/, "");
@@ -238,6 +278,31 @@ export async function authRefresh(
238
278
  if (!params.refreshToken?.trim()) {
239
279
  return { kind: "permanent", code: CODE_INVALID_REFRESH, message: "missing refresh token" };
240
280
  }
281
+ const timeoutMs = opts.timeoutMs ?? REFRESH_REQUEST_TIMEOUT_MS;
282
+ const controller = new AbortController();
283
+ let timer: ReturnType<typeof setTimeout> | undefined;
284
+ const deadline = new Promise<AuthRefreshResult>((resolve) => {
285
+ timer = setTimeout(() => {
286
+ controller.abort(new Error("refresh request timed out"));
287
+ resolve({ kind: "transient", message: `refresh request timed out after ${timeoutMs}ms` });
288
+ }, timeoutMs);
289
+ });
290
+ try {
291
+ return await Promise.race([
292
+ authRefreshAttempt(baseUrl, fetchImpl, params, controller.signal),
293
+ deadline,
294
+ ]);
295
+ } finally {
296
+ clearTimeout(timer);
297
+ }
298
+ }
299
+
300
+ async function authRefreshAttempt(
301
+ baseUrl: string,
302
+ fetchImpl: typeof fetch,
303
+ params: AuthRefreshParams,
304
+ signal: AbortSignal,
305
+ ): Promise<AuthRefreshResult> {
241
306
  let res: Response;
242
307
  try {
243
308
  res = await fetchImpl(`${baseUrl}/v1/auth/refresh`, {
@@ -248,6 +313,8 @@ export async function authRefresh(
248
313
  "x-device-id": params.deviceId,
249
314
  },
250
315
  body: JSON.stringify({ refresh_token: params.refreshToken.trim() }),
316
+ // Still in effect while the body is read below.
317
+ signal,
251
318
  });
252
319
  } catch (err) {
253
320
  // Network error / timeout / DNS — TRANSIENT (no rotation committed).
@@ -292,8 +359,9 @@ export async function authRefresh(
292
359
  const refreshToken = typeof data.refresh_token === "string" ? data.refresh_token : "";
293
360
  if (!accessToken || !refreshToken) {
294
361
  // Rotation succeeded server-side but the body is malformed — transient so
295
- // we retry; the next attempt will return 10003 (rotation single-use) and
296
- // escalate to permanent (§B transient→permanent).
362
+ // we retry; inside the backend grace window the retry redeems the old
363
+ // token again, after it the retry returns 10003 and escalates to
364
+ // permanent (§B transient→permanent).
297
365
  return { kind: "transient", status: 200, message: "refresh: rotation body incomplete" };
298
366
  }
299
367
  return { kind: "success", accessToken, refreshToken };
@@ -639,6 +707,17 @@ export function createOpenclawClawlingApiClient(opts: ApiClientOptions): Opencla
639
707
  `/v1/moments/${encodeURIComponent(String(params.momentId))}/comments/${encodeURIComponent(String(params.commentId))}`,
640
708
  );
641
709
  },
710
+ async getDirectConversation(peerId): Promise<{ conversation: { id: string; type: string } }> {
711
+ assertNonBlankId(peerId, "getDirectConversation: peerId");
712
+ return await call<{ conversation: { id: string; type: string } }>(
713
+ "POST",
714
+ "/v1/conversations/direct",
715
+ {
716
+ body: JSON.stringify({ peer_id: peerId.trim() }),
717
+ headers: { "content-type": "application/json" },
718
+ },
719
+ );
720
+ },
642
721
  async getConversation(conversationId): Promise<{ conversation: ConversationDetails }> {
643
722
  return await call<{ conversation: ConversationDetails }>(
644
723
  "GET",
@@ -698,7 +777,20 @@ export function createOpenclawClawlingApiClient(opts: ApiClientOptions): Opencla
698
777
  },
699
778
  );
700
779
  },
701
- async agentsConnect({ code: inviteCode, platform, type, user_id: userId }): Promise<AgentConnectResult> {
780
+ async agentsConnectCheck({ code, platform, user_id: userId, context }): Promise<AgentConnectCheckResult> {
781
+ if (!code?.trim()) {
782
+ throw new ClawlingApiError("validation", "agentsConnectCheck: code is required");
783
+ }
784
+ const body: Record<string, string> = { code: code.trim(), platform: platform.trim() };
785
+ if (userId?.trim()) body.user_id = userId.trim();
786
+ if (opts.pluginVersion?.trim()) body.plugin_version = opts.pluginVersion.trim();
787
+ for (const [k, v] of Object.entries(context ?? {})) if (v) body[k] = v;
788
+ return await call<AgentConnectCheckResult>("POST", "/v1/agents/connect/check", {
789
+ headers: { "content-type": "application/json" },
790
+ body: JSON.stringify(body),
791
+ });
792
+ },
793
+ async agentsConnect({ code: inviteCode, platform, type, user_id: userId, context }): Promise<AgentConnectResult> {
702
794
  if (!inviteCode?.trim()) {
703
795
  throw new ClawlingApiError("validation", "agentsConnect: inviteCode is required");
704
796
  }
@@ -719,6 +811,7 @@ export function createOpenclawClawlingApiClient(opts: ApiClientOptions): Opencla
719
811
  if (opts.pluginVersion?.trim()) {
720
812
  body.plugin_version = opts.pluginVersion.trim();
721
813
  }
814
+ for (const [k, v] of Object.entries(context ?? {})) if (v) body[k] = v;
722
815
  return await call<AgentConnectResult>("POST", "/v1/agents/connect", {
723
816
  // `X-Device-Id` is added globally via `authHeaders` on every request.
724
817
  headers: { "content-type": "application/json" },
package/src/api-types.ts CHANGED
@@ -146,6 +146,24 @@ export interface AgentConnectResult {
146
146
  conversation?: AgentConnectConversation;
147
147
  }
148
148
 
149
+ /** Body of POST /v1/agents/connect/check. Same optional telemetry as /connect. */
150
+ export interface AgentConnectCheckInput {
151
+ code: string;
152
+ platform: string;
153
+ user_id?: string;
154
+ /** agent_kind / os / lane / wiki_version / matched_install — see onboarding-context.ts */
155
+ context?: Record<string, string>;
156
+ }
157
+
158
+ /** data of POST /v1/agents/connect/check. Older backends omit `bound_agent`. */
159
+ export interface AgentConnectCheckResult {
160
+ pairable: boolean;
161
+ status: string;
162
+ expires_at?: string;
163
+ user_id_status?: string;
164
+ bound_agent?: boolean;
165
+ }
166
+
149
167
  export type AgentAppView = { id: string; app_id?: string; name: string; url: string };
150
168
 
151
169
  export type ClawlingApiErrorKind =
package/src/config.ts CHANGED
@@ -114,6 +114,8 @@ export type OpenclawClawlingAccountConfig = {
114
114
  richInteractions?: boolean;
115
115
  /** Emit ONE consolidated awareness note to the agent when friend/conversation signals arrive. */
116
116
  awarenessNote?: boolean;
117
+ /** Speak first to a newly added non-owner friend (default true). */
118
+ friendGreeting?: boolean;
117
119
  /** Auto-install the Liveware Sample demo app when no liveware app is registered (default true). */
118
120
  livewareSample?: boolean;
119
121
  reconnect?: OpenclawClawlingReconnectConfig;
@@ -174,6 +176,7 @@ export const openclawClawlingAccountConfigSchema = {
174
176
  forwardToolCalls: { type: "boolean" },
175
177
  richInteractions: { type: "boolean" },
176
178
  awarenessNote: { type: "boolean" },
179
+ friendGreeting: { type: "boolean" },
177
180
  livewareSample: { type: "boolean" },
178
181
  reconnect: {
179
182
  type: "object",
@@ -311,6 +314,7 @@ export type ResolvedOpenclawClawlingAccount = {
311
314
  forwardToolCalls: boolean;
312
315
  richInteractions: boolean;
313
316
  awarenessNote: boolean;
317
+ friendGreeting: boolean;
314
318
  livewareSample: boolean;
315
319
  allowFrom: string[];
316
320
  reconnect: Required<OpenclawClawlingReconnectConfig>;
@@ -649,6 +653,8 @@ export function resolveOpenclawClawlingAccount(
649
653
  typeof channel.richInteractions === "boolean" ? channel.richInteractions : false;
650
654
  const awarenessNote =
651
655
  typeof channel.awarenessNote === "boolean" ? channel.awarenessNote : false;
656
+ const friendGreeting =
657
+ typeof channel.friendGreeting === "boolean" ? channel.friendGreeting : true;
652
658
  const livewareSample =
653
659
  typeof channel.livewareSample === "boolean" ? channel.livewareSample : true;
654
660
 
@@ -678,6 +684,7 @@ export function resolveOpenclawClawlingAccount(
678
684
  forwardToolCalls,
679
685
  richInteractions,
680
686
  awarenessNote,
687
+ friendGreeting,
681
688
  livewareSample,
682
689
  allowFrom: [],
683
690
  reconnect: readReconnect(channel.reconnect),
@@ -0,0 +1,98 @@
1
+ import { ClawlingApiError } from "./api-types.ts";
2
+ import type { OpenclawClawlingApiClient } from "./api-client.ts";
3
+ import { AGENTS_CONNECT_PLATFORM, RECONNECT_GUIDE_URL } from "./onboarding-context.ts";
4
+
5
+ export interface ConnectPreCheck {
6
+ pairable: boolean;
7
+ status: string;
8
+ boundAgent: boolean;
9
+ userIdStatus: string;
10
+ }
11
+
12
+ /**
13
+ * Non-consuming pre-check of a connect code. Records the "checked" funnel
14
+ * stage server-side and tells us whether the code is bound to an existing
15
+ * agent (the reconnect prompt). Any transport-level failure — older backend,
16
+ * rate limit, network — degrades to `null`: the pre-check is telemetry plus a
17
+ * courtesy, never a gate in front of `/connect`.
18
+ */
19
+ export async function preCheckConnectCode(
20
+ client: Pick<OpenclawClawlingApiClient, "agentsConnectCheck">,
21
+ input: { code: string; userId?: string; context?: Record<string, string> },
22
+ log: (message: string) => void,
23
+ ): Promise<ConnectPreCheck | null> {
24
+ try {
25
+ const res = await client.agentsConnectCheck({
26
+ code: input.code,
27
+ platform: AGENTS_CONNECT_PLATFORM,
28
+ ...(input.userId ? { user_id: input.userId } : {}),
29
+ ...(input.context ? { context: input.context } : {}),
30
+ });
31
+ // A 200 with no data says nothing about the code: degrade exactly like an
32
+ // unreachable endpoint (Hermes' `evaluate_precheck({})` does the same).
33
+ if (!res || typeof res !== "object" || Object.keys(res).length === 0) {
34
+ log("Connect-code pre-check returned no data; continuing without it.");
35
+ return null;
36
+ }
37
+ return {
38
+ pairable: res.pairable === true,
39
+ status: typeof res.status === "string" ? res.status : "",
40
+ boundAgent: res.bound_agent === true,
41
+ userIdStatus: typeof res.user_id_status === "string" ? res.user_id_status : "",
42
+ };
43
+ } catch (err) {
44
+ const kind = err instanceof ClawlingApiError ? err.kind : "error";
45
+ log(`Connect-code pre-check unavailable (${kind}); continuing without it.`);
46
+ return null;
47
+ }
48
+ }
49
+
50
+ /** Owner-facing explanation for `pairable: false`. No flags, no minutes, one URL. */
51
+ export function unpairableMessage(pre: ConnectPreCheck): string {
52
+ if (pre.userIdStatus === "owner_mismatch" && pre.boundAgent) {
53
+ // The server cannot tell us whether the bound agent shares this identity's
54
+ // owner, only that it is a different agent — so say exactly that.
55
+ return (
56
+ "This connect code is the reconnect prompt for a different agent than the identity stored here. " +
57
+ "Ask your owner to send the reconnect prompt from THIS agent's chat in the ClawChat app."
58
+ );
59
+ }
60
+ if (pre.userIdStatus === "invalid") {
61
+ return (
62
+ "The identity stored here is not a valid ClawChat user id, so it cannot be restored. " +
63
+ "Activate as a brand-new agent with --new-account."
64
+ );
65
+ }
66
+ if (pre.userIdStatus === "owner_mismatch") {
67
+ return (
68
+ "This connect code belongs to a different ClawChat account than the identity stored here. " +
69
+ "Ask the owner of THIS agent for a code, or activate as a brand-new agent with --new-account."
70
+ );
71
+ }
72
+ switch (pre.status) {
73
+ case "paired":
74
+ return (
75
+ "This connect code was already redeemed. If this agent lost its connection, ask your owner " +
76
+ `to send you the reconnect prompt from the ClawChat app and follow ${RECONNECT_GUIDE_URL}; ` +
77
+ "otherwise ask for a fresh code."
78
+ );
79
+ case "expired":
80
+ case "invalid":
81
+ return `This connect code is ${pre.status}. Ask your owner for a fresh code from the ClawChat app.`;
82
+ default:
83
+ return `This connect code is not pairable (status=${pre.status || "unknown"}). Ask your owner for a fresh code from the ClawChat app.`;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Refusal for a new-identity intent (`--new-account` or the interactive
89
+ * choice) on a bound code: without a user_id, `/connect` restores the bound
90
+ * agent instead of creating one. No flags, no minutes, no URL.
91
+ */
92
+ export function boundCodeNewIdentityMessage(): string {
93
+ return (
94
+ "This is a reconnect code bound to an existing agent, so it cannot create a new agent. " +
95
+ "Ask your owner for a normal connect code from the ClawChat app, or use this reconnect prompt " +
96
+ "on the agent it belongs to."
97
+ );
98
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * First message to a newly added NON-owner friend.
3
+ *
4
+ * `friend.added` used to be a pure awareness event. The server creates the
5
+ * direct conversation inside the friend-accept transaction, but the signal
6
+ * only carries the counterparty `usr_…` — so the runtime resolves the
7
+ * conversation through `POST /v1/conversations/direct` and then feeds ONE
8
+ * synthetic inbound turn (built here) into the normal dispatch path, the same
9
+ * way the activation bootstrap greets the owner.
10
+ *
11
+ * The prompt is deliberately distinct from the owner activation prompt: this
12
+ * reader is a stranger, so "you are connected and ready" makes no sense and
13
+ * the agent must say whose agent it is instead.
14
+ */
15
+ import fs from "node:fs";
16
+ import os from "node:os";
17
+ import path from "node:path";
18
+ import { EVENT, type Envelope } from "./protocol-types.ts";
19
+ import type { ResolvedOpenclawClawlingAccount } from "./config.ts";
20
+
21
+ export const FRIEND_GREETING_FALLBACK = [
22
+ "A ClawChat user has just become your friend. You are now in a direct conversation with them; they are not your owner.",
23
+ "Reply now with one short, friendly greeting message in this conversation: introduce yourself by name, say you are an AI agent acting on behalf of your owner, and invite them to tell you what they need.",
24
+ "Send it as a normal chat reply. Do not write or create any files or notes, and do not call tools just to greet.",
25
+ "Do not share your owner's private information, and do not ask the user for personal information.",
26
+ ].join("\n");
27
+
28
+ // Cross-plugin, user-editable override read lazily so edits apply on the next
29
+ // friend without a restart. Any read failure falls back to the built-in text.
30
+ // Mirrors `buildActivationBootstrapText` (`~/clawchat/greeting.md`).
31
+ export function buildFriendGreetingText(homeDir: string = os.homedir()): string {
32
+ const greetingPath = path.join(homeDir, "clawchat", "friend-greeting.md");
33
+ try {
34
+ const raw = fs.readFileSync(greetingPath);
35
+ const override = new TextDecoder("utf-8", { fatal: true }).decode(raw).trim();
36
+ if (override.length > 0) {
37
+ return override;
38
+ }
39
+ } catch (error) {
40
+ const code = (error as NodeJS.ErrnoException).code;
41
+ if (code !== "ENOENT") {
42
+ console.warn(`clawchat.friend-greeting failed to read override ${greetingPath}:`, error);
43
+ }
44
+ }
45
+ return FRIEND_GREETING_FALLBACK;
46
+ }
47
+
48
+ export interface BuildFriendGreetingEnvelopeParams {
49
+ account: ResolvedOpenclawClawlingAccount;
50
+ /** The resolved direct conversation (`cnv_…`) shared with the new friend. */
51
+ conversationId: string;
52
+ /** The new friend's `usr_…` id — becomes the sender so the turn's session and sender metadata resolve to them. */
53
+ friendUserId: string;
54
+ }
55
+
56
+ /**
57
+ * Synthetic inbound envelope for the friend greeting turn. Same invariant as
58
+ * `buildActivationBootstrapEnvelope`: `conversationId` MUST be a conversation
59
+ * idcode — the agent's reply inherits this chat_id, and anything else is
60
+ * refused at the outbound boundary after a full LLM turn has been spent.
61
+ */
62
+ export function buildFriendGreetingEnvelope(params: BuildFriendGreetingEnvelopeParams): Envelope {
63
+ const { account, conversationId, friendUserId } = params;
64
+ const text = buildFriendGreetingText();
65
+ const now = Date.now();
66
+ return {
67
+ version: "2",
68
+ event: EVENT.MESSAGE_SEND,
69
+ trace_id: `clawchat-plugin-openclaw-friend-greeting-${now}`,
70
+ emitted_at: now,
71
+ chat_id: conversationId,
72
+ chat_type: "direct",
73
+ to: { id: account.userId, type: "direct" },
74
+ sender: { id: friendUserId, type: "direct", nick_name: "" },
75
+ payload: {
76
+ message_id: `clawchat-plugin-openclaw-friend-greeting-${conversationId}-${now}`,
77
+ message_mode: "normal",
78
+ message: {
79
+ body: { fragments: [{ kind: "text", text }] },
80
+ context: { mentions: [], reply: null },
81
+ streaming: {
82
+ status: "static",
83
+ sequence: 0,
84
+ mutation_policy: "sealed",
85
+ started_at: null,
86
+ completed_at: null,
87
+ },
88
+ },
89
+ },
90
+ } as unknown as Envelope;
91
+ }