@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.
- package/README.md +37 -11
- package/dist/index.js +27 -0
- package/dist/src/api-client.js +156 -0
- package/dist/src/api-types.js +17 -0
- package/dist/src/buffered-stream.js +177 -0
- package/dist/src/channel.js +200 -0
- package/dist/src/client.js +176 -0
- package/dist/src/commands.js +35 -0
- package/dist/src/config.js +226 -0
- package/dist/src/inbound.js +133 -0
- package/dist/src/login.runtime.js +132 -0
- package/dist/src/media-runtime.js +85 -0
- package/dist/src/message-mapper.js +82 -0
- package/dist/src/outbound.js +181 -0
- package/dist/src/protocol.js +38 -0
- package/dist/src/reply-dispatcher.js +440 -0
- package/dist/src/runtime.js +288 -0
- package/dist/src/streaming.js +65 -0
- package/dist/src/tools-schema.js +38 -0
- package/dist/src/tools.js +287 -0
- package/openclaw.plugin.json +21 -0
- package/package.json +27 -5
- package/skills/clawchat-activate/SKILL.md +18 -9
- package/src/buffered-stream.test.ts +10 -0
- package/src/buffered-stream.ts +6 -6
- package/src/channel.outbound.test.ts +3 -3
- package/src/channel.test.ts +7 -1
- package/src/channel.ts +27 -8
- package/src/client.test.ts +8 -1
- package/src/client.ts +11 -10
- package/src/commands.test.ts +6 -0
- package/src/commands.ts +5 -1
- package/src/config.test.ts +47 -0
- package/src/config.ts +28 -5
- package/src/inbound.test.ts +4 -1
- package/src/inbound.ts +11 -10
- package/src/login.runtime.test.ts +36 -0
- package/src/login.runtime.ts +57 -27
- package/src/manifest.test.ts +156 -30
- package/src/outbound.test.ts +6 -5
- package/src/outbound.ts +8 -7
- package/src/plugin-entry.test.ts +7 -1
- package/src/reply-dispatcher.test.ts +418 -3
- package/src/reply-dispatcher.ts +137 -12
- package/src/runtime.ts +1 -0
- package/src/streaming.test.ts +12 -9
- package/src/streaming.ts +6 -6
- package/src/tools.test.ts +81 -18
- 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})`;
|
|
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
|
+
}
|