Compare commits

..
22 changed files with 317 additions and 274 deletions

No files matched your search

+1 -2
View File
@@ -8,7 +8,6 @@ Docs: https://docs.clawd.bot
- Highlight: Lobster optional plugin tool for typed workflows + approval gates. 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.
- Slack: add chat-type reply threading overrides via `replyToModeByChatType`. (#1442) Thanks @stefangalescu.
- 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
@@ -34,7 +33,6 @@ Docs: https://docs.clawd.bot
- 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.
- 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.
- CLI: prefer `~` for home paths in output.
- 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.
@@ -42,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).
+1 -21
View File
@@ -304,8 +304,7 @@ Slack uses Socket Mode only (no HTTP webhook server). Provide both tokens:
"policy": "pairing",
"allowFrom": ["U123", "U456", "*"],
"groupEnabled": false,
"groupChannels": ["G123"],
"replyToMode": "all"
"groupChannels": ["G123"]
},
"channels": {
"C123": { "allow": true, "requireMention": true },
@@ -362,25 +361,6 @@ 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`).
### Per-chat-type threading
You can configure different threading behavior per chat type by setting `channels.slack.replyToModeByChatType`:
```json5
{
channels: {
slack: {
replyToMode: "off", // default for channels
replyToModeByChatType: {
direct: "all", // DMs always thread
group: "first" // group DMs/MPIM thread first reply
},
}
}
}
```
When a chat-type override is set, it takes precedence for that chat type. Otherwise the top-level `replyToMode` is used.
### Manual threading tags
For fine-grained control, use these tags in agent responses:
- `[[reply_to_current]]` — reply to the triggering message (start/continue thread).
+2 -3
View File
@@ -19,7 +19,6 @@ import {
readStringParam,
resolveDefaultSlackAccountId,
resolveSlackAccount,
resolveSlackReplyToMode,
resolveSlackGroupRequireMention,
buildSlackThreadingToolContext,
setAccountEnabledInConfigSection,
@@ -163,8 +162,8 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
resolveRequireMention: resolveSlackGroupRequireMention,
},
threading: {
resolveReplyToMode: ({ cfg, accountId, chatType }) =>
resolveSlackReplyToMode(resolveSlackAccount({ cfg, accountId }), chatType),
resolveReplyToMode: ({ cfg, accountId }) =>
resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off",
allowTagsWhenOff: true,
buildToolContext: (params) => buildSlackThreadingToolContext(params),
},
-14
View File
@@ -237,20 +237,6 @@ describe("buildAgentSystemPrompt", () => {
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", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd",
+6 -12
View File
@@ -517,18 +517,12 @@ export function buildAgentSystemPrompt(params: {
const contextFiles = params.contextFiles ?? [];
if (contextFiles.length > 0) {
const hasSoulFile = contextFiles.some((file) => {
const normalizedPath = file.path.trim().replace(/\\/g, "/");
const baseName = normalizedPath.split("/").pop() ?? normalizedPath;
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("");
lines.push(
"# Project Context",
"",
"The following project context files have been loaded:",
"",
);
for (const file of contextFiles) {
lines.push(`## ${file.path}`, "", file.content, "");
}
-1
View File
@@ -136,7 +136,6 @@ export async function runReplyAgent(params: {
followupRun.run.config,
replyToChannel,
sessionCtx.AccountId,
sessionCtx.ChatType,
);
const applyReplyToMode = createReplyToModeFilterForChannel(replyToMode, replyToChannel);
const cfg = followupRun.run.config;
-1
View File
@@ -204,7 +204,6 @@ export function createFollowupRunner(params: {
queued.run.config,
replyToChannel,
queued.originatingAccountId,
queued.originatingChatType,
);
const replyTaggedPayloads: ReplyPayload[] = applyReplyThreading({
-1
View File
@@ -358,7 +358,6 @@ export async function runPreparedReply(
originatingTo: ctx.OriginatingTo,
originatingAccountId: ctx.AccountId,
originatingThreadId: ctx.MessageThreadId,
originatingChatType: ctx.ChatType,
run: {
agentId,
agentDir,
-2
View File
@@ -39,8 +39,6 @@ export type FollowupRun = {
originatingAccountId?: string;
/** Thread id for reply routing (Telegram topic id or Matrix thread event id). */
originatingThreadId?: string | number;
/** Chat type for context-aware threading (e.g., DM vs channel). */
originatingChatType?: string;
run: {
agentId: string;
agentDir: string;
@@ -31,33 +31,6 @@ describe("resolveReplyToMode", () => {
expect(resolveReplyToMode(cfg, "discord")).toBe("first");
expect(resolveReplyToMode(cfg, "slack")).toBe("all");
});
it("uses chat-type replyToMode overrides for Slack when configured", () => {
const cfg = {
channels: {
slack: {
replyToMode: "off",
replyToModeByChatType: { direct: "all", group: "first" },
},
},
} as ClawdbotConfig;
expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("all");
expect(resolveReplyToMode(cfg, "slack", null, "group")).toBe("first");
expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("off");
expect(resolveReplyToMode(cfg, "slack", null, undefined)).toBe("off");
});
it("falls back to top-level replyToMode when no chat-type override is set", () => {
const cfg = {
channels: {
slack: {
replyToMode: "first",
},
},
} as ClawdbotConfig;
expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("first");
expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("first");
});
});
describe("createReplyToModeFilter", () => {
-2
View File
@@ -9,14 +9,12 @@ export function resolveReplyToMode(
cfg: ClawdbotConfig,
channel?: OriginatingChannelType,
accountId?: string | null,
chatType?: string | null,
): ReplyToMode {
const provider = normalizeChannelId(channel);
if (!provider) return "all";
const resolved = getChannelDock(provider)?.threading?.resolveReplyToMode?.({
cfg,
accountId,
chatType,
});
return resolved ?? "all";
}
+3 -3
View File
@@ -2,7 +2,7 @@ import type { ClawdbotConfig } from "../config/config.js";
import { resolveDiscordAccount } from "../discord/accounts.js";
import { resolveIMessageAccount } from "../imessage/accounts.js";
import { resolveSignalAccount } from "../signal/accounts.js";
import { resolveSlackAccount, resolveSlackReplyToMode } from "../slack/accounts.js";
import { resolveSlackAccount } from "../slack/accounts.js";
import { buildSlackThreadingToolContext } from "../slack/threading-tool-context.js";
import { resolveTelegramAccount } from "../telegram/accounts.js";
import { normalizeE164 } from "../utils.js";
@@ -224,8 +224,8 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
resolveRequireMention: resolveSlackGroupRequireMention,
},
threading: {
resolveReplyToMode: ({ cfg, accountId, chatType }) =>
resolveSlackReplyToMode(resolveSlackAccount({ cfg, accountId }), chatType),
resolveReplyToMode: ({ cfg, accountId }) =>
resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off",
allowTagsWhenOff: true,
buildToolContext: (params) => buildSlackThreadingToolContext(params),
},
-1
View File
@@ -198,7 +198,6 @@ export type ChannelThreadingAdapter = {
resolveReplyToMode?: (params: {
cfg: ClawdbotConfig;
accountId?: string | null;
chatType?: string | null;
}) => "off" | "first" | "all";
allowTagsWhenOff?: boolean;
buildToolContext?: (params: {
-5
View File
@@ -117,11 +117,6 @@ export type SlackAccountConfig = {
reactionAllowlist?: Array<string | number>;
/** Control reply threading when reply tags are present (off|first|all). */
replyToMode?: ReplyToMode;
/**
* Optional per-chat-type reply threading overrides.
* Example: { direct: "all", group: "first", channel: "off" }.
*/
replyToModeByChatType?: Partial<Record<"direct" | "group" | "channel", ReplyToMode>>;
/** Thread session behavior. */
thread?: SlackThreadConfig;
actions?: SlackActionConfig;
-9
View File
@@ -280,14 +280,6 @@ export const SlackThreadSchema = z
})
.strict();
const SlackReplyToModeByChatTypeSchema = z
.object({
direct: ReplyToModeSchema.optional(),
group: ReplyToModeSchema.optional(),
channel: ReplyToModeSchema.optional(),
})
.strict();
export const SlackAccountSchema = z
.object({
name: z.string().optional(),
@@ -315,7 +307,6 @@ export const SlackAccountSchema = z
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),
reactionAllowlist: z.array(z.union([z.string(), z.number()])).optional(),
replyToMode: ReplyToModeSchema.optional(),
replyToModeByChatType: SlackReplyToModeByChatTypeSchema.optional(),
thread: SlackThreadSchema.optional(),
actions: z
.object({
-1
View File
@@ -230,7 +230,6 @@ export {
listSlackAccountIds,
resolveDefaultSlackAccountId,
resolveSlackAccount,
resolveSlackReplyToMode,
type ResolvedSlackAccount,
} from "../slack/accounts.js";
export { slackOnboardingAdapter } from "../channels/plugins/onboarding/slack.js";
-15
View File
@@ -1,6 +1,5 @@
import type { ClawdbotConfig } from "../config/config.js";
import type { SlackAccountConfig } from "../config/types.js";
import { normalizeChatType } from "../channels/chat-type.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
import { resolveSlackAppToken, resolveSlackBotToken } from "./token.js";
@@ -21,7 +20,6 @@ export type ResolvedSlackAccount = {
reactionNotifications?: SlackAccountConfig["reactionNotifications"];
reactionAllowlist?: SlackAccountConfig["reactionAllowlist"];
replyToMode?: SlackAccountConfig["replyToMode"];
replyToModeByChatType?: SlackAccountConfig["replyToModeByChatType"];
actions?: SlackAccountConfig["actions"];
slashCommand?: SlackAccountConfig["slashCommand"];
dm?: SlackAccountConfig["dm"];
@@ -97,7 +95,6 @@ export function resolveSlackAccount(params: {
reactionNotifications: merged.reactionNotifications,
reactionAllowlist: merged.reactionAllowlist,
replyToMode: merged.replyToMode,
replyToModeByChatType: merged.replyToModeByChatType,
actions: merged.actions,
slashCommand: merged.slashCommand,
dm: merged.dm,
@@ -110,15 +107,3 @@ export function listEnabledSlackAccounts(cfg: ClawdbotConfig): ResolvedSlackAcco
.map((accountId) => resolveSlackAccount({ cfg, accountId }))
.filter((account) => account.enabled);
}
export function resolveSlackReplyToMode(
account: ResolvedSlackAccount,
chatType?: string | null,
): "off" | "first" | "all" {
const normalized = normalizeChatType(chatType ?? undefined);
const overrides = account.replyToModeByChatType;
if (normalized && overrides && overrides[normalized] !== undefined) {
return overrides[normalized] ?? "off";
}
return account.replyToMode ?? "off";
}
+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,
-95
View File
@@ -1,95 +0,0 @@
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 chat-type replyToMode overrides for direct messages when configured", () => {
const cfg = {
channels: {
slack: {
replyToMode: "off",
replyToModeByChatType: { direct: "all" },
},
},
} as ClawdbotConfig;
const result = buildSlackThreadingToolContext({
cfg,
accountId: null,
context: { ChatType: "direct" },
});
expect(result.replyToMode).toBe("all");
});
it("uses top-level replyToMode for channels when no channel override is set", () => {
const cfg = {
channels: {
slack: {
replyToMode: "off",
replyToModeByChatType: { direct: "all" },
},
},
} as ClawdbotConfig;
const result = buildSlackThreadingToolContext({
cfg,
accountId: null,
context: { ChatType: "channel" },
});
expect(result.replyToMode).toBe("off");
});
it("falls back to top-level when no chat-type override is set", () => {
const cfg = {
channels: {
slack: {
replyToMode: "first",
},
},
} 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");
});
});
+6 -6
View File
@@ -3,7 +3,7 @@ import type {
ChannelThreadingToolContext,
} from "../channels/plugins/types.js";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveSlackAccount, resolveSlackReplyToMode } from "./accounts.js";
import { resolveSlackAccount } from "./accounts.js";
export function buildSlackThreadingToolContext(params: {
cfg: ClawdbotConfig;
@@ -11,11 +11,11 @@ export function buildSlackThreadingToolContext(params: {
context: ChannelThreadingContext;
hasRepliedRef?: { value: boolean };
}): ChannelThreadingToolContext {
const account = resolveSlackAccount({
cfg: params.cfg,
accountId: params.accountId,
});
const configuredReplyToMode = resolveSlackReplyToMode(account, params.context.ChatType);
const configuredReplyToMode =
resolveSlackAccount({
cfg: params.cfg,
accountId: params.accountId,
}).replyToMode ?? "off";
const effectiveReplyToMode = params.context.ThreadLabel ? "all" : configuredReplyToMode;
const threadId = params.context.MessageThreadId ?? params.context.ReplyToId;
return {