@newbase-clawchat/openclaw-clawchat 2026.4.29 → 2026.5.4

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.
Files changed (49) hide show
  1. package/README.md +37 -11
  2. package/dist/index.js +27 -0
  3. package/dist/src/api-client.js +156 -0
  4. package/dist/src/api-types.js +17 -0
  5. package/dist/src/buffered-stream.js +177 -0
  6. package/dist/src/channel.js +200 -0
  7. package/dist/src/client.js +176 -0
  8. package/dist/src/commands.js +35 -0
  9. package/dist/src/config.js +226 -0
  10. package/dist/src/inbound.js +133 -0
  11. package/dist/src/login.runtime.js +132 -0
  12. package/dist/src/media-runtime.js +85 -0
  13. package/dist/src/message-mapper.js +82 -0
  14. package/dist/src/outbound.js +181 -0
  15. package/dist/src/protocol.js +38 -0
  16. package/dist/src/reply-dispatcher.js +440 -0
  17. package/dist/src/runtime.js +288 -0
  18. package/dist/src/streaming.js +65 -0
  19. package/dist/src/tools-schema.js +38 -0
  20. package/dist/src/tools.js +287 -0
  21. package/openclaw.plugin.json +21 -0
  22. package/package.json +27 -5
  23. package/skills/clawchat-activate/SKILL.md +18 -9
  24. package/src/buffered-stream.test.ts +10 -0
  25. package/src/buffered-stream.ts +6 -6
  26. package/src/channel.outbound.test.ts +3 -3
  27. package/src/channel.test.ts +7 -1
  28. package/src/channel.ts +27 -8
  29. package/src/client.test.ts +8 -1
  30. package/src/client.ts +11 -10
  31. package/src/commands.test.ts +6 -0
  32. package/src/commands.ts +5 -1
  33. package/src/config.test.ts +47 -0
  34. package/src/config.ts +28 -5
  35. package/src/inbound.test.ts +4 -1
  36. package/src/inbound.ts +11 -10
  37. package/src/login.runtime.test.ts +36 -0
  38. package/src/login.runtime.ts +57 -27
  39. package/src/manifest.test.ts +156 -30
  40. package/src/outbound.test.ts +6 -5
  41. package/src/outbound.ts +8 -7
  42. package/src/plugin-entry.test.ts +7 -1
  43. package/src/reply-dispatcher.test.ts +418 -3
  44. package/src/reply-dispatcher.ts +137 -12
  45. package/src/runtime.ts +1 -0
  46. package/src/streaming.test.ts +12 -9
  47. package/src/streaming.ts +6 -6
  48. package/src/tools.test.ts +81 -18
  49. package/src/tools.ts +65 -74
