Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43bd219f1d | ||
|
|
b65916e0d1 | ||
|
|
9207840db4 | ||
|
|
784468d6c3 | ||
|
|
02b5f403db | ||
|
|
5d0d9e6323 |
No files matched your search
+3
-1
@@ -9,10 +9,12 @@ Docs: https://docs.clawd.bot
|
||||
- Lobster: allow workflow file args via `argsJson` in the plugin tool. https://docs.clawd.bot/tools/lobster
|
||||
- Agents: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer.
|
||||
- Memory: prevent CLI hangs by deferring vector probes, adding sqlite-vec/embedding timeouts, and showing sync progress early.
|
||||
- BlueBubbles: add `asVoice` support for MP3/CAF voice memos in sendAttachment. (#1477, #1482) Thanks @Nicell.
|
||||
- Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting
|
||||
- Docs: add /model allowlist troubleshooting note. (#1405)
|
||||
- Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky.
|
||||
- UI: show per-session assistant identity in the Control UI. (#1420) Thanks @robbyczgw-cla.
|
||||
- Onboarding: add hatch choice (TUI/Web/Later), token explainer, background dashboard seed on macOS, and showcase link.
|
||||
- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
|
||||
- Signal: add typing indicators and DM read receipts via signal-cli.
|
||||
- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero.
|
||||
@@ -24,7 +26,6 @@ Docs: https://docs.clawd.bot
|
||||
### Fixes
|
||||
- BlueBubbles: stop typing indicator on idle/no-reply. (#1439) Thanks @Nicell.
|
||||
- Message tool: keep path/filePath as-is for send; hydrate buffers only for sendAttachment. (#1444) Thanks @hopyky.
|
||||
- CLI: handle Windows runtime exe argv and pnpm build shims more reliably. (#1480) Thanks @Takhoffman.
|
||||
- Auto-reply: only report a model switch when session state is available. (#1465) Thanks @robbyczgw-cla.
|
||||
- Control UI: resolve local avatar URLs with basePath across injection + identity RPC. (#1457) Thanks @dlauer.
|
||||
- Agents: surface concrete API error details instead of generic AI service errors.
|
||||
@@ -39,6 +40,7 @@ Docs: https://docs.clawd.bot
|
||||
- Agents/TUI: honor user-pinned auth profiles during cooldown and preserve search picker ranking. (#1432) Thanks @tobiasbischoff.
|
||||
- Docs: fix gog auth services example to include docs scope. (#1454) Thanks @zerone0x.
|
||||
- Slack: read thread replies for message reads when threadId is provided (replies-only). (#1450) Thanks @rodrigouroz.
|
||||
- Slack: hydrate thread root attachments for replies and include multi-file context. (#1479) Thanks @travisirby.
|
||||
- macOS: prefer linked channels in gateway summary to avoid false “not linked” status.
|
||||
- Providers: improve GitHub Copilot integration (enterprise support, base URL, and auth flow alignment).
|
||||
|
||||
|
||||
@@ -147,7 +147,8 @@ Available actions:
|
||||
- **addParticipant**: Add someone to a group (`chatGuid`, `address`)
|
||||
- **removeParticipant**: Remove someone from a group (`chatGuid`, `address`)
|
||||
- **leaveGroup**: Leave a group chat (`chatGuid`)
|
||||
- **sendAttachment**: Send media/files (`to`, `buffer`, `filename`)
|
||||
- **sendAttachment**: Send media/files (`to`, `buffer`, `filename`, `asVoice`)
|
||||
- Voice memos: set `asVoice: true` with **MP3** or **CAF** audio to send as an iMessage voice message. BlueBubbles converts MP3 → CAF when sending voice memos.
|
||||
|
||||
### Message IDs (short vs full)
|
||||
Clawdbot may surface *short* message IDs (e.g., `1`, `2`) to save tokens.
|
||||
|
||||
@@ -521,6 +521,42 @@ describe("bluebubblesMessageActions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("passes asVoice through sendAttachment", async () => {
|
||||
const { sendBlueBubblesAttachment } = await import("./attachments.js");
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test-password",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const base64Buffer = Buffer.from("voice").toString("base64");
|
||||
|
||||
await bluebubblesMessageActions.handleAction({
|
||||
action: "sendAttachment",
|
||||
params: {
|
||||
to: "+15551234567",
|
||||
filename: "voice.mp3",
|
||||
buffer: base64Buffer,
|
||||
contentType: "audio/mpeg",
|
||||
asVoice: true,
|
||||
},
|
||||
cfg,
|
||||
accountId: null,
|
||||
});
|
||||
|
||||
expect(sendBlueBubblesAttachment).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filename: "voice.mp3",
|
||||
contentType: "audio/mpeg",
|
||||
asVoice: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when buffer is missing for setGroupIcon", async () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
BLUEBUBBLES_ACTIONS,
|
||||
createActionGate,
|
||||
jsonResult,
|
||||
readBooleanParam,
|
||||
readNumberParam,
|
||||
readReactionParams,
|
||||
readStringParam,
|
||||
@@ -51,6 +50,17 @@ function readMessageText(params: Record<string, unknown>): string | undefined {
|
||||
return readStringParam(params, "text") ?? readStringParam(params, "message");
|
||||
}
|
||||
|
||||
function readBooleanParam(params: Record<string, unknown>, key: string): boolean | undefined {
|
||||
const raw = params[key];
|
||||
if (typeof raw === "boolean") return raw;
|
||||
if (typeof raw === "string") {
|
||||
const trimmed = raw.trim().toLowerCase();
|
||||
if (trimmed === "true") return true;
|
||||
if (trimmed === "false") return false;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Supported action names for BlueBubbles */
|
||||
const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>(BLUEBUBBLES_ACTION_NAMES);
|
||||
|
||||
@@ -356,6 +366,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
const caption = readStringParam(params, "caption");
|
||||
const contentType =
|
||||
readStringParam(params, "contentType") ?? readStringParam(params, "mimeType");
|
||||
const asVoice = readBooleanParam(params, "asVoice");
|
||||
|
||||
// Buffer can come from params.buffer (base64) or params.path (file path)
|
||||
const base64Buffer = readStringParam(params, "buffer");
|
||||
@@ -380,6 +391,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
filename,
|
||||
contentType: contentType ?? undefined,
|
||||
caption: caption ?? undefined,
|
||||
asVoice: asVoice ?? undefined,
|
||||
opts,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { downloadBlueBubblesAttachment } from "./attachments.js";
|
||||
import { downloadBlueBubblesAttachment, sendBlueBubblesAttachment } from "./attachments.js";
|
||||
import type { BlueBubblesAttachment } from "./types.js";
|
||||
|
||||
vi.mock("./accounts.js", () => ({
|
||||
@@ -238,3 +238,109 @@ describe("downloadBlueBubblesAttachment", () => {
|
||||
expect(result.buffer).toEqual(new Uint8Array([1]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendBlueBubblesAttachment", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function decodeBody(body: Uint8Array) {
|
||||
return Buffer.from(body).toString("utf8");
|
||||
}
|
||||
|
||||
it("marks voice memos when asVoice is true and mp3 is provided", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(JSON.stringify({ messageId: "msg-1" })),
|
||||
});
|
||||
|
||||
await sendBlueBubblesAttachment({
|
||||
to: "chat_guid:iMessage;-;+15551234567",
|
||||
buffer: new Uint8Array([1, 2, 3]),
|
||||
filename: "voice.mp3",
|
||||
contentType: "audio/mpeg",
|
||||
asVoice: true,
|
||||
opts: { serverUrl: "http://localhost:1234", password: "test" },
|
||||
});
|
||||
|
||||
const body = mockFetch.mock.calls[0][1]?.body as Uint8Array;
|
||||
const bodyText = decodeBody(body);
|
||||
expect(bodyText).toContain('name="isAudioMessage"');
|
||||
expect(bodyText).toContain("true");
|
||||
expect(bodyText).toContain('filename="voice.mp3"');
|
||||
});
|
||||
|
||||
it("normalizes mp3 filenames for voice memos", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(JSON.stringify({ messageId: "msg-2" })),
|
||||
});
|
||||
|
||||
await sendBlueBubblesAttachment({
|
||||
to: "chat_guid:iMessage;-;+15551234567",
|
||||
buffer: new Uint8Array([1, 2, 3]),
|
||||
filename: "voice",
|
||||
contentType: "audio/mpeg",
|
||||
asVoice: true,
|
||||
opts: { serverUrl: "http://localhost:1234", password: "test" },
|
||||
});
|
||||
|
||||
const body = mockFetch.mock.calls[0][1]?.body as Uint8Array;
|
||||
const bodyText = decodeBody(body);
|
||||
expect(bodyText).toContain('filename="voice.mp3"');
|
||||
expect(bodyText).toContain('name="voice.mp3"');
|
||||
});
|
||||
|
||||
it("throws when asVoice is true but media is not audio", async () => {
|
||||
await expect(
|
||||
sendBlueBubblesAttachment({
|
||||
to: "chat_guid:iMessage;-;+15551234567",
|
||||
buffer: new Uint8Array([1, 2, 3]),
|
||||
filename: "image.png",
|
||||
contentType: "image/png",
|
||||
asVoice: true,
|
||||
opts: { serverUrl: "http://localhost:1234", password: "test" },
|
||||
}),
|
||||
).rejects.toThrow("voice messages require audio");
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws when asVoice is true but audio is not mp3 or caf", async () => {
|
||||
await expect(
|
||||
sendBlueBubblesAttachment({
|
||||
to: "chat_guid:iMessage;-;+15551234567",
|
||||
buffer: new Uint8Array([1, 2, 3]),
|
||||
filename: "voice.wav",
|
||||
contentType: "audio/wav",
|
||||
asVoice: true,
|
||||
opts: { serverUrl: "http://localhost:1234", password: "test" },
|
||||
}),
|
||||
).rejects.toThrow("require mp3 or caf");
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sanitizes filenames before sending", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(JSON.stringify({ messageId: "msg-3" })),
|
||||
});
|
||||
|
||||
await sendBlueBubblesAttachment({
|
||||
to: "chat_guid:iMessage;-;+15551234567",
|
||||
buffer: new Uint8Array([1, 2, 3]),
|
||||
filename: "../evil.mp3",
|
||||
contentType: "audio/mpeg",
|
||||
opts: { serverUrl: "http://localhost:1234", password: "test" },
|
||||
});
|
||||
|
||||
const body = mockFetch.mock.calls[0][1]?.body as Uint8Array;
|
||||
const bodyText = decodeBody(body);
|
||||
expect(bodyText).toContain('filename="evil.mp3"');
|
||||
expect(bodyText).toContain('name="evil.mp3"');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||
import { resolveChatGuidForTarget } from "./send.js";
|
||||
@@ -19,6 +20,30 @@ export type BlueBubblesAttachmentOpts = {
|
||||
};
|
||||
|
||||
const DEFAULT_ATTACHMENT_MAX_BYTES = 8 * 1024 * 1024;
|
||||
const AUDIO_MIME_MP3 = new Set(["audio/mpeg", "audio/mp3"]);
|
||||
const AUDIO_MIME_CAF = new Set(["audio/x-caf", "audio/caf"]);
|
||||
|
||||
function sanitizeFilename(input: string | undefined, fallback: string): string {
|
||||
const trimmed = input?.trim() ?? "";
|
||||
const base = trimmed ? path.basename(trimmed) : "";
|
||||
return base || fallback;
|
||||
}
|
||||
|
||||
function ensureExtension(filename: string, extension: string, fallbackBase: string): string {
|
||||
const currentExt = path.extname(filename);
|
||||
if (currentExt.toLowerCase() === extension) return filename;
|
||||
const base = currentExt ? filename.slice(0, -currentExt.length) : filename;
|
||||
return `${base || fallbackBase}${extension}`;
|
||||
}
|
||||
|
||||
function resolveVoiceInfo(filename: string, contentType?: string) {
|
||||
const normalizedType = contentType?.trim().toLowerCase();
|
||||
const extension = path.extname(filename).toLowerCase();
|
||||
const isMp3 = extension === ".mp3" || (normalizedType ? AUDIO_MIME_MP3.has(normalizedType) : false);
|
||||
const isCaf = extension === ".caf" || (normalizedType ? AUDIO_MIME_CAF.has(normalizedType) : false);
|
||||
const isAudio = isMp3 || isCaf || Boolean(normalizedType?.startsWith("audio/"));
|
||||
return { isAudio, isMp3, isCaf };
|
||||
}
|
||||
|
||||
function resolveAccount(params: BlueBubblesAttachmentOpts) {
|
||||
const account = resolveBlueBubblesAccount({
|
||||
@@ -104,6 +129,7 @@ function extractMessageId(payload: unknown): string {
|
||||
/**
|
||||
* Send an attachment via BlueBubbles API.
|
||||
* Supports sending media files (images, videos, audio, documents) to a chat.
|
||||
* When asVoice is true, expects MP3/CAF audio and marks it as an iMessage voice memo.
|
||||
*/
|
||||
export async function sendBlueBubblesAttachment(params: {
|
||||
to: string;
|
||||
@@ -113,12 +139,37 @@ export async function sendBlueBubblesAttachment(params: {
|
||||
caption?: string;
|
||||
replyToMessageGuid?: string;
|
||||
replyToPartIndex?: number;
|
||||
asVoice?: boolean;
|
||||
opts?: BlueBubblesAttachmentOpts;
|
||||
}): Promise<SendBlueBubblesAttachmentResult> {
|
||||
const { to, buffer, filename, contentType, caption, replyToMessageGuid, replyToPartIndex, opts = {} } =
|
||||
params;
|
||||
const { to, caption, replyToMessageGuid, replyToPartIndex, asVoice, opts = {} } = params;
|
||||
let { buffer, filename, contentType } = params;
|
||||
const wantsVoice = asVoice === true;
|
||||
const fallbackName = wantsVoice ? "Audio Message" : "attachment";
|
||||
filename = sanitizeFilename(filename, fallbackName);
|
||||
contentType = contentType?.trim() || undefined;
|
||||
const { baseUrl, password } = resolveAccount(opts);
|
||||
|
||||
// Validate voice memo format when requested (BlueBubbles converts MP3 -> CAF when isAudioMessage).
|
||||
const isAudioMessage = wantsVoice;
|
||||
if (isAudioMessage) {
|
||||
const voiceInfo = resolveVoiceInfo(filename, contentType);
|
||||
if (!voiceInfo.isAudio) {
|
||||
throw new Error("BlueBubbles voice messages require audio media (mp3 or caf).");
|
||||
}
|
||||
if (voiceInfo.isMp3) {
|
||||
filename = ensureExtension(filename, ".mp3", fallbackName);
|
||||
contentType = contentType ?? "audio/mpeg";
|
||||
} else if (voiceInfo.isCaf) {
|
||||
filename = ensureExtension(filename, ".caf", fallbackName);
|
||||
contentType = contentType ?? "audio/x-caf";
|
||||
} else {
|
||||
throw new Error(
|
||||
"BlueBubbles voice messages require mp3 or caf audio (convert before sending).",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const target = resolveSendTarget(to);
|
||||
const chatGuid = await resolveChatGuidForTarget({
|
||||
baseUrl,
|
||||
@@ -170,6 +221,11 @@ export async function sendBlueBubblesAttachment(params: {
|
||||
addField("tempGuid", `temp-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`);
|
||||
addField("method", "private-api");
|
||||
|
||||
// Add isAudioMessage flag for voice memos
|
||||
if (isAudioMessage) {
|
||||
addField("isAudioMessage", "true");
|
||||
}
|
||||
|
||||
const trimmedReplyTo = replyToMessageGuid?.trim();
|
||||
if (trimmedReplyTo) {
|
||||
addField("selectedMessageGuid", trimmedReplyTo);
|
||||
|
||||
@@ -59,6 +59,7 @@ export async function sendBlueBubblesMedia(params: {
|
||||
caption?: string;
|
||||
replyToId?: string | null;
|
||||
accountId?: string;
|
||||
asVoice?: boolean;
|
||||
}) {
|
||||
const {
|
||||
cfg,
|
||||
@@ -71,6 +72,7 @@ export async function sendBlueBubblesMedia(params: {
|
||||
caption,
|
||||
replyToId,
|
||||
accountId,
|
||||
asVoice,
|
||||
} = params;
|
||||
const core = getBlueBubblesRuntime();
|
||||
const maxBytes = resolveChannelMediaMaxBytes({
|
||||
@@ -146,6 +148,7 @@ export async function sendBlueBubblesMedia(params: {
|
||||
filename: resolvedFilename ?? "attachment",
|
||||
contentType: resolvedContentType ?? undefined,
|
||||
replyToMessageGuid,
|
||||
asVoice,
|
||||
opts: {
|
||||
cfg,
|
||||
accountId,
|
||||
|
||||
@@ -116,7 +116,6 @@ if (!shouldBuild()) {
|
||||
} else {
|
||||
logRunner("Building TypeScript (dist is stale).");
|
||||
const pnpmArgs = ["exec", compiler, ...projectArgs];
|
||||
// On Windows, pnpm is a .cmd shim, so use cmd.exe for reliable resolution.
|
||||
const buildCmd = process.platform === "win32" ? "cmd.exe" : "pnpm";
|
||||
const buildArgs =
|
||||
process.platform === "win32" ? ["/d", "/s", "/c", "pnpm", ...pnpmArgs] : pnpmArgs;
|
||||
|
||||
@@ -78,16 +78,6 @@ describe("argv helpers", () => {
|
||||
});
|
||||
expect(nodeArgv).toEqual(["node", "clawdbot", "status"]);
|
||||
|
||||
const nodeExeArgv = buildParseArgv({
|
||||
programName: "clawdbot",
|
||||
rawArgs: ["C:\\\\Program Files\\\\nodejs\\\\Node.EXE", "clawdbot", "status"],
|
||||
});
|
||||
expect(nodeExeArgv).toEqual([
|
||||
"C:\\\\Program Files\\\\nodejs\\\\Node.EXE",
|
||||
"clawdbot",
|
||||
"status",
|
||||
]);
|
||||
|
||||
const directArgv = buildParseArgv({
|
||||
programName: "clawdbot",
|
||||
rawArgs: ["clawdbot", "status"],
|
||||
@@ -99,12 +89,6 @@ describe("argv helpers", () => {
|
||||
rawArgs: ["bun", "src/entry.ts", "status"],
|
||||
});
|
||||
expect(bunArgv).toEqual(["bun", "src/entry.ts", "status"]);
|
||||
|
||||
const bunExeArgv = buildParseArgv({
|
||||
programName: "clawdbot",
|
||||
rawArgs: ["C:\\\\bun\\\\bun.exe", "src\\\\entry.ts", "status"],
|
||||
});
|
||||
expect(bunExeArgv).toEqual(["C:\\\\bun\\\\bun.exe", "src\\\\entry.ts", "status"]);
|
||||
});
|
||||
|
||||
it("builds parse argv from fallback args", () => {
|
||||
|
||||
@@ -95,7 +95,6 @@ export function buildParseArgv(params: {
|
||||
? baseArgv.slice(1)
|
||||
: baseArgv;
|
||||
const executable = (normalizedArgv[0]?.split(/[/\\]/).pop() ?? "").toLowerCase();
|
||||
// Normalize Windows `.exe` runtimes (node.exe/bun.exe) for runtime detection.
|
||||
const looksLikeNode =
|
||||
normalizedArgv.length >= 2 &&
|
||||
(executable === "node" ||
|
||||
|
||||
@@ -217,6 +217,19 @@ export async function openUrl(url: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function openUrlInBackground(url: string): Promise<boolean> {
|
||||
if (process.platform !== "darwin") return false;
|
||||
const resolved = await resolveBrowserOpenCommand();
|
||||
if (!resolved.argv || resolved.command !== "open") return false;
|
||||
const command = ["open", "-g", url];
|
||||
try {
|
||||
await runCommandWithTimeout(command, { timeoutMs: 5_000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureWorkspaceAndSessions(
|
||||
workspaceDir: string,
|
||||
runtime: RuntimeEnv,
|
||||
|
||||
+29
-13
@@ -5,17 +5,28 @@ import { fetchRemoteMedia } from "../../media/fetch.js";
|
||||
import { saveMediaBuffer } from "../../media/store.js";
|
||||
import type { SlackFile } from "../types.js";
|
||||
|
||||
export async function resolveSlackMedia(params: {
|
||||
files?: SlackFile[];
|
||||
token: string;
|
||||
maxBytes: number;
|
||||
}): Promise<{
|
||||
export type SlackResolvedMedia = {
|
||||
path: string;
|
||||
contentType?: string;
|
||||
placeholder: string;
|
||||
} | null> {
|
||||
};
|
||||
|
||||
export function resolveSlackFilePlaceholder(files?: SlackFile[]): string | undefined {
|
||||
if (!files || files.length === 0) return undefined;
|
||||
const named = files.find((file) => file?.name?.trim());
|
||||
if (named?.name) return `[Slack file: ${named.name}]`;
|
||||
return "[Slack file]";
|
||||
}
|
||||
|
||||
export async function resolveSlackMediaList(params: {
|
||||
files?: SlackFile[];
|
||||
token: string;
|
||||
maxBytes: number;
|
||||
}): Promise<SlackResolvedMedia[]> {
|
||||
const files = params.files ?? [];
|
||||
const resolved: SlackResolvedMedia[] = [];
|
||||
for (const file of files) {
|
||||
if (file.size && file.size > params.maxBytes) continue;
|
||||
const url = file.url_private_download ?? file.url_private;
|
||||
if (!url) continue;
|
||||
try {
|
||||
@@ -37,22 +48,23 @@ export async function resolveSlackMedia(params: {
|
||||
params.maxBytes,
|
||||
);
|
||||
const label = fetched.fileName ?? file.name;
|
||||
return {
|
||||
resolved.push({
|
||||
path: saved.path,
|
||||
contentType: saved.contentType,
|
||||
placeholder: label ? `[Slack file: ${label}]` : "[Slack file]",
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
// Ignore download failures and fall through to the next file.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export type SlackThreadStarter = {
|
||||
text: string;
|
||||
text?: string;
|
||||
userId?: string;
|
||||
ts?: string;
|
||||
files?: SlackFile[];
|
||||
};
|
||||
|
||||
const THREAD_STARTER_CACHE = new Map<string, SlackThreadStarter>();
|
||||
@@ -71,14 +83,18 @@ export async function resolveSlackThreadStarter(params: {
|
||||
ts: params.threadTs,
|
||||
limit: 1,
|
||||
inclusive: true,
|
||||
})) as { messages?: Array<{ text?: string; user?: string; ts?: string }> };
|
||||
})) as {
|
||||
messages?: Array<{ text?: string; user?: string; ts?: string; files?: SlackFile[] }>;
|
||||
};
|
||||
const message = response?.messages?.[0];
|
||||
const text = (message?.text ?? "").trim();
|
||||
if (!message || !text) return null;
|
||||
const hasFiles = Boolean(message?.files && message.files.length > 0);
|
||||
if (!message || (!text && !hasFiles)) return null;
|
||||
const starter: SlackThreadStarter = {
|
||||
text,
|
||||
text: text || undefined,
|
||||
userId: message.user,
|
||||
ts: message.ts,
|
||||
files: message.files,
|
||||
};
|
||||
THREAD_STARTER_CACHE.set(cacheKey, starter);
|
||||
return starter;
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../media.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../media.js")>("../media.js");
|
||||
return {
|
||||
...actual,
|
||||
resolveSlackMediaList: vi.fn(),
|
||||
resolveSlackThreadStarter: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import type { App } from "@slack/bolt";
|
||||
|
||||
import type { ClawdbotConfig } from "../../../config/config.js";
|
||||
import type { RuntimeEnv } from "../../../runtime.js";
|
||||
import type { ResolvedSlackAccount } from "../../accounts.js";
|
||||
import type { SlackMessageEvent } from "../../types.js";
|
||||
import { createSlackMonitorContext } from "../context.js";
|
||||
import { resolveSlackMediaList, resolveSlackThreadStarter } from "../media.js";
|
||||
import { prepareSlackMessage } from "./prepare.js";
|
||||
|
||||
const account: ResolvedSlackAccount = {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
botTokenSource: "config",
|
||||
appTokenSource: "config",
|
||||
config: {},
|
||||
} as ResolvedSlackAccount;
|
||||
|
||||
const createContext = () => {
|
||||
const slackCtx = createSlackMonitorContext({
|
||||
cfg: {
|
||||
agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/clawd" } },
|
||||
channels: { slack: { enabled: true } },
|
||||
} as ClawdbotConfig,
|
||||
accountId: "default",
|
||||
botToken: "token",
|
||||
app: { client: {} } as App,
|
||||
runtime: {} as RuntimeEnv,
|
||||
botUserId: "B1",
|
||||
teamId: "T1",
|
||||
apiAppId: "A1",
|
||||
historyLimit: 0,
|
||||
sessionScope: "per-sender",
|
||||
mainKey: "main",
|
||||
dmEnabled: true,
|
||||
dmPolicy: "open",
|
||||
allowFrom: [],
|
||||
groupDmEnabled: true,
|
||||
groupDmChannels: [],
|
||||
defaultRequireMention: true,
|
||||
groupPolicy: "open",
|
||||
useAccessGroups: false,
|
||||
reactionMode: "off",
|
||||
reactionAllowlist: [],
|
||||
replyToMode: "off",
|
||||
threadHistoryScope: "thread",
|
||||
threadInheritParent: false,
|
||||
slashCommand: {
|
||||
enabled: false,
|
||||
name: "clawd",
|
||||
sessionPrefix: "slack:slash",
|
||||
ephemeral: true,
|
||||
},
|
||||
textLimit: 4000,
|
||||
ackReactionScope: "off",
|
||||
mediaMaxBytes: 1024,
|
||||
removeAckAfterReply: false,
|
||||
});
|
||||
slackCtx.resolveChannelName = async () => ({ name: "general", type: "channel" });
|
||||
slackCtx.resolveUserName = async () => ({ name: "Alice" });
|
||||
return slackCtx;
|
||||
};
|
||||
|
||||
const mediaMock = vi.mocked(resolveSlackMediaList);
|
||||
const starterMock = vi.mocked(resolveSlackThreadStarter);
|
||||
|
||||
beforeEach(() => {
|
||||
mediaMock.mockReset();
|
||||
starterMock.mockReset();
|
||||
mediaMock.mockResolvedValue([]);
|
||||
starterMock.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
describe("prepareSlackMessage thread media", () => {
|
||||
it("hydrates root files for thread replies without attachments", async () => {
|
||||
const ctx = createContext();
|
||||
starterMock.mockResolvedValueOnce({
|
||||
text: "",
|
||||
userId: "U2",
|
||||
ts: "171234.000",
|
||||
files: [{ name: "root.pdf" }],
|
||||
});
|
||||
mediaMock.mockResolvedValueOnce([]).mockResolvedValueOnce([
|
||||
{
|
||||
path: "/tmp/root.pdf",
|
||||
contentType: "application/pdf",
|
||||
placeholder: "[Slack file: root.pdf]",
|
||||
},
|
||||
]);
|
||||
|
||||
const message: SlackMessageEvent = {
|
||||
type: "message",
|
||||
channel: "C1",
|
||||
channel_type: "channel",
|
||||
text: "",
|
||||
user: "U1",
|
||||
ts: "171234.111",
|
||||
thread_ts: "171234.000",
|
||||
} as SlackMessageEvent;
|
||||
|
||||
const prepared = await prepareSlackMessage({
|
||||
ctx,
|
||||
account,
|
||||
message,
|
||||
opts: { source: "message", wasMentioned: true },
|
||||
});
|
||||
|
||||
expect(prepared).not.toBeNull();
|
||||
expect(prepared?.ctxPayload.RawBody).toBe("[Slack file: root.pdf]");
|
||||
expect(prepared?.ctxPayload.MediaPath).toBe("/tmp/root.pdf");
|
||||
expect(prepared?.ctxPayload.MediaPaths).toEqual(["/tmp/root.pdf"]);
|
||||
});
|
||||
|
||||
it("emits MediaPaths for multiple attachments", async () => {
|
||||
const ctx = createContext();
|
||||
mediaMock.mockResolvedValueOnce([
|
||||
{
|
||||
path: "/tmp/a.png",
|
||||
contentType: "image/png",
|
||||
placeholder: "[Slack file: a.png]",
|
||||
},
|
||||
{
|
||||
path: "/tmp/b.pdf",
|
||||
contentType: "application/pdf",
|
||||
placeholder: "[Slack file: b.pdf]",
|
||||
},
|
||||
]);
|
||||
|
||||
const message: SlackMessageEvent = {
|
||||
type: "message",
|
||||
channel: "C1",
|
||||
channel_type: "channel",
|
||||
text: "hi",
|
||||
user: "U1",
|
||||
ts: "171234.111",
|
||||
files: [{ name: "a.png" }, { name: "b.pdf" }],
|
||||
} as SlackMessageEvent;
|
||||
|
||||
const prepared = await prepareSlackMessage({
|
||||
ctx,
|
||||
account,
|
||||
message,
|
||||
opts: { source: "message", wasMentioned: true },
|
||||
});
|
||||
|
||||
expect(prepared?.ctxPayload.MediaPath).toBe("/tmp/a.png");
|
||||
expect(prepared?.ctxPayload.MediaPaths).toEqual(["/tmp/a.png", "/tmp/b.pdf"]);
|
||||
expect(prepared?.ctxPayload.MediaTypes).toEqual(["image/png", "application/pdf"]);
|
||||
expect(prepared?.ctxPayload.MediaUrls).toEqual(["/tmp/a.png", "/tmp/b.pdf"]);
|
||||
});
|
||||
|
||||
it("keeps file-only messages when downloads fail", async () => {
|
||||
const ctx = createContext();
|
||||
mediaMock.mockResolvedValueOnce([]);
|
||||
|
||||
const message: SlackMessageEvent = {
|
||||
type: "message",
|
||||
channel: "C1",
|
||||
channel_type: "channel",
|
||||
text: "",
|
||||
user: "U1",
|
||||
ts: "171234.111",
|
||||
files: [{ name: "doc.txt" }],
|
||||
} as SlackMessageEvent;
|
||||
|
||||
const prepared = await prepareSlackMessage({
|
||||
ctx,
|
||||
account,
|
||||
message,
|
||||
opts: { source: "message", wasMentioned: true },
|
||||
});
|
||||
|
||||
expect(prepared).not.toBeNull();
|
||||
expect(prepared?.ctxPayload.RawBody).toBe("[Slack file: doc.txt]");
|
||||
expect(prepared?.ctxPayload.MediaPath).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -39,7 +39,11 @@ import { resolveSlackAllowListMatch, resolveSlackUserAllowed } from "../allow-li
|
||||
import { resolveSlackEffectiveAllowFrom } from "../auth.js";
|
||||
import { resolveSlackChannelConfig } from "../channel-config.js";
|
||||
import { normalizeSlackChannelType, type SlackMonitorContext } from "../context.js";
|
||||
import { resolveSlackMedia, resolveSlackThreadStarter } from "../media.js";
|
||||
import {
|
||||
resolveSlackFilePlaceholder,
|
||||
resolveSlackMediaList,
|
||||
resolveSlackThreadStarter,
|
||||
} from "../media.js";
|
||||
|
||||
import type { PreparedSlackMessage } from "./types.js";
|
||||
|
||||
@@ -290,11 +294,7 @@ export async function prepareSlackMessage(params: {
|
||||
ctx.logger.info({ channel: message.channel, reason: "no-mention" }, "skipping channel message");
|
||||
if (ctx.historyLimit > 0) {
|
||||
const pendingText = (message.text ?? "").trim();
|
||||
const fallbackFile = message.files?.[0]?.name
|
||||
? `[Slack file: ${message.files[0].name}]`
|
||||
: message.files?.length
|
||||
? "[Slack file]"
|
||||
: "";
|
||||
const fallbackFile = resolveSlackFilePlaceholder(message.files) ?? "";
|
||||
const pendingBody = pendingText || fallbackFile;
|
||||
if (pendingBody) {
|
||||
recordPendingHistoryEntry({
|
||||
@@ -313,13 +313,80 @@ export async function prepareSlackMessage(params: {
|
||||
return null;
|
||||
}
|
||||
|
||||
const media = await resolveSlackMedia({
|
||||
const roomLabel = channelName ? `#${channelName}` : `#${message.channel}`;
|
||||
const envelopeOptions = resolveEnvelopeFormatOptions(ctx.cfg);
|
||||
|
||||
const mediaList = await resolveSlackMediaList({
|
||||
files: message.files,
|
||||
token: ctx.botToken,
|
||||
maxBytes: ctx.mediaMaxBytes,
|
||||
});
|
||||
const rawBody = (message.text ?? "").trim() || media?.placeholder || "";
|
||||
const messageFilePlaceholder = resolveSlackFilePlaceholder(message.files);
|
||||
|
||||
let threadStarter: Awaited<ReturnType<typeof resolveSlackThreadStarter>> = null;
|
||||
let threadStarterBody: string | undefined;
|
||||
let threadLabel: string | undefined;
|
||||
let threadStarterMediaList: Awaited<ReturnType<typeof resolveSlackMediaList>> = [];
|
||||
if (isThreadReply && threadTs) {
|
||||
threadStarter = await resolveSlackThreadStarter({
|
||||
channelId: message.channel,
|
||||
threadTs,
|
||||
client: ctx.app.client,
|
||||
});
|
||||
if (threadStarter?.text) {
|
||||
const starterUser = threadStarter.userId
|
||||
? await ctx.resolveUserName(threadStarter.userId)
|
||||
: null;
|
||||
const starterName = starterUser?.name ?? threadStarter.userId ?? "Unknown";
|
||||
const starterWithId = `${threadStarter.text}\n[slack message id: ${
|
||||
threadStarter.ts ?? threadTs
|
||||
} channel: ${message.channel}]`;
|
||||
threadStarterBody = formatThreadStarterEnvelope({
|
||||
channel: "Slack",
|
||||
author: starterName,
|
||||
timestamp: threadStarter.ts ? Math.round(Number(threadStarter.ts) * 1000) : undefined,
|
||||
body: starterWithId,
|
||||
envelope: envelopeOptions,
|
||||
});
|
||||
const snippet = threadStarter.text.replace(/\s+/g, " ").slice(0, 80);
|
||||
threadLabel = `Slack thread ${roomLabel}${snippet ? `: ${snippet}` : ""}`;
|
||||
} else {
|
||||
threadLabel = `Slack thread ${roomLabel}`;
|
||||
}
|
||||
|
||||
if (!message.files?.length && threadStarter?.files?.length) {
|
||||
threadStarterMediaList = await resolveSlackMediaList({
|
||||
files: threadStarter.files,
|
||||
token: ctx.botToken,
|
||||
maxBytes: ctx.mediaMaxBytes,
|
||||
});
|
||||
if (threadStarterMediaList.length > 0) {
|
||||
logVerbose(
|
||||
`slack: hydrated thread starter files (${threadStarterMediaList.length}) from root message`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const threadStarterFilePlaceholder =
|
||||
!message.files?.length && threadStarter?.files?.length
|
||||
? resolveSlackFilePlaceholder(threadStarter.files)
|
||||
: undefined;
|
||||
|
||||
const effectiveMediaList = mediaList.length > 0 ? mediaList : threadStarterMediaList;
|
||||
const effectiveMedia = effectiveMediaList[0];
|
||||
|
||||
const rawBody =
|
||||
(message.text ?? "").trim() ||
|
||||
messageFilePlaceholder ||
|
||||
threadStarterFilePlaceholder ||
|
||||
effectiveMedia?.placeholder ||
|
||||
"";
|
||||
if (!rawBody) return null;
|
||||
const mediaPaths = effectiveMediaList.map((entry) => entry.path);
|
||||
const mediaTypes = effectiveMediaList
|
||||
.map((entry) => entry.contentType)
|
||||
.filter(Boolean) as string[];
|
||||
|
||||
const ackReaction = resolveAckReaction(cfg, route.agentId);
|
||||
const ackReactionValue = ackReaction ?? "";
|
||||
@@ -353,7 +420,6 @@ export async function prepareSlackMessage(params: {
|
||||
)
|
||||
: null;
|
||||
|
||||
const roomLabel = channelName ? `#${channelName}` : `#${message.channel}`;
|
||||
const preview = rawBody.replace(/\s+/g, " ").slice(0, 160);
|
||||
const inboundLabel = isDirectMessage
|
||||
? `Slack DM from ${senderName}`
|
||||
@@ -380,7 +446,6 @@ export async function prepareSlackMessage(params: {
|
||||
const storePath = resolveStorePath(ctx.cfg.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
const envelopeOptions = resolveEnvelopeFormatOptions(ctx.cfg);
|
||||
const previousTimestamp = readSessionUpdatedAt({
|
||||
storePath,
|
||||
sessionKey: route.sessionKey,
|
||||
@@ -431,33 +496,6 @@ export async function prepareSlackMessage(params: {
|
||||
].filter((entry): entry is string => Boolean(entry));
|
||||
const groupSystemPrompt =
|
||||
systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined;
|
||||
|
||||
let threadStarterBody: string | undefined;
|
||||
let threadLabel: string | undefined;
|
||||
if (isThreadReply && threadTs) {
|
||||
const starter = await resolveSlackThreadStarter({
|
||||
channelId: message.channel,
|
||||
threadTs,
|
||||
client: ctx.app.client,
|
||||
});
|
||||
if (starter?.text) {
|
||||
const starterUser = starter.userId ? await ctx.resolveUserName(starter.userId) : null;
|
||||
const starterName = starterUser?.name ?? starter.userId ?? "Unknown";
|
||||
const starterWithId = `${starter.text}\n[slack message id: ${starter.ts ?? threadTs} channel: ${message.channel}]`;
|
||||
threadStarterBody = formatThreadStarterEnvelope({
|
||||
channel: "Slack",
|
||||
author: starterName,
|
||||
timestamp: starter.ts ? Math.round(Number(starter.ts) * 1000) : undefined,
|
||||
body: starterWithId,
|
||||
envelope: envelopeOptions,
|
||||
});
|
||||
const snippet = starter.text.replace(/\s+/g, " ").slice(0, 80);
|
||||
threadLabel = `Slack thread ${roomLabel}${snippet ? `: ${snippet}` : ""}`;
|
||||
} else {
|
||||
threadLabel = `Slack thread ${roomLabel}`;
|
||||
}
|
||||
}
|
||||
|
||||
const ctxPayload = finalizeInboundContext({
|
||||
Body: combinedBody,
|
||||
RawBody: rawBody,
|
||||
@@ -483,9 +521,12 @@ export async function prepareSlackMessage(params: {
|
||||
ThreadLabel: threadLabel,
|
||||
Timestamp: message.ts ? Math.round(Number(message.ts) * 1000) : undefined,
|
||||
WasMentioned: isRoomish ? effectiveWasMentioned : undefined,
|
||||
MediaPath: media?.path,
|
||||
MediaType: media?.contentType,
|
||||
MediaUrl: media?.path,
|
||||
MediaPath: effectiveMedia?.path,
|
||||
MediaType: effectiveMedia?.contentType,
|
||||
MediaUrl: effectiveMedia?.path,
|
||||
MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
|
||||
MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined,
|
||||
MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
|
||||
CommandAuthorized: commandAuthorized,
|
||||
OriginatingChannel: "slack" as const,
|
||||
OriginatingTo: slackTo,
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
detectBrowserOpenSupport,
|
||||
formatControlUiSshHint,
|
||||
openUrl,
|
||||
openUrlInBackground,
|
||||
probeGatewayReachable,
|
||||
waitForGatewayReachable,
|
||||
resolveControlUiLinks,
|
||||
@@ -282,6 +283,11 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
|
||||
"Control UI",
|
||||
);
|
||||
|
||||
let controlUiOpened = false;
|
||||
let controlUiOpenHint: string | undefined;
|
||||
let seededInBackground = false;
|
||||
let hatchChoice: "tui" | "web" | "later" | null = null;
|
||||
|
||||
if (!opts.skipUi && gatewayProbe.ok) {
|
||||
if (hasBootstrap) {
|
||||
await prompter.note(
|
||||
@@ -293,11 +299,27 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
|
||||
].join("\n"),
|
||||
"Start TUI (best option!)",
|
||||
);
|
||||
const wantsTui = await prompter.confirm({
|
||||
message: "Do you want to hatch your bot now?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (wantsTui) {
|
||||
await prompter.note(
|
||||
[
|
||||
"Gateway token: shared auth for the Gateway + Control UI.",
|
||||
"Stored in: ~/.clawdbot/clawdbot.json (gateway.auth.token) or CLAWDBOT_GATEWAY_TOKEN.",
|
||||
"Web UI stores a copy in this browser's localStorage (clawdbot.control.settings.v1).",
|
||||
`Get the tokenized link anytime: ${formatCliCommand("clawdbot dashboard --no-open")}`,
|
||||
].join("\n"),
|
||||
"Token",
|
||||
);
|
||||
|
||||
hatchChoice = (await prompter.select({
|
||||
message: "How do you want to hatch your bot?",
|
||||
options: [
|
||||
{ value: "tui", label: "Hatch in TUI (recommended)" },
|
||||
{ value: "web", label: "Open the Web UI" },
|
||||
{ value: "later", label: "Do this later" },
|
||||
],
|
||||
initialValue: "tui",
|
||||
})) as "tui" | "web" | "later";
|
||||
|
||||
if (hatchChoice === "tui") {
|
||||
await runTui({
|
||||
url: links.wsUrl,
|
||||
token: settings.authMode === "token" ? settings.gatewayToken : undefined,
|
||||
@@ -306,6 +328,52 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
|
||||
deliver: false,
|
||||
message: "Wake up, my friend!",
|
||||
});
|
||||
if (settings.authMode === "token" && settings.gatewayToken) {
|
||||
seededInBackground = await openUrlInBackground(authedUrl);
|
||||
}
|
||||
if (seededInBackground) {
|
||||
await prompter.note(
|
||||
`Web UI seeded in the background. Open later with: ${formatCliCommand(
|
||||
"clawdbot dashboard --no-open",
|
||||
)}`,
|
||||
"Web UI",
|
||||
);
|
||||
}
|
||||
} else if (hatchChoice === "web") {
|
||||
const browserSupport = await detectBrowserOpenSupport();
|
||||
if (browserSupport.ok) {
|
||||
controlUiOpened = await openUrl(authedUrl);
|
||||
if (!controlUiOpened) {
|
||||
controlUiOpenHint = formatControlUiSshHint({
|
||||
port: settings.port,
|
||||
basePath: controlUiBasePath,
|
||||
token: settings.gatewayToken,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
controlUiOpenHint = formatControlUiSshHint({
|
||||
port: settings.port,
|
||||
basePath: controlUiBasePath,
|
||||
token: settings.gatewayToken,
|
||||
});
|
||||
}
|
||||
await prompter.note(
|
||||
[
|
||||
`Dashboard link (with token): ${authedUrl}`,
|
||||
controlUiOpened
|
||||
? "Opened in your browser. Keep that tab to control Clawdbot."
|
||||
: "Copy/paste this URL in a browser on this machine to control Clawdbot.",
|
||||
controlUiOpenHint,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
"Dashboard ready",
|
||||
);
|
||||
} else {
|
||||
await prompter.note(
|
||||
`When you're ready: ${formatCliCommand("clawdbot dashboard --no-open")}`,
|
||||
"Later",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const browserSupport = await detectBrowserOpenSupport();
|
||||
@@ -342,9 +410,10 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
|
||||
);
|
||||
|
||||
const shouldOpenControlUi =
|
||||
!opts.skipUi && settings.authMode === "token" && Boolean(settings.gatewayToken);
|
||||
let controlUiOpened = false;
|
||||
let controlUiOpenHint: string | undefined;
|
||||
!opts.skipUi &&
|
||||
settings.authMode === "token" &&
|
||||
Boolean(settings.gatewayToken) &&
|
||||
hatchChoice === null;
|
||||
if (shouldOpenControlUi) {
|
||||
const browserSupport = await detectBrowserOpenSupport();
|
||||
if (browserSupport.ok) {
|
||||
@@ -406,9 +475,16 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
|
||||
"Web search (optional)",
|
||||
);
|
||||
|
||||
await prompter.note(
|
||||
'What now: https://clawd.bot/showcase ("What People Are Building").',
|
||||
"What now",
|
||||
);
|
||||
|
||||
await prompter.outro(
|
||||
controlUiOpened
|
||||
? "Onboarding complete. Dashboard opened with your token; keep that tab to control Clawdbot."
|
||||
: "Onboarding complete. Use the tokenized dashboard link above to control Clawdbot.",
|
||||
: seededInBackground
|
||||
? "Onboarding complete. Web UI seeded in the background; open it anytime with the tokenized link above."
|
||||
: "Onboarding complete. Use the tokenized dashboard link above to control Clawdbot.",
|
||||
);
|
||||
}
|
||||
@@ -177,19 +177,19 @@ describe("runOnboardingWizard", () => {
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-onboard-"));
|
||||
await fs.writeFile(path.join(workspaceDir, DEFAULT_BOOTSTRAP_FILENAME), "{}");
|
||||
|
||||
const confirm: WizardPrompter["confirm"] = vi.fn(async (opts) => {
|
||||
if (opts.message === "Do you want to hatch your bot now?") return true;
|
||||
return opts.initialValue ?? false;
|
||||
const select: WizardPrompter["select"] = vi.fn(async (opts) => {
|
||||
if (opts.message === "How do you want to hatch your bot?") return "tui";
|
||||
return "quickstart";
|
||||
});
|
||||
|
||||
const prompter: WizardPrompter = {
|
||||
intro: vi.fn(async () => {}),
|
||||
outro: vi.fn(async () => {}),
|
||||
note: vi.fn(async () => {}),
|
||||
select: vi.fn(async () => "quickstart"),
|
||||
select,
|
||||
multiselect: vi.fn(async () => []),
|
||||
text: vi.fn(async () => ""),
|
||||
confirm,
|
||||
confirm: vi.fn(async () => false),
|
||||
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
|
||||
};
|
||||
|
||||
@@ -267,8 +267,7 @@ describe("runOnboardingWizard", () => {
|
||||
|
||||
const calls = (note as unknown as { mock: { calls: unknown[][] } }).mock.calls;
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
const lastCall = calls[calls.length - 1];
|
||||
expect(lastCall?.[1]).toBe("Web search (optional)");
|
||||
expect(calls.some((call) => call?.[1] === "Web search (optional)")).toBe(true);
|
||||
} finally {
|
||||
if (prevBraveKey === undefined) {
|
||||
delete process.env.BRAVE_API_KEY;
|
||||
|
||||
Reference in new issue
Block a user