Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b24bf7ac1 | ||
|
|
a61c8a2317 | ||
|
|
c717258a8f | ||
|
|
7b40d1b261 | ||
|
|
2c10c601a8 | ||
|
|
578ac9f1a9 | ||
|
|
2accb47e4d | ||
|
|
b65916e0d1 | ||
|
|
9207840db4 | ||
|
|
784468d6c3 | ||
|
|
02b5f403db | ||
|
|
5d0d9e6323 |
No files matched your search
+4
-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
|
- 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.
|
- 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.
|
- 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 troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting
|
||||||
- Docs: add /model allowlist troubleshooting note. (#1405)
|
- Docs: add /model allowlist troubleshooting note. (#1405)
|
||||||
- Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky.
|
- 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.
|
- 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).
|
- 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.
|
- Signal: add typing indicators and DM read receipts via signal-cli.
|
||||||
- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero.
|
- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero.
|
||||||
@@ -24,7 +26,6 @@ Docs: https://docs.clawd.bot
|
|||||||
### Fixes
|
### Fixes
|
||||||
- BlueBubbles: stop typing indicator on idle/no-reply. (#1439) Thanks @Nicell.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- Agents: surface concrete API error details instead of generic AI service errors.
|
||||||
@@ -32,6 +33,8 @@ Docs: https://docs.clawd.bot
|
|||||||
- Agents: make OpenAI sessions image-sanitize-only; gate tool-id/repair sanitization by provider.
|
- Agents: make OpenAI sessions image-sanitize-only; gate tool-id/repair sanitization by provider.
|
||||||
- Doctor: honor CLAWDBOT_GATEWAY_TOKEN for auth checks and security audit token reuse. (#1448) Thanks @azade-c.
|
- Doctor: honor CLAWDBOT_GATEWAY_TOKEN for auth checks and security audit token reuse. (#1448) Thanks @azade-c.
|
||||||
- Agents: make tool summaries more readable and only show optional params when set.
|
- Agents: make tool summaries more readable and only show optional params when set.
|
||||||
|
- Agents: honor SOUL.md guidance even when the file is nested or path-qualified. (#1434) Thanks @neooriginal.
|
||||||
|
- Matrix (plugin): persist m.direct for resolved DMs and harden room fallback. (#1436) Thanks @sibbl.
|
||||||
- CLI: prefer `~` for home paths in output.
|
- CLI: prefer `~` for home paths in output.
|
||||||
- Mattermost (plugin): enforce pairing/allowlist gating, keep @username targets, and clarify plugin-only docs. (#1428) Thanks @damoahdominic.
|
- Mattermost (plugin): enforce pairing/allowlist gating, keep @username targets, and clarify plugin-only docs. (#1428) Thanks @damoahdominic.
|
||||||
- Agents: centralize transcript sanitization in the runner; keep <final> tags and error turns intact.
|
- Agents: centralize transcript sanitization in the runner; keep <final> tags and error turns intact.
|
||||||
|
|||||||
@@ -147,7 +147,8 @@ Available actions:
|
|||||||
- **addParticipant**: Add someone to a group (`chatGuid`, `address`)
|
- **addParticipant**: Add someone to a group (`chatGuid`, `address`)
|
||||||
- **removeParticipant**: Remove someone from a group (`chatGuid`, `address`)
|
- **removeParticipant**: Remove someone from a group (`chatGuid`, `address`)
|
||||||
- **leaveGroup**: Leave a group chat (`chatGuid`)
|
- **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)
|
### Message IDs (short vs full)
|
||||||
Clawdbot may surface *short* message IDs (e.g., `1`, `2`) to save tokens.
|
Clawdbot may surface *short* message IDs (e.g., `1`, `2`) to save tokens.
|
||||||
|
|||||||
+20
-1
@@ -304,7 +304,8 @@ Slack uses Socket Mode only (no HTTP webhook server). Provide both tokens:
|
|||||||
"policy": "pairing",
|
"policy": "pairing",
|
||||||
"allowFrom": ["U123", "U456", "*"],
|
"allowFrom": ["U123", "U456", "*"],
|
||||||
"groupEnabled": false,
|
"groupEnabled": false,
|
||||||
"groupChannels": ["G123"]
|
"groupChannels": ["G123"],
|
||||||
|
"replyToMode": "all"
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
"C123": { "allow": true, "requireMention": true },
|
"C123": { "allow": true, "requireMention": true },
|
||||||
@@ -361,6 +362,24 @@ By default, Clawdbot replies in the main channel. Use `channels.slack.replyToMod
|
|||||||
|
|
||||||
The mode applies to both auto-replies and agent tool calls (`slack sendMessage`).
|
The mode applies to both auto-replies and agent tool calls (`slack sendMessage`).
|
||||||
|
|
||||||
|
### DM-specific threading
|
||||||
|
You can configure different threading behavior for DMs vs channels by setting `channels.slack.dm.replyToMode`:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
slack: {
|
||||||
|
replyToMode: "off", // default for channels
|
||||||
|
dm: {
|
||||||
|
replyToMode: "all" // DMs always thread
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When `dm.replyToMode` is set, DMs use that mode; channels use the top-level `replyToMode`. If `dm.replyToMode` is not set, both DMs and channels use the top-level setting.
|
||||||
|
|
||||||
### Manual threading tags
|
### Manual threading tags
|
||||||
For fine-grained control, use these tags in agent responses:
|
For fine-grained control, use these tags in agent responses:
|
||||||
- `[[reply_to_current]]` — reply to the triggering message (start/continue thread).
|
- `[[reply_to_current]]` — reply to the triggering message (start/continue thread).
|
||||||
|
|||||||
@@ -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 () => {
|
it("throws when buffer is missing for setGroupIcon", async () => {
|
||||||
const cfg: ClawdbotConfig = {
|
const cfg: ClawdbotConfig = {
|
||||||
channels: {
|
channels: {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
BLUEBUBBLES_ACTIONS,
|
BLUEBUBBLES_ACTIONS,
|
||||||
createActionGate,
|
createActionGate,
|
||||||
jsonResult,
|
jsonResult,
|
||||||
readBooleanParam,
|
|
||||||
readNumberParam,
|
readNumberParam,
|
||||||
readReactionParams,
|
readReactionParams,
|
||||||
readStringParam,
|
readStringParam,
|
||||||
@@ -51,6 +50,17 @@ function readMessageText(params: Record<string, unknown>): string | undefined {
|
|||||||
return readStringParam(params, "text") ?? readStringParam(params, "message");
|
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 */
|
/** Supported action names for BlueBubbles */
|
||||||
const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>(BLUEBUBBLES_ACTION_NAMES);
|
const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>(BLUEBUBBLES_ACTION_NAMES);
|
||||||
|
|
||||||
@@ -356,6 +366,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
const caption = readStringParam(params, "caption");
|
const caption = readStringParam(params, "caption");
|
||||||
const contentType =
|
const contentType =
|
||||||
readStringParam(params, "contentType") ?? readStringParam(params, "mimeType");
|
readStringParam(params, "contentType") ?? readStringParam(params, "mimeType");
|
||||||
|
const asVoice = readBooleanParam(params, "asVoice");
|
||||||
|
|
||||||
// Buffer can come from params.buffer (base64) or params.path (file path)
|
// Buffer can come from params.buffer (base64) or params.path (file path)
|
||||||
const base64Buffer = readStringParam(params, "buffer");
|
const base64Buffer = readStringParam(params, "buffer");
|
||||||
@@ -380,6 +391,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
filename,
|
filename,
|
||||||
contentType: contentType ?? undefined,
|
contentType: contentType ?? undefined,
|
||||||
caption: caption ?? undefined,
|
caption: caption ?? undefined,
|
||||||
|
asVoice: asVoice ?? undefined,
|
||||||
opts,
|
opts,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
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";
|
import type { BlueBubblesAttachment } from "./types.js";
|
||||||
|
|
||||||
vi.mock("./accounts.js", () => ({
|
vi.mock("./accounts.js", () => ({
|
||||||
@@ -238,3 +238,109 @@ describe("downloadBlueBubblesAttachment", () => {
|
|||||||
expect(result.buffer).toEqual(new Uint8Array([1]));
|
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 crypto from "node:crypto";
|
||||||
|
import path from "node:path";
|
||||||
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||||
import { resolveChatGuidForTarget } from "./send.js";
|
import { resolveChatGuidForTarget } from "./send.js";
|
||||||
@@ -19,6 +20,30 @@ export type BlueBubblesAttachmentOpts = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_ATTACHMENT_MAX_BYTES = 8 * 1024 * 1024;
|
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) {
|
function resolveAccount(params: BlueBubblesAttachmentOpts) {
|
||||||
const account = resolveBlueBubblesAccount({
|
const account = resolveBlueBubblesAccount({
|
||||||
@@ -104,6 +129,7 @@ function extractMessageId(payload: unknown): string {
|
|||||||
/**
|
/**
|
||||||
* Send an attachment via BlueBubbles API.
|
* Send an attachment via BlueBubbles API.
|
||||||
* Supports sending media files (images, videos, audio, documents) to a chat.
|
* 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: {
|
export async function sendBlueBubblesAttachment(params: {
|
||||||
to: string;
|
to: string;
|
||||||
@@ -113,12 +139,37 @@ export async function sendBlueBubblesAttachment(params: {
|
|||||||
caption?: string;
|
caption?: string;
|
||||||
replyToMessageGuid?: string;
|
replyToMessageGuid?: string;
|
||||||
replyToPartIndex?: number;
|
replyToPartIndex?: number;
|
||||||
|
asVoice?: boolean;
|
||||||
opts?: BlueBubblesAttachmentOpts;
|
opts?: BlueBubblesAttachmentOpts;
|
||||||
}): Promise<SendBlueBubblesAttachmentResult> {
|
}): Promise<SendBlueBubblesAttachmentResult> {
|
||||||
const { to, buffer, filename, contentType, caption, replyToMessageGuid, replyToPartIndex, opts = {} } =
|
const { to, caption, replyToMessageGuid, replyToPartIndex, asVoice, opts = {} } = params;
|
||||||
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);
|
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 target = resolveSendTarget(to);
|
||||||
const chatGuid = await resolveChatGuidForTarget({
|
const chatGuid = await resolveChatGuidForTarget({
|
||||||
baseUrl,
|
baseUrl,
|
||||||
@@ -170,6 +221,11 @@ export async function sendBlueBubblesAttachment(params: {
|
|||||||
addField("tempGuid", `temp-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`);
|
addField("tempGuid", `temp-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`);
|
||||||
addField("method", "private-api");
|
addField("method", "private-api");
|
||||||
|
|
||||||
|
// Add isAudioMessage flag for voice memos
|
||||||
|
if (isAudioMessage) {
|
||||||
|
addField("isAudioMessage", "true");
|
||||||
|
}
|
||||||
|
|
||||||
const trimmedReplyTo = replyToMessageGuid?.trim();
|
const trimmedReplyTo = replyToMessageGuid?.trim();
|
||||||
if (trimmedReplyTo) {
|
if (trimmedReplyTo) {
|
||||||
addField("selectedMessageGuid", trimmedReplyTo);
|
addField("selectedMessageGuid", trimmedReplyTo);
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export async function sendBlueBubblesMedia(params: {
|
|||||||
caption?: string;
|
caption?: string;
|
||||||
replyToId?: string | null;
|
replyToId?: string | null;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
|
asVoice?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
cfg,
|
cfg,
|
||||||
@@ -71,6 +72,7 @@ export async function sendBlueBubblesMedia(params: {
|
|||||||
caption,
|
caption,
|
||||||
replyToId,
|
replyToId,
|
||||||
accountId,
|
accountId,
|
||||||
|
asVoice,
|
||||||
} = params;
|
} = params;
|
||||||
const core = getBlueBubblesRuntime();
|
const core = getBlueBubblesRuntime();
|
||||||
const maxBytes = resolveChannelMediaMaxBytes({
|
const maxBytes = resolveChannelMediaMaxBytes({
|
||||||
@@ -146,6 +148,7 @@ export async function sendBlueBubblesMedia(params: {
|
|||||||
filename: resolvedFilename ?? "attachment",
|
filename: resolvedFilename ?? "attachment",
|
||||||
contentType: resolvedContentType ?? undefined,
|
contentType: resolvedContentType ?? undefined,
|
||||||
replyToMessageGuid,
|
replyToMessageGuid,
|
||||||
|
asVoice,
|
||||||
opts: {
|
opts: {
|
||||||
cfg,
|
cfg,
|
||||||
accountId,
|
accountId,
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export async function resolveMatrixAuth(params?: {
|
|||||||
saveMatrixCredentials,
|
saveMatrixCredentials,
|
||||||
credentialsMatchConfig,
|
credentialsMatchConfig,
|
||||||
touchMatrixCredentials,
|
touchMatrixCredentials,
|
||||||
} = await import("./credentials.js");
|
} = await import("../credentials.js");
|
||||||
|
|
||||||
const cached = loadMatrixCredentials(env);
|
const cached = loadMatrixCredentials(env);
|
||||||
const cachedCredentials =
|
const cachedCredentials =
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { MatrixClient } from "matrix-bot-sdk";
|
||||||
|
import { EventType } from "./types.js";
|
||||||
|
|
||||||
|
let resolveMatrixRoomId: typeof import("./targets.js").resolveMatrixRoomId;
|
||||||
|
let normalizeThreadId: typeof import("./targets.js").normalizeThreadId;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
({ resolveMatrixRoomId, normalizeThreadId } = await import("./targets.js"));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveMatrixRoomId", () => {
|
||||||
|
it("uses m.direct when available", async () => {
|
||||||
|
const userId = "@user:example.org";
|
||||||
|
const client = {
|
||||||
|
getAccountData: vi.fn().mockResolvedValue({
|
||||||
|
[userId]: ["!room:example.org"],
|
||||||
|
}),
|
||||||
|
getJoinedRooms: vi.fn(),
|
||||||
|
getJoinedRoomMembers: vi.fn(),
|
||||||
|
setAccountData: vi.fn(),
|
||||||
|
} as unknown as MatrixClient;
|
||||||
|
|
||||||
|
const roomId = await resolveMatrixRoomId(client, userId);
|
||||||
|
|
||||||
|
expect(roomId).toBe("!room:example.org");
|
||||||
|
expect(client.getJoinedRooms).not.toHaveBeenCalled();
|
||||||
|
expect(client.setAccountData).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to joined rooms and persists m.direct", async () => {
|
||||||
|
const userId = "@fallback:example.org";
|
||||||
|
const roomId = "!room:example.org";
|
||||||
|
const setAccountData = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const client = {
|
||||||
|
getAccountData: vi.fn().mockRejectedValue(new Error("nope")),
|
||||||
|
getJoinedRooms: vi.fn().mockResolvedValue([roomId]),
|
||||||
|
getJoinedRoomMembers: vi.fn().mockResolvedValue([
|
||||||
|
"@bot:example.org",
|
||||||
|
userId,
|
||||||
|
]),
|
||||||
|
setAccountData,
|
||||||
|
} as unknown as MatrixClient;
|
||||||
|
|
||||||
|
const resolved = await resolveMatrixRoomId(client, userId);
|
||||||
|
|
||||||
|
expect(resolved).toBe(roomId);
|
||||||
|
expect(setAccountData).toHaveBeenCalledWith(
|
||||||
|
EventType.Direct,
|
||||||
|
expect.objectContaining({ [userId]: [roomId] }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("continues when a room member lookup fails", async () => {
|
||||||
|
const userId = "@continue:example.org";
|
||||||
|
const roomId = "!good:example.org";
|
||||||
|
const setAccountData = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const getJoinedRoomMembers = vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce(new Error("boom"))
|
||||||
|
.mockResolvedValueOnce(["@bot:example.org", userId]);
|
||||||
|
const client = {
|
||||||
|
getAccountData: vi.fn().mockRejectedValue(new Error("nope")),
|
||||||
|
getJoinedRooms: vi.fn().mockResolvedValue(["!bad:example.org", roomId]),
|
||||||
|
getJoinedRoomMembers,
|
||||||
|
setAccountData,
|
||||||
|
} as unknown as MatrixClient;
|
||||||
|
|
||||||
|
const resolved = await resolveMatrixRoomId(client, userId);
|
||||||
|
|
||||||
|
expect(resolved).toBe(roomId);
|
||||||
|
expect(setAccountData).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows larger rooms when no 1:1 match exists", async () => {
|
||||||
|
const userId = "@group:example.org";
|
||||||
|
const roomId = "!group:example.org";
|
||||||
|
const client = {
|
||||||
|
getAccountData: vi.fn().mockRejectedValue(new Error("nope")),
|
||||||
|
getJoinedRooms: vi.fn().mockResolvedValue([roomId]),
|
||||||
|
getJoinedRoomMembers: vi.fn().mockResolvedValue([
|
||||||
|
"@bot:example.org",
|
||||||
|
userId,
|
||||||
|
"@extra:example.org",
|
||||||
|
]),
|
||||||
|
setAccountData: vi.fn().mockResolvedValue(undefined),
|
||||||
|
} as unknown as MatrixClient;
|
||||||
|
|
||||||
|
const resolved = await resolveMatrixRoomId(client, userId);
|
||||||
|
|
||||||
|
expect(resolved).toBe(roomId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("normalizeThreadId", () => {
|
||||||
|
it("returns null for empty thread ids", () => {
|
||||||
|
expect(normalizeThreadId(" ")).toBeNull();
|
||||||
|
expect(normalizeThreadId("$thread")).toBe("$thread");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,22 +16,100 @@ export function normalizeThreadId(raw?: string | number | null): string | null {
|
|||||||
return trimmed ? trimmed : null;
|
return trimmed ? trimmed : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveDirectRoomId(client: MatrixClient, userId: string): Promise<string> {
|
const directRoomCache = new Map<string, string>();
|
||||||
|
|
||||||
|
async function persistDirectRoom(
|
||||||
|
client: MatrixClient,
|
||||||
|
userId: string,
|
||||||
|
roomId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
let directContent: MatrixDirectAccountData | null = null;
|
||||||
|
try {
|
||||||
|
directContent = (await client.getAccountData(
|
||||||
|
EventType.Direct,
|
||||||
|
)) as MatrixDirectAccountData | null;
|
||||||
|
} catch {
|
||||||
|
// Ignore fetch errors and fall back to an empty map.
|
||||||
|
}
|
||||||
|
const existing =
|
||||||
|
directContent && !Array.isArray(directContent) ? directContent : {};
|
||||||
|
const current = Array.isArray(existing[userId]) ? existing[userId] : [];
|
||||||
|
if (current[0] === roomId) return;
|
||||||
|
const next = [roomId, ...current.filter((id) => id !== roomId)];
|
||||||
|
try {
|
||||||
|
await client.setAccountData(EventType.Direct, {
|
||||||
|
...existing,
|
||||||
|
[userId]: next,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Ignore persistence errors.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveDirectRoomId(
|
||||||
|
client: MatrixClient,
|
||||||
|
userId: string,
|
||||||
|
): Promise<string> {
|
||||||
const trimmed = userId.trim();
|
const trimmed = userId.trim();
|
||||||
if (!trimmed.startsWith("@")) {
|
if (!trimmed.startsWith("@")) {
|
||||||
throw new Error(`Matrix user IDs must be fully qualified (got "${trimmed}")`);
|
throw new Error(
|
||||||
|
`Matrix user IDs must be fully qualified (got "${trimmed}")`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// matrix-bot-sdk: use getAccountData to retrieve m.direct
|
|
||||||
|
const cached = directRoomCache.get(trimmed);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
// 1) Fast path: use account data (m.direct) for *this* logged-in user (the bot).
|
||||||
try {
|
try {
|
||||||
const directContent = await client.getAccountData(EventType.Direct) as MatrixDirectAccountData | null;
|
const directContent = (await client.getAccountData(
|
||||||
const list = Array.isArray(directContent?.[trimmed]) ? directContent[trimmed] : [];
|
EventType.Direct,
|
||||||
if (list.length > 0) return list[0];
|
)) as MatrixDirectAccountData | null;
|
||||||
|
const list = Array.isArray(directContent?.[trimmed])
|
||||||
|
? directContent[trimmed]
|
||||||
|
: [];
|
||||||
|
if (list.length > 0) {
|
||||||
|
directRoomCache.set(trimmed, list[0]);
|
||||||
|
return list[0];
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore errors, try fetching from server
|
// Ignore and fall back.
|
||||||
}
|
}
|
||||||
throw new Error(
|
|
||||||
`No m.direct room found for ${trimmed}. Open a DM first so Matrix can set m.direct.`,
|
// 2) Fallback: look for an existing joined room that looks like a 1:1 with the user.
|
||||||
);
|
// Many clients only maintain m.direct for *their own* account data, so relying on it is brittle.
|
||||||
|
let fallbackRoom: string | null = null;
|
||||||
|
try {
|
||||||
|
const rooms = await client.getJoinedRooms();
|
||||||
|
for (const roomId of rooms) {
|
||||||
|
let members: string[];
|
||||||
|
try {
|
||||||
|
members = await client.getJoinedRoomMembers(roomId);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!members.includes(trimmed)) continue;
|
||||||
|
// Prefer classic 1:1 rooms, but allow larger rooms if requested.
|
||||||
|
if (members.length === 2) {
|
||||||
|
directRoomCache.set(trimmed, roomId);
|
||||||
|
await persistDirectRoom(client, trimmed, roomId);
|
||||||
|
return roomId;
|
||||||
|
}
|
||||||
|
if (!fallbackRoom) {
|
||||||
|
fallbackRoom = roomId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore and fall back.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fallbackRoom) {
|
||||||
|
directRoomCache.set(trimmed, fallbackRoom);
|
||||||
|
await persistDirectRoom(client, trimmed, fallbackRoom);
|
||||||
|
return fallbackRoom;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`No direct room found for ${trimmed} (m.direct missing)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveMatrixRoomId(
|
export async function resolveMatrixRoomId(
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
readStringParam,
|
readStringParam,
|
||||||
resolveDefaultSlackAccountId,
|
resolveDefaultSlackAccountId,
|
||||||
resolveSlackAccount,
|
resolveSlackAccount,
|
||||||
|
resolveSlackReplyToMode,
|
||||||
resolveSlackGroupRequireMention,
|
resolveSlackGroupRequireMention,
|
||||||
buildSlackThreadingToolContext,
|
buildSlackThreadingToolContext,
|
||||||
setAccountEnabledInConfigSection,
|
setAccountEnabledInConfigSection,
|
||||||
@@ -162,8 +163,8 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
|||||||
resolveRequireMention: resolveSlackGroupRequireMention,
|
resolveRequireMention: resolveSlackGroupRequireMention,
|
||||||
},
|
},
|
||||||
threading: {
|
threading: {
|
||||||
resolveReplyToMode: ({ cfg, accountId }) =>
|
resolveReplyToMode: ({ cfg, accountId, chatType }) =>
|
||||||
resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off",
|
resolveSlackReplyToMode(resolveSlackAccount({ cfg, accountId }), chatType),
|
||||||
allowTagsWhenOff: true,
|
allowTagsWhenOff: true,
|
||||||
buildToolContext: (params) => buildSlackThreadingToolContext(params),
|
buildToolContext: (params) => buildSlackThreadingToolContext(params),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ TRASH
|
|||||||
local needle="$1"
|
local needle="$1"
|
||||||
local timeout_s="${2:-45}"
|
local timeout_s="${2:-45}"
|
||||||
local needle_compact
|
local needle_compact
|
||||||
needle_compact="$(printf "%s" "$needle" | sed -E "s/[[:space:]]+//g")"
|
needle_compact="$(printf "%s" "$needle" | tr -cd "[:alnum:]")"
|
||||||
local start_s
|
local start_s
|
||||||
start_s="$(date +%s)"
|
start_s="$(date +%s)"
|
||||||
while true; do
|
while true; do
|
||||||
@@ -61,17 +61,12 @@ TRASH
|
|||||||
let text = \"\";
|
let text = \"\";
|
||||||
try { text = fs.readFileSync(file, \"utf8\"); } catch { process.exit(1); }
|
try { text = fs.readFileSync(file, \"utf8\"); } catch { process.exit(1); }
|
||||||
if (text.length > 20000) text = text.slice(-20000);
|
if (text.length > 20000) text = text.slice(-20000);
|
||||||
const sanitize = (value) => value.replace(/[\\x00-\\x1f\\x7f]/g, \"\");
|
const stripAnsi = (value) => value.replace(/\\x1b\\[[0-9;]*[A-Za-z]/g, \"\");
|
||||||
const haystack = sanitize(text);
|
const compact = (value) => stripAnsi(value).toLowerCase().replace(/[^a-z0-9]+/g, \"\");
|
||||||
const safeNeedle = sanitize(needle);
|
const haystack = compact(text);
|
||||||
const needsEscape = new Set([\"\\\\\", \"^\", \"$\", \".\", \"*\", \"+\", \"?\", \"(\", \")\", \"[\", \"]\", \"{\", \"}\", \"|\"]);
|
const compactNeedle = compact(needle);
|
||||||
let escaped = \"\";
|
if (!compactNeedle) process.exit(1);
|
||||||
for (const ch of safeNeedle) {
|
process.exit(haystack.includes(compactNeedle) ? 0 : 1);
|
||||||
escaped += needsEscape.has(ch) ? \"\\\\\" + ch : ch;
|
|
||||||
}
|
|
||||||
const pattern = escaped.split(\"\").join(\".*\");
|
|
||||||
const re = new RegExp(pattern, \"i\");
|
|
||||||
process.exit(re.test(haystack) ? 0 : 1);
|
|
||||||
"; then
|
"; then
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
@@ -260,9 +255,11 @@ TRASH
|
|||||||
|
|
||||||
send_skills_flow() {
|
send_skills_flow() {
|
||||||
# Select skills section and skip optional installs.
|
# Select skills section and skip optional installs.
|
||||||
send $'"'"'\r'"'"' 1.2
|
wait_for_log "Where will the Gateway run?" 60 || true
|
||||||
|
send $'"'"'\r'"'"' 0.6
|
||||||
# Configure skills now? -> No
|
# Configure skills now? -> No
|
||||||
send $'"'"'n\r'"'"' 1.5
|
wait_for_log "Configure skills now?" 60 || true
|
||||||
|
send $'"'"'n\r'"'"' 0.8
|
||||||
send "" 1.0
|
send "" 1.0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -116,7 +116,6 @@ if (!shouldBuild()) {
|
|||||||
} else {
|
} else {
|
||||||
logRunner("Building TypeScript (dist is stale).");
|
logRunner("Building TypeScript (dist is stale).");
|
||||||
const pnpmArgs = ["exec", compiler, ...projectArgs];
|
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 buildCmd = process.platform === "win32" ? "cmd.exe" : "pnpm";
|
||||||
const buildArgs =
|
const buildArgs =
|
||||||
process.platform === "win32" ? ["/d", "/s", "/c", "pnpm", ...pnpmArgs] : pnpmArgs;
|
process.platform === "win32" ? ["/d", "/s", "/c", "pnpm", ...pnpmArgs] : pnpmArgs;
|
||||||
|
|||||||
@@ -237,6 +237,20 @@ describe("buildAgentSystemPrompt", () => {
|
|||||||
expect(prompt).toContain("Bravo");
|
expect(prompt).toContain("Bravo");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("adds SOUL guidance when a soul file is present", () => {
|
||||||
|
const prompt = buildAgentSystemPrompt({
|
||||||
|
workspaceDir: "/tmp/clawd",
|
||||||
|
contextFiles: [
|
||||||
|
{ path: "./SOUL.md", content: "Persona" },
|
||||||
|
{ path: "dir\\SOUL.md", content: "Persona Windows" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(prompt).toContain(
|
||||||
|
"If SOUL.md is present, embody its persona and tone. Avoid stiff, generic replies; follow its guidance unless higher-priority instructions override it.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("summarizes the message tool when available", () => {
|
it("summarizes the message tool when available", () => {
|
||||||
const prompt = buildAgentSystemPrompt({
|
const prompt = buildAgentSystemPrompt({
|
||||||
workspaceDir: "/tmp/clawd",
|
workspaceDir: "/tmp/clawd",
|
||||||
|
|||||||
@@ -517,12 +517,18 @@ export function buildAgentSystemPrompt(params: {
|
|||||||
|
|
||||||
const contextFiles = params.contextFiles ?? [];
|
const contextFiles = params.contextFiles ?? [];
|
||||||
if (contextFiles.length > 0) {
|
if (contextFiles.length > 0) {
|
||||||
lines.push(
|
const hasSoulFile = contextFiles.some((file) => {
|
||||||
"# Project Context",
|
const normalizedPath = file.path.trim().replace(/\\/g, "/");
|
||||||
"",
|
const baseName = normalizedPath.split("/").pop() ?? normalizedPath;
|
||||||
"The following project context files have been loaded:",
|
return baseName.toLowerCase() === "soul.md";
|
||||||
"",
|
});
|
||||||
);
|
lines.push("# Project Context", "", "The following project context files have been loaded:");
|
||||||
|
if (hasSoulFile) {
|
||||||
|
lines.push(
|
||||||
|
"If SOUL.md is present, embody its persona and tone. Avoid stiff, generic replies; follow its guidance unless higher-priority instructions override it.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
for (const file of contextFiles) {
|
for (const file of contextFiles) {
|
||||||
lines.push(`## ${file.path}`, "", file.content, "");
|
lines.push(`## ${file.path}`, "", file.content, "");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ export async function runReplyAgent(params: {
|
|||||||
followupRun.run.config,
|
followupRun.run.config,
|
||||||
replyToChannel,
|
replyToChannel,
|
||||||
sessionCtx.AccountId,
|
sessionCtx.AccountId,
|
||||||
|
sessionCtx.ChatType,
|
||||||
);
|
);
|
||||||
const applyReplyToMode = createReplyToModeFilterForChannel(replyToMode, replyToChannel);
|
const applyReplyToMode = createReplyToModeFilterForChannel(replyToMode, replyToChannel);
|
||||||
const cfg = followupRun.run.config;
|
const cfg = followupRun.run.config;
|
||||||
|
|||||||
@@ -204,6 +204,7 @@ export function createFollowupRunner(params: {
|
|||||||
queued.run.config,
|
queued.run.config,
|
||||||
replyToChannel,
|
replyToChannel,
|
||||||
queued.originatingAccountId,
|
queued.originatingAccountId,
|
||||||
|
queued.originatingChatType,
|
||||||
);
|
);
|
||||||
|
|
||||||
const replyTaggedPayloads: ReplyPayload[] = applyReplyThreading({
|
const replyTaggedPayloads: ReplyPayload[] = applyReplyThreading({
|
||||||
|
|||||||
@@ -358,6 +358,7 @@ export async function runPreparedReply(
|
|||||||
originatingTo: ctx.OriginatingTo,
|
originatingTo: ctx.OriginatingTo,
|
||||||
originatingAccountId: ctx.AccountId,
|
originatingAccountId: ctx.AccountId,
|
||||||
originatingThreadId: ctx.MessageThreadId,
|
originatingThreadId: ctx.MessageThreadId,
|
||||||
|
originatingChatType: ctx.ChatType,
|
||||||
run: {
|
run: {
|
||||||
agentId,
|
agentId,
|
||||||
agentDir,
|
agentDir,
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ export type FollowupRun = {
|
|||||||
originatingAccountId?: string;
|
originatingAccountId?: string;
|
||||||
/** Thread id for reply routing (Telegram topic id or Matrix thread event id). */
|
/** Thread id for reply routing (Telegram topic id or Matrix thread event id). */
|
||||||
originatingThreadId?: string | number;
|
originatingThreadId?: string | number;
|
||||||
|
/** Chat type for context-aware threading (e.g., DM vs channel). */
|
||||||
|
originatingChatType?: string;
|
||||||
run: {
|
run: {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
agentDir: string;
|
agentDir: string;
|
||||||
|
|||||||
@@ -31,6 +31,33 @@ describe("resolveReplyToMode", () => {
|
|||||||
expect(resolveReplyToMode(cfg, "discord")).toBe("first");
|
expect(resolveReplyToMode(cfg, "discord")).toBe("first");
|
||||||
expect(resolveReplyToMode(cfg, "slack")).toBe("all");
|
expect(resolveReplyToMode(cfg, "slack")).toBe("all");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses dm-specific replyToMode for Slack DMs when configured", () => {
|
||||||
|
const cfg = {
|
||||||
|
channels: {
|
||||||
|
slack: {
|
||||||
|
replyToMode: "off",
|
||||||
|
dm: { replyToMode: "all" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("all");
|
||||||
|
expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("off");
|
||||||
|
expect(resolveReplyToMode(cfg, "slack", null, undefined)).toBe("off");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to top-level replyToMode when dm.replyToMode is not configured", () => {
|
||||||
|
const cfg = {
|
||||||
|
channels: {
|
||||||
|
slack: {
|
||||||
|
replyToMode: "first",
|
||||||
|
dm: { enabled: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("first");
|
||||||
|
expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("first");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("createReplyToModeFilter", () => {
|
describe("createReplyToModeFilter", () => {
|
||||||
|
|||||||
@@ -9,12 +9,14 @@ export function resolveReplyToMode(
|
|||||||
cfg: ClawdbotConfig,
|
cfg: ClawdbotConfig,
|
||||||
channel?: OriginatingChannelType,
|
channel?: OriginatingChannelType,
|
||||||
accountId?: string | null,
|
accountId?: string | null,
|
||||||
|
chatType?: string | null,
|
||||||
): ReplyToMode {
|
): ReplyToMode {
|
||||||
const provider = normalizeChannelId(channel);
|
const provider = normalizeChannelId(channel);
|
||||||
if (!provider) return "all";
|
if (!provider) return "all";
|
||||||
const resolved = getChannelDock(provider)?.threading?.resolveReplyToMode?.({
|
const resolved = getChannelDock(provider)?.threading?.resolveReplyToMode?.({
|
||||||
cfg,
|
cfg,
|
||||||
accountId,
|
accountId,
|
||||||
|
chatType,
|
||||||
});
|
});
|
||||||
return resolved ?? "all";
|
return resolved ?? "all";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { ClawdbotConfig } from "../config/config.js";
|
|||||||
import { resolveDiscordAccount } from "../discord/accounts.js";
|
import { resolveDiscordAccount } from "../discord/accounts.js";
|
||||||
import { resolveIMessageAccount } from "../imessage/accounts.js";
|
import { resolveIMessageAccount } from "../imessage/accounts.js";
|
||||||
import { resolveSignalAccount } from "../signal/accounts.js";
|
import { resolveSignalAccount } from "../signal/accounts.js";
|
||||||
import { resolveSlackAccount } from "../slack/accounts.js";
|
import { resolveSlackAccount, resolveSlackReplyToMode } from "../slack/accounts.js";
|
||||||
import { buildSlackThreadingToolContext } from "../slack/threading-tool-context.js";
|
import { buildSlackThreadingToolContext } from "../slack/threading-tool-context.js";
|
||||||
import { resolveTelegramAccount } from "../telegram/accounts.js";
|
import { resolveTelegramAccount } from "../telegram/accounts.js";
|
||||||
import { normalizeE164 } from "../utils.js";
|
import { normalizeE164 } from "../utils.js";
|
||||||
@@ -224,8 +224,8 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
|
|||||||
resolveRequireMention: resolveSlackGroupRequireMention,
|
resolveRequireMention: resolveSlackGroupRequireMention,
|
||||||
},
|
},
|
||||||
threading: {
|
threading: {
|
||||||
resolveReplyToMode: ({ cfg, accountId }) =>
|
resolveReplyToMode: ({ cfg, accountId, chatType }) =>
|
||||||
resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off",
|
resolveSlackReplyToMode(resolveSlackAccount({ cfg, accountId }), chatType),
|
||||||
allowTagsWhenOff: true,
|
allowTagsWhenOff: true,
|
||||||
buildToolContext: (params) => buildSlackThreadingToolContext(params),
|
buildToolContext: (params) => buildSlackThreadingToolContext(params),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -198,6 +198,7 @@ export type ChannelThreadingAdapter = {
|
|||||||
resolveReplyToMode?: (params: {
|
resolveReplyToMode?: (params: {
|
||||||
cfg: ClawdbotConfig;
|
cfg: ClawdbotConfig;
|
||||||
accountId?: string | null;
|
accountId?: string | null;
|
||||||
|
chatType?: string | null;
|
||||||
}) => "off" | "first" | "all";
|
}) => "off" | "first" | "all";
|
||||||
allowTagsWhenOff?: boolean;
|
allowTagsWhenOff?: boolean;
|
||||||
buildToolContext?: (params: {
|
buildToolContext?: (params: {
|
||||||
|
|||||||
@@ -78,16 +78,6 @@ describe("argv helpers", () => {
|
|||||||
});
|
});
|
||||||
expect(nodeArgv).toEqual(["node", "clawdbot", "status"]);
|
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({
|
const directArgv = buildParseArgv({
|
||||||
programName: "clawdbot",
|
programName: "clawdbot",
|
||||||
rawArgs: ["clawdbot", "status"],
|
rawArgs: ["clawdbot", "status"],
|
||||||
@@ -99,12 +89,6 @@ describe("argv helpers", () => {
|
|||||||
rawArgs: ["bun", "src/entry.ts", "status"],
|
rawArgs: ["bun", "src/entry.ts", "status"],
|
||||||
});
|
});
|
||||||
expect(bunArgv).toEqual(["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", () => {
|
it("builds parse argv from fallback args", () => {
|
||||||
|
|||||||
@@ -95,7 +95,6 @@ export function buildParseArgv(params: {
|
|||||||
? baseArgv.slice(1)
|
? baseArgv.slice(1)
|
||||||
: baseArgv;
|
: baseArgv;
|
||||||
const executable = (normalizedArgv[0]?.split(/[/\\]/).pop() ?? "").toLowerCase();
|
const executable = (normalizedArgv[0]?.split(/[/\\]/).pop() ?? "").toLowerCase();
|
||||||
// Normalize Windows `.exe` runtimes (node.exe/bun.exe) for runtime detection.
|
|
||||||
const looksLikeNode =
|
const looksLikeNode =
|
||||||
normalizedArgv.length >= 2 &&
|
normalizedArgv.length >= 2 &&
|
||||||
(executable === "node" ||
|
(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(
|
export async function ensureWorkspaceAndSessions(
|
||||||
workspaceDir: string,
|
workspaceDir: string,
|
||||||
runtime: RuntimeEnv,
|
runtime: RuntimeEnv,
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export type SlackDmConfig = {
|
|||||||
groupEnabled?: boolean;
|
groupEnabled?: boolean;
|
||||||
/** Optional allowlist for group DM channels (ids or slugs). */
|
/** Optional allowlist for group DM channels (ids or slugs). */
|
||||||
groupChannels?: Array<string | number>;
|
groupChannels?: Array<string | number>;
|
||||||
|
/** Control reply threading for DMs (off|first|all). Overrides top-level replyToMode for DMs. */
|
||||||
|
replyToMode?: ReplyToMode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SlackChannelConfig = {
|
export type SlackChannelConfig = {
|
||||||
|
|||||||
@@ -230,6 +230,7 @@ export {
|
|||||||
listSlackAccountIds,
|
listSlackAccountIds,
|
||||||
resolveDefaultSlackAccountId,
|
resolveDefaultSlackAccountId,
|
||||||
resolveSlackAccount,
|
resolveSlackAccount,
|
||||||
|
resolveSlackReplyToMode,
|
||||||
type ResolvedSlackAccount,
|
type ResolvedSlackAccount,
|
||||||
} from "../slack/accounts.js";
|
} from "../slack/accounts.js";
|
||||||
export { slackOnboardingAdapter } from "../channels/plugins/onboarding/slack.js";
|
export { slackOnboardingAdapter } from "../channels/plugins/onboarding/slack.js";
|
||||||
|
|||||||
@@ -107,3 +107,13 @@ export function listEnabledSlackAccounts(cfg: ClawdbotConfig): ResolvedSlackAcco
|
|||||||
.map((accountId) => resolveSlackAccount({ cfg, accountId }))
|
.map((accountId) => resolveSlackAccount({ cfg, accountId }))
|
||||||
.filter((account) => account.enabled);
|
.filter((account) => account.enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveSlackReplyToMode(
|
||||||
|
account: ResolvedSlackAccount,
|
||||||
|
chatType?: string | null,
|
||||||
|
): "off" | "first" | "all" {
|
||||||
|
if (chatType === "direct" && account.dm?.replyToMode !== undefined) {
|
||||||
|
return account.dm.replyToMode;
|
||||||
|
}
|
||||||
|
return account.replyToMode ?? "off";
|
||||||
|
}
|
||||||
@@ -53,6 +53,7 @@ export type SlackThreadStarter = {
|
|||||||
text: string;
|
text: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
ts?: string;
|
ts?: string;
|
||||||
|
files?: SlackFile[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const THREAD_STARTER_CACHE = new Map<string, SlackThreadStarter>();
|
const THREAD_STARTER_CACHE = new Map<string, SlackThreadStarter>();
|
||||||
@@ -71,7 +72,7 @@ export async function resolveSlackThreadStarter(params: {
|
|||||||
ts: params.threadTs,
|
ts: params.threadTs,
|
||||||
limit: 1,
|
limit: 1,
|
||||||
inclusive: true,
|
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 message = response?.messages?.[0];
|
||||||
const text = (message?.text ?? "").trim();
|
const text = (message?.text ?? "").trim();
|
||||||
if (!message || !text) return null;
|
if (!message || !text) return null;
|
||||||
@@ -79,6 +80,7 @@ export async function resolveSlackThreadStarter(params: {
|
|||||||
text,
|
text,
|
||||||
userId: message.user,
|
userId: message.user,
|
||||||
ts: message.ts,
|
ts: message.ts,
|
||||||
|
files: message.files,
|
||||||
};
|
};
|
||||||
THREAD_STARTER_CACHE.set(cacheKey, starter);
|
THREAD_STARTER_CACHE.set(cacheKey, starter);
|
||||||
return starter;
|
return starter;
|
||||||
|
|||||||
@@ -434,6 +434,7 @@ export async function prepareSlackMessage(params: {
|
|||||||
|
|
||||||
let threadStarterBody: string | undefined;
|
let threadStarterBody: string | undefined;
|
||||||
let threadLabel: string | undefined;
|
let threadLabel: string | undefined;
|
||||||
|
let threadStarterMedia: Awaited<ReturnType<typeof resolveSlackMedia>> = null;
|
||||||
if (isThreadReply && threadTs) {
|
if (isThreadReply && threadTs) {
|
||||||
const starter = await resolveSlackThreadStarter({
|
const starter = await resolveSlackThreadStarter({
|
||||||
channelId: message.channel,
|
channelId: message.channel,
|
||||||
@@ -453,11 +454,27 @@ export async function prepareSlackMessage(params: {
|
|||||||
});
|
});
|
||||||
const snippet = starter.text.replace(/\s+/g, " ").slice(0, 80);
|
const snippet = starter.text.replace(/\s+/g, " ").slice(0, 80);
|
||||||
threadLabel = `Slack thread ${roomLabel}${snippet ? `: ${snippet}` : ""}`;
|
threadLabel = `Slack thread ${roomLabel}${snippet ? `: ${snippet}` : ""}`;
|
||||||
|
// If current message has no files but thread starter does, fetch starter's files
|
||||||
|
if (!media && starter.files && starter.files.length > 0) {
|
||||||
|
threadStarterMedia = await resolveSlackMedia({
|
||||||
|
files: starter.files,
|
||||||
|
token: ctx.botToken,
|
||||||
|
maxBytes: ctx.mediaMaxBytes,
|
||||||
|
});
|
||||||
|
if (threadStarterMedia) {
|
||||||
|
logVerbose(
|
||||||
|
`slack: hydrated thread starter file ${threadStarterMedia.placeholder} from root message`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
threadLabel = `Slack thread ${roomLabel}`;
|
threadLabel = `Slack thread ${roomLabel}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use thread starter media if current message has none
|
||||||
|
const effectiveMedia = media ?? threadStarterMedia;
|
||||||
|
|
||||||
const ctxPayload = finalizeInboundContext({
|
const ctxPayload = finalizeInboundContext({
|
||||||
Body: combinedBody,
|
Body: combinedBody,
|
||||||
RawBody: rawBody,
|
RawBody: rawBody,
|
||||||
@@ -483,9 +500,9 @@ export async function prepareSlackMessage(params: {
|
|||||||
ThreadLabel: threadLabel,
|
ThreadLabel: threadLabel,
|
||||||
Timestamp: message.ts ? Math.round(Number(message.ts) * 1000) : undefined,
|
Timestamp: message.ts ? Math.round(Number(message.ts) * 1000) : undefined,
|
||||||
WasMentioned: isRoomish ? effectiveWasMentioned : undefined,
|
WasMentioned: isRoomish ? effectiveWasMentioned : undefined,
|
||||||
MediaPath: media?.path,
|
MediaPath: effectiveMedia?.path,
|
||||||
MediaType: media?.contentType,
|
MediaType: effectiveMedia?.contentType,
|
||||||
MediaUrl: media?.path,
|
MediaUrl: effectiveMedia?.path,
|
||||||
CommandAuthorized: commandAuthorized,
|
CommandAuthorized: commandAuthorized,
|
||||||
OriginatingChannel: "slack" as const,
|
OriginatingChannel: "slack" as const,
|
||||||
OriginatingTo: slackTo,
|
OriginatingTo: slackTo,
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
|
import { buildSlackThreadingToolContext } from "./threading-tool-context.js";
|
||||||
|
|
||||||
|
const emptyCfg = {} as ClawdbotConfig;
|
||||||
|
|
||||||
|
describe("buildSlackThreadingToolContext", () => {
|
||||||
|
it("uses top-level replyToMode by default", () => {
|
||||||
|
const cfg = {
|
||||||
|
channels: {
|
||||||
|
slack: { replyToMode: "first" },
|
||||||
|
},
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
const result = buildSlackThreadingToolContext({
|
||||||
|
cfg,
|
||||||
|
accountId: null,
|
||||||
|
context: { ChatType: "channel" },
|
||||||
|
});
|
||||||
|
expect(result.replyToMode).toBe("first");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses dm.replyToMode for direct messages when configured", () => {
|
||||||
|
const cfg = {
|
||||||
|
channels: {
|
||||||
|
slack: {
|
||||||
|
replyToMode: "off",
|
||||||
|
dm: { replyToMode: "all" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
const result = buildSlackThreadingToolContext({
|
||||||
|
cfg,
|
||||||
|
accountId: null,
|
||||||
|
context: { ChatType: "direct" },
|
||||||
|
});
|
||||||
|
expect(result.replyToMode).toBe("all");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses top-level replyToMode for channels even when dm.replyToMode is set", () => {
|
||||||
|
const cfg = {
|
||||||
|
channels: {
|
||||||
|
slack: {
|
||||||
|
replyToMode: "off",
|
||||||
|
dm: { replyToMode: "all" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
const result = buildSlackThreadingToolContext({
|
||||||
|
cfg,
|
||||||
|
accountId: null,
|
||||||
|
context: { ChatType: "channel" },
|
||||||
|
});
|
||||||
|
expect(result.replyToMode).toBe("off");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to top-level when dm.replyToMode is not set", () => {
|
||||||
|
const cfg = {
|
||||||
|
channels: {
|
||||||
|
slack: {
|
||||||
|
replyToMode: "first",
|
||||||
|
dm: { enabled: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
const result = buildSlackThreadingToolContext({
|
||||||
|
cfg,
|
||||||
|
accountId: null,
|
||||||
|
context: { ChatType: "direct" },
|
||||||
|
});
|
||||||
|
expect(result.replyToMode).toBe("first");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses all mode when ThreadLabel is present", () => {
|
||||||
|
const cfg = {
|
||||||
|
channels: {
|
||||||
|
slack: { replyToMode: "off" },
|
||||||
|
},
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
const result = buildSlackThreadingToolContext({
|
||||||
|
cfg,
|
||||||
|
accountId: null,
|
||||||
|
context: { ChatType: "channel", ThreadLabel: "some-thread" },
|
||||||
|
});
|
||||||
|
expect(result.replyToMode).toBe("all");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to off when no replyToMode is configured", () => {
|
||||||
|
const result = buildSlackThreadingToolContext({
|
||||||
|
cfg: emptyCfg,
|
||||||
|
accountId: null,
|
||||||
|
context: { ChatType: "direct" },
|
||||||
|
});
|
||||||
|
expect(result.replyToMode).toBe("off");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,7 +3,7 @@ import type {
|
|||||||
ChannelThreadingToolContext,
|
ChannelThreadingToolContext,
|
||||||
} from "../channels/plugins/types.js";
|
} from "../channels/plugins/types.js";
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import { resolveSlackAccount } from "./accounts.js";
|
import { resolveSlackAccount, resolveSlackReplyToMode } from "./accounts.js";
|
||||||
|
|
||||||
export function buildSlackThreadingToolContext(params: {
|
export function buildSlackThreadingToolContext(params: {
|
||||||
cfg: ClawdbotConfig;
|
cfg: ClawdbotConfig;
|
||||||
@@ -11,11 +11,11 @@ export function buildSlackThreadingToolContext(params: {
|
|||||||
context: ChannelThreadingContext;
|
context: ChannelThreadingContext;
|
||||||
hasRepliedRef?: { value: boolean };
|
hasRepliedRef?: { value: boolean };
|
||||||
}): ChannelThreadingToolContext {
|
}): ChannelThreadingToolContext {
|
||||||
const configuredReplyToMode =
|
const account = resolveSlackAccount({
|
||||||
resolveSlackAccount({
|
cfg: params.cfg,
|
||||||
cfg: params.cfg,
|
accountId: params.accountId,
|
||||||
accountId: params.accountId,
|
});
|
||||||
}).replyToMode ?? "off";
|
const configuredReplyToMode = resolveSlackReplyToMode(account, params.context.ChatType);
|
||||||
const effectiveReplyToMode = params.context.ThreadLabel ? "all" : configuredReplyToMode;
|
const effectiveReplyToMode = params.context.ThreadLabel ? "all" : configuredReplyToMode;
|
||||||
const threadId = params.context.MessageThreadId ?? params.context.ReplyToId;
|
const threadId = params.context.MessageThreadId ?? params.context.ReplyToId;
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
detectBrowserOpenSupport,
|
detectBrowserOpenSupport,
|
||||||
formatControlUiSshHint,
|
formatControlUiSshHint,
|
||||||
openUrl,
|
openUrl,
|
||||||
|
openUrlInBackground,
|
||||||
probeGatewayReachable,
|
probeGatewayReachable,
|
||||||
waitForGatewayReachable,
|
waitForGatewayReachable,
|
||||||
resolveControlUiLinks,
|
resolveControlUiLinks,
|
||||||
@@ -282,6 +283,11 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
|
|||||||
"Control UI",
|
"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 (!opts.skipUi && gatewayProbe.ok) {
|
||||||
if (hasBootstrap) {
|
if (hasBootstrap) {
|
||||||
await prompter.note(
|
await prompter.note(
|
||||||
@@ -293,11 +299,27 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
|
|||||||
].join("\n"),
|
].join("\n"),
|
||||||
"Start TUI (best option!)",
|
"Start TUI (best option!)",
|
||||||
);
|
);
|
||||||
const wantsTui = await prompter.confirm({
|
await prompter.note(
|
||||||
message: "Do you want to hatch your bot now?",
|
[
|
||||||
initialValue: true,
|
"Gateway token: shared auth for the Gateway + Control UI.",
|
||||||
});
|
"Stored in: ~/.clawdbot/clawdbot.json (gateway.auth.token) or CLAWDBOT_GATEWAY_TOKEN.",
|
||||||
if (wantsTui) {
|
"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({
|
await runTui({
|
||||||
url: links.wsUrl,
|
url: links.wsUrl,
|
||||||
token: settings.authMode === "token" ? settings.gatewayToken : undefined,
|
token: settings.authMode === "token" ? settings.gatewayToken : undefined,
|
||||||
@@ -306,6 +328,52 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
|
|||||||
deliver: false,
|
deliver: false,
|
||||||
message: "Wake up, my friend!",
|
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 {
|
} else {
|
||||||
const browserSupport = await detectBrowserOpenSupport();
|
const browserSupport = await detectBrowserOpenSupport();
|
||||||
@@ -342,9 +410,10 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
|
|||||||
);
|
);
|
||||||
|
|
||||||
const shouldOpenControlUi =
|
const shouldOpenControlUi =
|
||||||
!opts.skipUi && settings.authMode === "token" && Boolean(settings.gatewayToken);
|
!opts.skipUi &&
|
||||||
let controlUiOpened = false;
|
settings.authMode === "token" &&
|
||||||
let controlUiOpenHint: string | undefined;
|
Boolean(settings.gatewayToken) &&
|
||||||
|
hatchChoice === null;
|
||||||
if (shouldOpenControlUi) {
|
if (shouldOpenControlUi) {
|
||||||
const browserSupport = await detectBrowserOpenSupport();
|
const browserSupport = await detectBrowserOpenSupport();
|
||||||
if (browserSupport.ok) {
|
if (browserSupport.ok) {
|
||||||
@@ -406,9 +475,16 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
|
|||||||
"Web search (optional)",
|
"Web search (optional)",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await prompter.note(
|
||||||
|
'What now: https://clawd.bot/showcase ("What People Are Building").',
|
||||||
|
"What now",
|
||||||
|
);
|
||||||
|
|
||||||
await prompter.outro(
|
await prompter.outro(
|
||||||
controlUiOpened
|
controlUiOpened
|
||||||
? "Onboarding complete. Dashboard opened with your token; keep that tab to control Clawdbot."
|
? "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-"));
|
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-onboard-"));
|
||||||
await fs.writeFile(path.join(workspaceDir, DEFAULT_BOOTSTRAP_FILENAME), "{}");
|
await fs.writeFile(path.join(workspaceDir, DEFAULT_BOOTSTRAP_FILENAME), "{}");
|
||||||
|
|
||||||
const confirm: WizardPrompter["confirm"] = vi.fn(async (opts) => {
|
const select: WizardPrompter["select"] = vi.fn(async (opts) => {
|
||||||
if (opts.message === "Do you want to hatch your bot now?") return true;
|
if (opts.message === "How do you want to hatch your bot?") return "tui";
|
||||||
return opts.initialValue ?? false;
|
return "quickstart";
|
||||||
});
|
});
|
||||||
|
|
||||||
const prompter: WizardPrompter = {
|
const prompter: WizardPrompter = {
|
||||||
intro: vi.fn(async () => {}),
|
intro: vi.fn(async () => {}),
|
||||||
outro: vi.fn(async () => {}),
|
outro: vi.fn(async () => {}),
|
||||||
note: vi.fn(async () => {}),
|
note: vi.fn(async () => {}),
|
||||||
select: vi.fn(async () => "quickstart"),
|
select,
|
||||||
multiselect: vi.fn(async () => []),
|
multiselect: vi.fn(async () => []),
|
||||||
text: vi.fn(async () => ""),
|
text: vi.fn(async () => ""),
|
||||||
confirm,
|
confirm: vi.fn(async () => false),
|
||||||
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
|
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;
|
const calls = (note as unknown as { mock: { calls: unknown[][] } }).mock.calls;
|
||||||
expect(calls.length).toBeGreaterThan(0);
|
expect(calls.length).toBeGreaterThan(0);
|
||||||
const lastCall = calls[calls.length - 1];
|
expect(calls.some((call) => call?.[1] === "Web search (optional)")).toBe(true);
|
||||||
expect(lastCall?.[1]).toBe("Web search (optional)");
|
|
||||||
} finally {
|
} finally {
|
||||||
if (prevBraveKey === undefined) {
|
if (prevBraveKey === undefined) {
|
||||||
delete process.env.BRAVE_API_KEY;
|
delete process.env.BRAVE_API_KEY;
|
||||||
|
|||||||
Reference in new issue
Block a user