@@ -0,0 +1,132 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ import { createOpenclawClawlingApiClient } from "./api-client.js";
3
+ import { ClawlingApiError } from "./api-types.js";
4
+ import { CHANNEL_ID, mergeOpenclawClawchatToolAllow, resolveOpenclawClawlingAccount, } from "./config.js";
5
+ /**
6
+ * Platform tag sent to `/v1/agents/connect`. Identifies the host of this
7
+ * agent runtime — openclaw's bundled clawchat channel.
8
+ */
9
+ export const AGENTS_CONNECT_PLATFORM = "openclaw";
10
+ /**
11
+ * Agent type tag sent to `/v1/agents/connect`. The clawchat channel is
12
+ * always a bot; humans don't log in through this flow.
13
+ */
14
+ export const AGENTS_CONNECT_TYPE = "clawbot";
15
+ /**
16
+ * Prompt the operator for an invite code.
17
+ *
18
+ * The prompt text is emitted via `runtime.log` so it flows through the
19
+ * same openclaw logging pipeline every other channel plugin uses (no
20
+ * clack frame, no raw-mode takeover, no TTY detection). Input is read
21
+ * from stdin with `node:readline` — Enter-to-submit is plain language in
22
+ * the prompt so any upstream LLM / orchestrator reading the log stream
23
+ * knows a newline is expected, and the behavior is identical under a
24
+ * TTY, piped stdin, or a test harness.
25
+ */
26
+ async function promptInviteCodeFromStdin(runtime) {
27
+ runtime.log("Please enter your ClawChat invite code (press Enter to submit):");
28
+ let rl;
29
+ try {
30
+ rl = createInterface({ input: process.stdin, output: process.stdout });
31
+ const answer = await rl.question("> ");
32
+ return answer.trim();
33
+ }
34
+ finally {
35
+ rl?.close();
36
+ }
37
+ }
38
+ function buildLoginConfig(cfg, result) {
39
+ const channels = (cfg.channels ?? {});
40
+ const existing = (channels[CHANNEL_ID] ?? {});
41
+ const nextSection = {
42
+ ...existing,
43
+ enabled: true,
44
+ token: result.access_token,
45
+ userId: result.agent.user_id,
46
+ };
47
+ if (result.refresh_token) {
48
+ nextSection.refreshToken = result.refresh_token;
49
+ }
50
+ return mergeOpenclawClawchatToolAllow({
51
+ ...cfg,
52
+ channels: { ...channels, [CHANNEL_ID]: nextSection },
53
+ });
54
+ }
55
+ async function persistLoginConfig(params, result) {
56
+ if (params.mutateConfigFile) {
57
+ await params.mutateConfigFile({
58
+ afterWrite: { mode: "auto" },
59
+ mutate(draft) {
60
+ Object.assign(draft, buildLoginConfig(draft, result));
61
+ },
62
+ });
63
+ return;
64
+ }
65
+ if (params.persistConfig) {
66
+ await params.persistConfig(buildLoginConfig(params.cfg, result));
67
+ return;
68
+ }
69
+ throw new Error("openclaw-clawchat: mutateConfigFile is required to persist login credentials");
70
+ }
71
+ /**
72
+ * Run the invite-code credential exchange used by `clawchat_activate`,
73
+ * `openclaw channels add --channel openclaw-clawchat --token <invite-code>`,
74
+ * and `openclaw channels login --channel openclaw-clawchat`:
75
+ * 1. Read the existing channel section; require `baseUrl` to be set so we
76
+ * know which server to hit.
77
+ * 2. Prompt the user for an invite code on stdin.
78
+ * 3. POST it to `${baseUrl}/v1/agents/connect`.
79
+ * 4. Write the returned `websocket_url` / `token` / `user_id` back into
80
+ * the config so subsequent Gateway runs pick them up.
81
+ *
82
+ * Errors surface with clear messages (missing baseUrl, empty invite,
83
+ * server-side rejection) so the caller can relay them to the operator.
84
+ */
85
+ export async function runOpenclawClawlingLogin(params) {
86
+ const { cfg, runtime } = params;
87
+ // `resolveOpenclawClawlingAccount` falls back to the built-in
88
+ // `DEFAULT_BASE_URL` / `DEFAULT_WEBSOCKET_URL` when the operator has not
89
+ // overridden them, so login works without a prior `openclaw channels setup --channel openclaw-clawchat`.
90
+ const account = resolveOpenclawClawlingAccount(cfg);
91
+ const inviteCode = (await (params.readInviteCode ?? (() => promptInviteCodeFromStdin(runtime)))()).trim();
92
+ if (!inviteCode) {
93
+ throw new Error("Login aborted: invite code is required.");
94
+ }
95
+ const apiClient = (params.apiClientFactory ?? createOpenclawClawlingApiClient)({
96
+ baseUrl: account.baseUrl,
97
+ // Pre-login we may not have a token yet. Send the current one (or empty)
98
+ // — the server should accept an unauthenticated invite-code exchange.
99
+ token: account.token || "",
100
+ });
101
+ runtime.log("Verifying invite code …");
102
+ let result;
103
+ try {
104
+ result = await apiClient.agentsConnect({
105
+ code: inviteCode,
106
+ platform: AGENTS_CONNECT_PLATFORM,
107
+ type: AGENTS_CONNECT_TYPE,
108
+ });
109
+ }
110
+ catch (err) {
111
+ if (err instanceof ClawlingApiError) {
112
+ throw new Error(`agents/connect failed (${err.kind}): ${err.message}`);
113
+ }
114
+ throw err;
115
+ }
116
+ if (!result?.access_token || !result?.agent?.user_id) {
117
+ throw new Error(`agents/connect response missing required fields (access_token / agent.user_id): ${JSON.stringify(result)}`);
118
+ }
119
+ const tokenPreview = redactToken(result.access_token);
120
+ runtime.log(`Updating config: channels.${CHANNEL_ID}.token=${tokenPreview} userId=${result.agent.user_id}${result.refresh_token ? " refreshToken=***" : ""} …`);
121
+ await persistLoginConfig(params, result);
122
+ runtime.log(`Config file updated.`);
123
+ runtime.log(`openclaw-clawchat login succeeded (user_id=${result.agent.user_id}, nickname=${result.agent.nickname || "-"}).`);
124
+ }
125
+ /** Shortens a token for display logs without revealing the full secret. */
126
+ function redactToken(token) {
127
+ if (!token)
128
+ return "(empty)";
129
+ if (token.length <= 8)
130
+ return "***";
131
+ return `${token.slice(0, 4)}…${token.slice(-4)}`;
132
+ }
@@ -0,0 +1,85 @@
1
+ export function inferMediaKindFromMime(mime) {
2
+ if (!mime)
3
+ return "file";
4
+ if (mime.startsWith("image/"))
5
+ return "image";
6
+ if (mime.startsWith("audio/"))
7
+ return "audio";
8
+ if (mime.startsWith("video/"))
9
+ return "video";
10
+ return "file";
11
+ }
12
+ const DEFAULT_MEDIA_MAX_BYTES = 20 * 1024 * 1024;
13
+ /**
14
+ * Fetch each remote URL via the shared media runtime, persist to a local
15
+ * cache, and return the list of local paths.
16
+ *
17
+ * Failed items are logged at info level and dropped; the remaining items
18
+ * still resolve so a single bad URL doesn't blow up the whole inbound turn.
19
+ */
20
+ export async function fetchInboundMedia(items, ctx) {
21
+ if (items.length === 0)
22
+ return [];
23
+ const maxBytes = ctx.maxBytes ?? DEFAULT_MEDIA_MAX_BYTES;
24
+ const paths = [];
25
+ for (const item of items) {
26
+ try {
27
+ const fetched = await ctx.runtime.channel.media.fetchRemoteMedia({
28
+ url: item.url,
29
+ maxBytes,
30
+ });
31
+ const saved = await ctx.runtime.channel.media.saveMediaBuffer(fetched.buffer, fetched.contentType ?? item.mime, "openclaw-clawchat-inbound", maxBytes, item.name ?? fetched.fileName);
32
+ paths.push(saved.path);
33
+ }
34
+ catch (err) {
35
+ ctx.log?.info?.(`openclaw-clawchat inbound media skipped: ${item.url} (${err instanceof Error ? err.message : String(err)})`);
36
+ }
37
+ }
38
+ return paths;
39
+ }
40
+ /**
41
+ * Upload each URL (remote or local path) to /media/upload via the api
42
+ * client and return a fragment ready to splice into `body.fragments`.
43
+ *
44
+ * Uses the host runtime's `runtime.media.loadWebMedia`, so local-root
45
+ * enforcement and media-loading policy stay aligned with the current
46
+ * OpenClaw runtime instead of a directly imported helper.
47
+ *
48
+ * Single-upload failures log at error and are dropped; the remaining
49
+ * fragments still come back so a partially-failing batch still sends the
50
+ * working media.
51
+ */
52
+ export async function uploadOutboundMedia(urls, ctx) {
53
+ if (urls.length === 0)
54
+ return [];
55
+ const maxBytes = ctx.maxBytes ?? DEFAULT_MEDIA_MAX_BYTES;
56
+ const out = [];
57
+ for (const url of urls) {
58
+ try {
59
+ const loaded = await ctx.runtime.media.loadWebMedia(url, {
60
+ maxBytes,
61
+ ...(ctx.mediaLocalRoots && ctx.mediaLocalRoots.length > 0
62
+ ? { localRoots: ctx.mediaLocalRoots }
63
+ : {}),
64
+ });
65
+ const uploaded = await ctx.apiClient.uploadMedia({
66
+ buffer: loaded.buffer,
67
+ filename: loaded.fileName ?? "upload.bin",
68
+ mime: loaded.contentType,
69
+ });
70
+ const fragment = {
71
+ kind: inferMediaKindFromMime(uploaded.mime),
72
+ url: uploaded.url,
73
+ mime: uploaded.mime,
74
+ size: uploaded.size,
75
+ };
76
+ if (loaded.fileName)
77
+ fragment.name = loaded.fileName;
78
+ out.push(fragment);
79
+ }
80
+ catch (err) {
81
+ ctx.log?.error?.(`openclaw-clawchat outbound media upload failed: ${url} (${err instanceof Error ? err.message : String(err)})`);
82
+ }
83
+ }
84
+ return out;
85
+ }
@@ -0,0 +1,82 @@
1
+ const MEDIA_KINDS = new Set(["image", "file", "audio", "video"]);
2
+ function isMediaKind(kind) {
3
+ return MEDIA_KINDS.has(kind);
4
+ }
5
+ function renderMediaPlaceholder(fragment) {
6
+ const kind = String(fragment.kind ?? "");
7
+ const url = typeof fragment.url === "string" ? fragment.url : "";
8
+ if (!url)
9
+ return "";
10
+ const name = typeof fragment.name === "string" && fragment.name.trim()
11
+ ? fragment.name
12
+ : kind === "image"
13
+ ? "image"
14
+ : kind === "audio"
15
+ ? "audio"
16
+ : kind === "video"
17
+ ? "video"
18
+ : "file";
19
+ return kind === "image" ? `![${name}](${url})` : `[${name}](${url})`;
20
+ }
21
+ export function fragmentsToText(fragments, opts = {}) {
22
+ const fallback = opts.mentionFallbackIds ?? [];
23
+ let fallbackCursor = 0;
24
+ const parts = fragments.map((fragment) => {
25
+ const f = fragment;
26
+ if (f.kind === "text" && typeof f.text === "string") {
27
+ return f.text;
28
+ }
29
+ if (f.kind === "mention") {
30
+ const display = typeof f.display === "string" ? f.display : undefined;
31
+ if (display && display.trim())
32
+ return display;
33
+ const id = typeof f.user_id === "string" ? f.user_id : undefined;
34
+ if (id && id.trim())
35
+ return `@${id}`;
36
+ const fallbackId = fallback[fallbackCursor++];
37
+ if (fallbackId)
38
+ return `@${fallbackId}`;
39
+ return "";
40
+ }
41
+ if (typeof f.kind === "string" && isMediaKind(f.kind)) {
42
+ return renderMediaPlaceholder(f);
43
+ }
44
+ return "";
45
+ });
46
+ return parts.join("").trim();
47
+ }
48
+ export function textToFragments(text) {
49
+ if (!text || !text.trim())
50
+ return [];
51
+ return [{ kind: "text", text }];
52
+ }
53
+ /**
54
+ * Extract media fragments from a body (image/file/audio/video). Skips
55
+ * entries missing `url`. Preserves all optional metadata fields the
56
+ * SDK passes through (mime/size/width/height/duration/name).
57
+ */
58
+ export function extractMediaFragments(fragments) {
59
+ const out = [];
60
+ for (const fragment of fragments) {
61
+ const f = fragment;
62
+ if (typeof f.kind !== "string" || !isMediaKind(f.kind))
63
+ continue;
64
+ if (typeof f.url !== "string" || !f.url)
65
+ continue;
66
+ const item = { kind: f.kind, url: f.url };
67
+ if (typeof f.name === "string")
68
+ item.name = f.name;
69
+ if (typeof f.mime === "string")
70
+ item.mime = f.mime;
71
+ if (typeof f.size === "number")
72
+ item.size = f.size;
73
+ if (typeof f.width === "number")
74
+ item.width = f.width;
75
+ if (typeof f.height === "number")
76
+ item.height = f.height;
77
+ if (typeof f.duration === "number")
78
+ item.duration = f.duration;
79
+ out.push(item);
80
+ }
81
+ return out;
82
+ }
@@ -0,0 +1,181 @@
1
+ import { createAttachedChannelResultAdapter, } from "openclaw/plugin-sdk/channel-send-result";
2
+ import { chunkMarkdownText } from "openclaw/plugin-sdk/reply-runtime";
3
+ import { createOpenclawClawlingApiClient } from "./api-client.js";
4
+ import { CHANNEL_ID, resolveOpenclawClawlingAccount } from "./config.js";
5
+ import { textToFragments } from "./message-mapper.js";
6
+ import { uploadOutboundMedia } from "./media-runtime.js";
7
+ import { getOpenclawClawlingClient, getOpenclawClawlingRuntime, waitForOpenclawClawlingClient, } from "./runtime.js";
8
+ /**
9
+ * Parse an agent-initiated outbound recipient string into the new-protocol
10
+ * `chat_id` + `chat_type` pair.
11
+ *
12
+ * Accepted forms (case-insensitive prefix):
13
+ * - `cc:{chat_id}` → direct
14
+ * - `clawchat:{chat_id}` → direct
15
+ * - `openclaw-clawchat:{chat_id}` → direct
16
+ * - `cc:direct:{chat_id}` → direct
17
+ * - `cc:group:{chat_id}` → group
18
+ * - `clawchat:direct:{chat_id}` → direct
19
+ * - `clawchat:group:{chat_id}` → group
20
+ * - `openclaw-clawchat:direct:{chat_id}` → direct
21
+ * - `openclaw-clawchat:group:{chat_id}` → group
22
+ * - bare `{chat_id}` → direct (backward compat)
23
+ */
24
+ export function parseOpenclawRecipient(to) {
25
+ const raw = (to ?? "").trim();
26
+ if (!raw)
27
+ throw new Error("openclaw-clawchat: outbound `to` is empty");
28
+ const firstColon = raw.indexOf(":");
29
+ if (firstColon < 0)
30
+ return { chatId: raw, chatType: "direct" };
31
+ const scheme = raw.slice(0, firstColon).toLowerCase();
32
+ const rest = raw.slice(firstColon + 1);
33
+ if (scheme !== "cc" && scheme !== "clawchat" && scheme !== CHANNEL_ID) {
34
+ return { chatId: raw, chatType: "direct" };
35
+ }
36
+ const secondColon = rest.indexOf(":");
37
+ if (secondColon >= 0) {
38
+ const typeToken = rest.slice(0, secondColon).toLowerCase();
39
+ const chatId = rest.slice(secondColon + 1).trim();
40
+ if ((typeToken === "direct" || typeToken === "group") && chatId) {
41
+ return { chatId, chatType: typeToken };
42
+ }
43
+ }
44
+ const chatId = rest.trim();
45
+ if (!chatId)
46
+ throw new Error(`openclaw-clawchat: missing chat_id in "${to}"`);
47
+ return { chatId, chatType: "direct" };
48
+ }
49
+ export async function sendOpenclawClawlingText(params) {
50
+ const text = (params.text ?? "").trim();
51
+ const richFragments = params.richFragments ?? [];
52
+ const mediaFragments = params.mediaFragments ?? [];
53
+ if (!text && richFragments.length === 0 && mediaFragments.length === 0) {
54
+ params.log?.info?.(`[${params.account.accountId}] openclaw-clawchat outbound suppressed: empty text and no media`);
55
+ return null;
56
+ }
57
+ const mentions = params.mentions ?? [];
58
+ const textFragments = text ? textToFragments(text) : [];
59
+ // Cast at the SDK boundary: each MediaItem object is structurally compatible
60
+ // with one of the SDK's narrow Fragment members (ImageFragment / FileFragment /
61
+ // AudioFragment / VideoFragment) based on its runtime `kind`. The wide local
62
+ // shape lets us build a single uniform array without a per-kind switch.
63
+ const fragments = [...textFragments, ...richFragments, ...mediaFragments];
64
+ const useReply = params.replyCtx && mediaFragments.length === 0;
65
+ if (params.replyCtx && mediaFragments.length > 0) {
66
+ params.log?.info?.(`[${params.account.accountId}] openclaw-clawchat replyCtx + media: downgraded to sendMessage`);
67
+ }
68
+ let ack;
69
+ let mode;
70
+ if (useReply && params.replyCtx) {
71
+ mode = "reply";
72
+ ack = await params.client.replyMessage({
73
+ chat_id: params.to.chatId,
74
+ mode: "normal",
75
+ replyTo: {
76
+ msgId: params.replyCtx.replyToMessageId,
77
+ senderId: params.replyCtx.replyPreviewChatId ?? params.replyCtx.replyPreviewSenderId,
78
+ nickName: params.replyCtx.replyPreviewNickName,
79
+ fragments: [{ kind: "text", text: params.replyCtx.replyPreviewText }],
80
+ },
81
+ body: { fragments },
82
+ context: { mentions },
83
+ });
84
+ }
85
+ else {
86
+ mode = "send";
87
+ ack = await params.client.sendMessage({
88
+ chat_id: params.to.chatId,
89
+ mode: "normal",
90
+ body: { fragments },
91
+ context: { mentions, reply: null },
92
+ });
93
+ }
94
+ params.log?.info?.(`[${params.account.accountId}] openclaw-clawchat outbound mode=${mode} msg=${ack.payload.message_id} text_len=${text.length} media=${mediaFragments.length} trace=${ack.trace_id}`);
95
+ return {
96
+ messageId: ack.payload.message_id,
97
+ acceptedAt: ack.payload.accepted_at,
98
+ };
99
+ }
100
+ /**
101
+ * Send one or more media fragments (image / file / audio / video) to the
102
+ * given target, with an optional text caption.
103
+ *
104
+ * Validates that mediaFragments is non-empty (returns null + info log
105
+ * otherwise) and delegates to {@link sendOpenclawClawlingText} for the
106
+ * actual envelope construction. Reuses the existing replyCtx-downgrade,
107
+ * ack backfill, and log shape.
108
+ */
109
+ export async function sendOpenclawClawlingMedia(params) {
110
+ if (params.mediaFragments.length === 0) {
111
+ params.log?.info?.(`[${params.account.accountId}] openclaw-clawchat sendMedia called with empty mediaFragments; suppressed`);
112
+ return null;
113
+ }
114
+ return await sendOpenclawClawlingText({
115
+ client: params.client,
116
+ account: params.account,
117
+ to: params.to,
118
+ text: params.text ?? "",
119
+ mediaFragments: params.mediaFragments,
120
+ ...(params.replyCtx ? { replyCtx: params.replyCtx } : {}),
121
+ ...(params.mentions ? { mentions: params.mentions } : {}),
122
+ ...(params.log ? { log: params.log } : {}),
123
+ });
124
+ }
125
+ export const openclawClawlingOutbound = {
126
+ deliveryMode: "direct",
127
+ chunker: (text, limit) => chunkMarkdownText(text, limit),
128
+ chunkerMode: "markdown",
129
+ textChunkLimit: 4000,
130
+ ...createAttachedChannelResultAdapter({
131
+ channel: CHANNEL_ID,
132
+ sendText: async ({ cfg, to, text }) => {
133
+ const account = resolveOpenclawClawlingAccount(cfg);
134
+ const client = getOpenclawClawlingClient(account.accountId) ??
135
+ (await waitForOpenclawClawlingClient(account.accountId));
136
+ const result = await sendOpenclawClawlingText({
137
+ client,
138
+ account,
139
+ to: parseOpenclawRecipient(to),
140
+ text,
141
+ });
142
+ return {
143
+ to,
144
+ messageId: result?.messageId ?? `${account.userId}-${Date.now()}`,
145
+ };
146
+ },
147
+ sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots }) => {
148
+ const account = resolveOpenclawClawlingAccount(cfg);
149
+ const client = getOpenclawClawlingClient(account.accountId) ??
150
+ (await waitForOpenclawClawlingClient(account.accountId));
151
+ if (!mediaUrl?.trim()) {
152
+ throw new Error("openclaw-clawchat sendMedia requires mediaUrl");
153
+ }
154
+ const runtime = getOpenclawClawlingRuntime();
155
+ const apiClient = createOpenclawClawlingApiClient({
156
+ baseUrl: account.baseUrl,
157
+ token: account.token,
158
+ userId: account.userId,
159
+ });
160
+ const mediaFragments = await uploadOutboundMedia([mediaUrl.trim()], {
161
+ apiClient,
162
+ runtime,
163
+ ...(mediaLocalRoots ? { mediaLocalRoots } : {}),
164
+ });
165
+ if (mediaFragments.length === 0) {
166
+ throw new Error(`openclaw-clawchat failed to upload media: ${mediaUrl}`);
167
+ }
168
+ const result = await sendOpenclawClawlingMedia({
169
+ client,
170
+ account,
171
+ to: parseOpenclawRecipient(to),
172
+ text,
173
+ mediaFragments,
174
+ });
175
+ return {
176
+ to,
177
+ messageId: result?.messageId ?? `${account.userId}-${Date.now()}`,
178
+ };
179
+ },
180
+ }),
181
+ };
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Local narrow guards for inbound protocol envelopes.
3
+ *
4
+ * The SDK's `message` event hands us `Envelope<unknown>`. Before casting the
5
+ * payload to `DownlinkMessageSendPayload` we run these cheap structural checks
6
+ * so runtime errors surface as skipped messages, not crashes.
7
+ */
8
+ export function isInboundMessagePayload(payload) {
9
+ if (!payload || typeof payload !== "object")
10
+ return false;
11
+ const p = payload;
12
+ if (typeof p.message_id !== "string" || !p.message_id)
13
+ return false;
14
+ if (typeof p.message !== "object" || p.message === null)
15
+ return false;
16
+ const m = p.message;
17
+ if (typeof m.body !== "object" || m.body === null)
18
+ return false;
19
+ const body = m.body;
20
+ if (!Array.isArray(body.fragments))
21
+ return false;
22
+ return true;
23
+ }
24
+ export function hasRenderableText(message) {
25
+ const fragments = message?.body?.fragments ?? [];
26
+ return fragments.some((f) => ((f.kind === "text" &&
27
+ typeof f.text === "string" &&
28
+ f.text.trim().length > 0) ||
29
+ (typeof f.kind === "string" &&
30
+ ["image", "file", "audio", "video"].includes(f.kind) &&
31
+ typeof f.url === "string" &&
32
+ f.url.trim().length > 0)));
33
+ }
34
+ export function isGroupSender(sender) {
35
+ if (!sender || typeof sender !== "object")
36
+ return false;
37
+ return sender.type === "group";
38
+ }