Compare commits

..
Author SHA1 Message Date
Peter Steinberger 43bd219f1d fix: hydrate Slack thread root files (#1479) (thanks @travisirby) 2026-01-23 05:00:59 +00:00
Tak hoffman b65916e0d1 CLI: fix Windows gateway startup 2026-01-23 04:47:01 +00:00
Peter Steinberger 9207840db4 docs: note #1482 in changelog 2026-01-23 04:38:08 +00:00
Peter Steinberger 784468d6c3 fix: harden BlueBubbles voice memos (#1477) (thanks @Nicell) 2026-01-23 04:38:08 +00:00
Clawd 02b5f403db feat(bluebubbles): add asVoice support for voice memos
Add asVoice parameter to sendBlueBubblesAttachment that converts audio
to iMessage voice memo format (Opus CAF at 48kHz) and sets isAudioMessage
flag in the BlueBubbles API.

This follows the existing asVoice pattern used by Telegram.

- Convert audio to Opus CAF format using ffmpeg when asVoice=true
- Set isAudioMessage=true in BlueBubbles attachment API
- Pass asVoice through action handler and media-send
2026-01-23 04:34:19 +00:00
Peter Steinberger 5d0d9e6323 feat: refine onboarding hatch flow 2026-01-23 04:32:23 +00:00
16 changed files with 624 additions and 93 deletions

No files matched your search

+3 -1
View File
@@ -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).
+2 -1
View File
@@ -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: {
+13 -1
View File
@@ -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,
});
+107 -1
View File
@@ -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"');
});
});
+58 -2
View File
@@ -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);
+3
View File
@@ -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,
-1
View File
@@ -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;
-16
View File
@@ -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", () => {
-1
View File
@@ -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" ||
+13
View File
@@ -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
View File
@@ -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();
});
});
+81 -40
View File
@@ -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,
+85 -9
View File
@@ -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.",
);
}
+6 -7
View File
@@ -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;