Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43bd219f1d |
No files matched your search
+1
-2
@@ -33,8 +33,6 @@ 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.
|
||||||
@@ -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.
|
- 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.
|
- 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: 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.
|
- 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).
|
- Providers: improve GitHub Copilot integration (enterprise support, base URL, and auth flow alignment).
|
||||||
|
|
||||||
|
|||||||
+1
-20
@@ -304,8 +304,7 @@ 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 },
|
||||||
@@ -362,24 +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`).
|
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).
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
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,100 +16,22 @@ export function normalizeThreadId(raw?: string | number | null): string | null {
|
|||||||
return trimmed ? trimmed : null;
|
return trimmed ? trimmed : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const directRoomCache = new Map<string, string>();
|
async function resolveDirectRoomId(client: MatrixClient, userId: string): Promise<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(
|
throw new Error(`Matrix user IDs must be fully qualified (got "${trimmed}")`);
|
||||||
`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(
|
const directContent = await client.getAccountData(EventType.Direct) as MatrixDirectAccountData | null;
|
||||||
EventType.Direct,
|
const list = Array.isArray(directContent?.[trimmed]) ? directContent[trimmed] : [];
|
||||||
)) as MatrixDirectAccountData | null;
|
if (list.length > 0) return list[0];
|
||||||
const list = Array.isArray(directContent?.[trimmed])
|
|
||||||
? directContent[trimmed]
|
|
||||||
: [];
|
|
||||||
if (list.length > 0) {
|
|
||||||
directRoomCache.set(trimmed, list[0]);
|
|
||||||
return list[0];
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore and fall back.
|
// Ignore errors, try fetching from server
|
||||||
}
|
}
|
||||||
|
throw new Error(
|
||||||
// 2) Fallback: look for an existing joined room that looks like a 1:1 with the user.
|
`No m.direct room found for ${trimmed}. Open a DM first so Matrix can set m.direct.`,
|
||||||
// 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,7 +19,6 @@ import {
|
|||||||
readStringParam,
|
readStringParam,
|
||||||
resolveDefaultSlackAccountId,
|
resolveDefaultSlackAccountId,
|
||||||
resolveSlackAccount,
|
resolveSlackAccount,
|
||||||
resolveSlackReplyToMode,
|
|
||||||
resolveSlackGroupRequireMention,
|
resolveSlackGroupRequireMention,
|
||||||
buildSlackThreadingToolContext,
|
buildSlackThreadingToolContext,
|
||||||
setAccountEnabledInConfigSection,
|
setAccountEnabledInConfigSection,
|
||||||
@@ -163,8 +162,8 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
|||||||
resolveRequireMention: resolveSlackGroupRequireMention,
|
resolveRequireMention: resolveSlackGroupRequireMention,
|
||||||
},
|
},
|
||||||
threading: {
|
threading: {
|
||||||
resolveReplyToMode: ({ cfg, accountId, chatType }) =>
|
resolveReplyToMode: ({ cfg, accountId }) =>
|
||||||
resolveSlackReplyToMode(resolveSlackAccount({ cfg, accountId }), chatType),
|
resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off",
|
||||||
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" | tr -cd "[:alnum:]")"
|
needle_compact="$(printf "%s" "$needle" | sed -E "s/[[:space:]]+//g")"
|
||||||
local start_s
|
local start_s
|
||||||
start_s="$(date +%s)"
|
start_s="$(date +%s)"
|
||||||
while true; do
|
while true; do
|
||||||
@@ -61,12 +61,17 @@ 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 stripAnsi = (value) => value.replace(/\\x1b\\[[0-9;]*[A-Za-z]/g, \"\");
|
const sanitize = (value) => value.replace(/[\\x00-\\x1f\\x7f]/g, \"\");
|
||||||
const compact = (value) => stripAnsi(value).toLowerCase().replace(/[^a-z0-9]+/g, \"\");
|
const haystack = sanitize(text);
|
||||||
const haystack = compact(text);
|
const safeNeedle = sanitize(needle);
|
||||||
const compactNeedle = compact(needle);
|
const needsEscape = new Set([\"\\\\\", \"^\", \"$\", \".\", \"*\", \"+\", \"?\", \"(\", \")\", \"[\", \"]\", \"{\", \"}\", \"|\"]);
|
||||||
if (!compactNeedle) process.exit(1);
|
let escaped = \"\";
|
||||||
process.exit(haystack.includes(compactNeedle) ? 0 : 1);
|
for (const ch of safeNeedle) {
|
||||||
|
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
|
||||||
@@ -255,11 +260,9 @@ TRASH
|
|||||||
|
|
||||||
send_skills_flow() {
|
send_skills_flow() {
|
||||||
# Select skills section and skip optional installs.
|
# Select skills section and skip optional installs.
|
||||||
wait_for_log "Where will the Gateway run?" 60 || true
|
send $'"'"'\r'"'"' 1.2
|
||||||
send $'"'"'\r'"'"' 0.6
|
|
||||||
# Configure skills now? -> No
|
# Configure skills now? -> No
|
||||||
wait_for_log "Configure skills now?" 60 || true
|
send $'"'"'n\r'"'"' 1.5
|
||||||
send $'"'"'n\r'"'"' 0.8
|
|
||||||
send "" 1.0
|
send "" 1.0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -237,20 +237,6 @@ 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,18 +517,12 @@ export function buildAgentSystemPrompt(params: {
|
|||||||
|
|
||||||
const contextFiles = params.contextFiles ?? [];
|
const contextFiles = params.contextFiles ?? [];
|
||||||
if (contextFiles.length > 0) {
|
if (contextFiles.length > 0) {
|
||||||
const hasSoulFile = contextFiles.some((file) => {
|
lines.push(
|
||||||
const normalizedPath = file.path.trim().replace(/\\/g, "/");
|
"# Project Context",
|
||||||
const baseName = normalizedPath.split("/").pop() ?? normalizedPath;
|
"",
|
||||||
return baseName.toLowerCase() === "soul.md";
|
"The following project context files have been loaded:",
|
||||||
});
|
"",
|
||||||
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,7 +136,6 @@ 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,7 +204,6 @@ 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,7 +358,6 @@ 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,8 +39,6 @@ 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,33 +31,6 @@ 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,14 +9,12 @@ 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, resolveSlackReplyToMode } from "../slack/accounts.js";
|
import { resolveSlackAccount } 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, chatType }) =>
|
resolveReplyToMode: ({ cfg, accountId }) =>
|
||||||
resolveSlackReplyToMode(resolveSlackAccount({ cfg, accountId }), chatType),
|
resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off",
|
||||||
allowTagsWhenOff: true,
|
allowTagsWhenOff: true,
|
||||||
buildToolContext: (params) => buildSlackThreadingToolContext(params),
|
buildToolContext: (params) => buildSlackThreadingToolContext(params),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -198,7 +198,6 @@ 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: {
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ 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,7 +230,6 @@ 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,13 +107,3 @@ 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";
|
|
||||||
}
|
|
||||||
+27
-13
@@ -5,17 +5,28 @@ import { fetchRemoteMedia } from "../../media/fetch.js";
|
|||||||
import { saveMediaBuffer } from "../../media/store.js";
|
import { saveMediaBuffer } from "../../media/store.js";
|
||||||
import type { SlackFile } from "../types.js";
|
import type { SlackFile } from "../types.js";
|
||||||
|
|
||||||
export async function resolveSlackMedia(params: {
|
export type SlackResolvedMedia = {
|
||||||
files?: SlackFile[];
|
|
||||||
token: string;
|
|
||||||
maxBytes: number;
|
|
||||||
}): Promise<{
|
|
||||||
path: string;
|
path: string;
|
||||||
contentType?: string;
|
contentType?: string;
|
||||||
placeholder: 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 files = params.files ?? [];
|
||||||
|
const resolved: SlackResolvedMedia[] = [];
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
|
if (file.size && file.size > params.maxBytes) continue;
|
||||||
const url = file.url_private_download ?? file.url_private;
|
const url = file.url_private_download ?? file.url_private;
|
||||||
if (!url) continue;
|
if (!url) continue;
|
||||||
try {
|
try {
|
||||||
@@ -37,20 +48,20 @@ export async function resolveSlackMedia(params: {
|
|||||||
params.maxBytes,
|
params.maxBytes,
|
||||||
);
|
);
|
||||||
const label = fetched.fileName ?? file.name;
|
const label = fetched.fileName ?? file.name;
|
||||||
return {
|
resolved.push({
|
||||||
path: saved.path,
|
path: saved.path,
|
||||||
contentType: saved.contentType,
|
contentType: saved.contentType,
|
||||||
placeholder: label ? `[Slack file: ${label}]` : "[Slack file]",
|
placeholder: label ? `[Slack file: ${label}]` : "[Slack file]",
|
||||||
};
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore download failures and fall through to the next file.
|
// Ignore download failures and fall through to the next file.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SlackThreadStarter = {
|
export type SlackThreadStarter = {
|
||||||
text: string;
|
text?: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
ts?: string;
|
ts?: string;
|
||||||
files?: SlackFile[];
|
files?: SlackFile[];
|
||||||
@@ -72,12 +83,15 @@ 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; files?: SlackFile[] }> };
|
})) 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;
|
const hasFiles = Boolean(message?.files && message.files.length > 0);
|
||||||
|
if (!message || (!text && !hasFiles)) return null;
|
||||||
const starter: SlackThreadStarter = {
|
const starter: SlackThreadStarter = {
|
||||||
text,
|
text: text || undefined,
|
||||||
userId: message.user,
|
userId: message.user,
|
||||||
ts: message.ts,
|
ts: message.ts,
|
||||||
files: message.files,
|
files: message.files,
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("../media.js", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("../media.js")>("../media.js");
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
resolveSlackMediaList: vi.fn(),
|
||||||
|
resolveSlackThreadStarter: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import type { App } from "@slack/bolt";
|
||||||
|
|
||||||
|
import type { ClawdbotConfig } from "../../../config/config.js";
|
||||||
|
import type { RuntimeEnv } from "../../../runtime.js";
|
||||||
|
import type { ResolvedSlackAccount } from "../../accounts.js";
|
||||||
|
import type { SlackMessageEvent } from "../../types.js";
|
||||||
|
import { createSlackMonitorContext } from "../context.js";
|
||||||
|
import { resolveSlackMediaList, resolveSlackThreadStarter } from "../media.js";
|
||||||
|
import { prepareSlackMessage } from "./prepare.js";
|
||||||
|
|
||||||
|
const account: ResolvedSlackAccount = {
|
||||||
|
accountId: "default",
|
||||||
|
enabled: true,
|
||||||
|
botTokenSource: "config",
|
||||||
|
appTokenSource: "config",
|
||||||
|
config: {},
|
||||||
|
} as ResolvedSlackAccount;
|
||||||
|
|
||||||
|
const createContext = () => {
|
||||||
|
const slackCtx = createSlackMonitorContext({
|
||||||
|
cfg: {
|
||||||
|
agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/clawd" } },
|
||||||
|
channels: { slack: { enabled: true } },
|
||||||
|
} as ClawdbotConfig,
|
||||||
|
accountId: "default",
|
||||||
|
botToken: "token",
|
||||||
|
app: { client: {} } as App,
|
||||||
|
runtime: {} as RuntimeEnv,
|
||||||
|
botUserId: "B1",
|
||||||
|
teamId: "T1",
|
||||||
|
apiAppId: "A1",
|
||||||
|
historyLimit: 0,
|
||||||
|
sessionScope: "per-sender",
|
||||||
|
mainKey: "main",
|
||||||
|
dmEnabled: true,
|
||||||
|
dmPolicy: "open",
|
||||||
|
allowFrom: [],
|
||||||
|
groupDmEnabled: true,
|
||||||
|
groupDmChannels: [],
|
||||||
|
defaultRequireMention: true,
|
||||||
|
groupPolicy: "open",
|
||||||
|
useAccessGroups: false,
|
||||||
|
reactionMode: "off",
|
||||||
|
reactionAllowlist: [],
|
||||||
|
replyToMode: "off",
|
||||||
|
threadHistoryScope: "thread",
|
||||||
|
threadInheritParent: false,
|
||||||
|
slashCommand: {
|
||||||
|
enabled: false,
|
||||||
|
name: "clawd",
|
||||||
|
sessionPrefix: "slack:slash",
|
||||||
|
ephemeral: true,
|
||||||
|
},
|
||||||
|
textLimit: 4000,
|
||||||
|
ackReactionScope: "off",
|
||||||
|
mediaMaxBytes: 1024,
|
||||||
|
removeAckAfterReply: false,
|
||||||
|
});
|
||||||
|
slackCtx.resolveChannelName = async () => ({ name: "general", type: "channel" });
|
||||||
|
slackCtx.resolveUserName = async () => ({ name: "Alice" });
|
||||||
|
return slackCtx;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mediaMock = vi.mocked(resolveSlackMediaList);
|
||||||
|
const starterMock = vi.mocked(resolveSlackThreadStarter);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mediaMock.mockReset();
|
||||||
|
starterMock.mockReset();
|
||||||
|
mediaMock.mockResolvedValue([]);
|
||||||
|
starterMock.mockResolvedValue(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("prepareSlackMessage thread media", () => {
|
||||||
|
it("hydrates root files for thread replies without attachments", async () => {
|
||||||
|
const ctx = createContext();
|
||||||
|
starterMock.mockResolvedValueOnce({
|
||||||
|
text: "",
|
||||||
|
userId: "U2",
|
||||||
|
ts: "171234.000",
|
||||||
|
files: [{ name: "root.pdf" }],
|
||||||
|
});
|
||||||
|
mediaMock.mockResolvedValueOnce([]).mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
path: "/tmp/root.pdf",
|
||||||
|
contentType: "application/pdf",
|
||||||
|
placeholder: "[Slack file: root.pdf]",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const message: SlackMessageEvent = {
|
||||||
|
type: "message",
|
||||||
|
channel: "C1",
|
||||||
|
channel_type: "channel",
|
||||||
|
text: "",
|
||||||
|
user: "U1",
|
||||||
|
ts: "171234.111",
|
||||||
|
thread_ts: "171234.000",
|
||||||
|
} as SlackMessageEvent;
|
||||||
|
|
||||||
|
const prepared = await prepareSlackMessage({
|
||||||
|
ctx,
|
||||||
|
account,
|
||||||
|
message,
|
||||||
|
opts: { source: "message", wasMentioned: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(prepared).not.toBeNull();
|
||||||
|
expect(prepared?.ctxPayload.RawBody).toBe("[Slack file: root.pdf]");
|
||||||
|
expect(prepared?.ctxPayload.MediaPath).toBe("/tmp/root.pdf");
|
||||||
|
expect(prepared?.ctxPayload.MediaPaths).toEqual(["/tmp/root.pdf"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits MediaPaths for multiple attachments", async () => {
|
||||||
|
const ctx = createContext();
|
||||||
|
mediaMock.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
path: "/tmp/a.png",
|
||||||
|
contentType: "image/png",
|
||||||
|
placeholder: "[Slack file: a.png]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/tmp/b.pdf",
|
||||||
|
contentType: "application/pdf",
|
||||||
|
placeholder: "[Slack file: b.pdf]",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const message: SlackMessageEvent = {
|
||||||
|
type: "message",
|
||||||
|
channel: "C1",
|
||||||
|
channel_type: "channel",
|
||||||
|
text: "hi",
|
||||||
|
user: "U1",
|
||||||
|
ts: "171234.111",
|
||||||
|
files: [{ name: "a.png" }, { name: "b.pdf" }],
|
||||||
|
} as SlackMessageEvent;
|
||||||
|
|
||||||
|
const prepared = await prepareSlackMessage({
|
||||||
|
ctx,
|
||||||
|
account,
|
||||||
|
message,
|
||||||
|
opts: { source: "message", wasMentioned: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(prepared?.ctxPayload.MediaPath).toBe("/tmp/a.png");
|
||||||
|
expect(prepared?.ctxPayload.MediaPaths).toEqual(["/tmp/a.png", "/tmp/b.pdf"]);
|
||||||
|
expect(prepared?.ctxPayload.MediaTypes).toEqual(["image/png", "application/pdf"]);
|
||||||
|
expect(prepared?.ctxPayload.MediaUrls).toEqual(["/tmp/a.png", "/tmp/b.pdf"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps file-only messages when downloads fail", async () => {
|
||||||
|
const ctx = createContext();
|
||||||
|
mediaMock.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
const message: SlackMessageEvent = {
|
||||||
|
type: "message",
|
||||||
|
channel: "C1",
|
||||||
|
channel_type: "channel",
|
||||||
|
text: "",
|
||||||
|
user: "U1",
|
||||||
|
ts: "171234.111",
|
||||||
|
files: [{ name: "doc.txt" }],
|
||||||
|
} as SlackMessageEvent;
|
||||||
|
|
||||||
|
const prepared = await prepareSlackMessage({
|
||||||
|
ctx,
|
||||||
|
account,
|
||||||
|
message,
|
||||||
|
opts: { source: "message", wasMentioned: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(prepared).not.toBeNull();
|
||||||
|
expect(prepared?.ctxPayload.RawBody).toBe("[Slack file: doc.txt]");
|
||||||
|
expect(prepared?.ctxPayload.MediaPath).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -39,7 +39,11 @@ import { resolveSlackAllowListMatch, resolveSlackUserAllowed } from "../allow-li
|
|||||||
import { resolveSlackEffectiveAllowFrom } from "../auth.js";
|
import { resolveSlackEffectiveAllowFrom } from "../auth.js";
|
||||||
import { resolveSlackChannelConfig } from "../channel-config.js";
|
import { resolveSlackChannelConfig } from "../channel-config.js";
|
||||||
import { normalizeSlackChannelType, type SlackMonitorContext } from "../context.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";
|
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");
|
ctx.logger.info({ channel: message.channel, reason: "no-mention" }, "skipping channel message");
|
||||||
if (ctx.historyLimit > 0) {
|
if (ctx.historyLimit > 0) {
|
||||||
const pendingText = (message.text ?? "").trim();
|
const pendingText = (message.text ?? "").trim();
|
||||||
const fallbackFile = message.files?.[0]?.name
|
const fallbackFile = resolveSlackFilePlaceholder(message.files) ?? "";
|
||||||
? `[Slack file: ${message.files[0].name}]`
|
|
||||||
: message.files?.length
|
|
||||||
? "[Slack file]"
|
|
||||||
: "";
|
|
||||||
const pendingBody = pendingText || fallbackFile;
|
const pendingBody = pendingText || fallbackFile;
|
||||||
if (pendingBody) {
|
if (pendingBody) {
|
||||||
recordPendingHistoryEntry({
|
recordPendingHistoryEntry({
|
||||||
@@ -313,13 +313,80 @@ export async function prepareSlackMessage(params: {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const media = await resolveSlackMedia({
|
const roomLabel = channelName ? `#${channelName}` : `#${message.channel}`;
|
||||||
|
const envelopeOptions = resolveEnvelopeFormatOptions(ctx.cfg);
|
||||||
|
|
||||||
|
const mediaList = await resolveSlackMediaList({
|
||||||
files: message.files,
|
files: message.files,
|
||||||
token: ctx.botToken,
|
token: ctx.botToken,
|
||||||
maxBytes: ctx.mediaMaxBytes,
|
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;
|
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 ackReaction = resolveAckReaction(cfg, route.agentId);
|
||||||
const ackReactionValue = ackReaction ?? "";
|
const ackReactionValue = ackReaction ?? "";
|
||||||
@@ -353,7 +420,6 @@ export async function prepareSlackMessage(params: {
|
|||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const roomLabel = channelName ? `#${channelName}` : `#${message.channel}`;
|
|
||||||
const preview = rawBody.replace(/\s+/g, " ").slice(0, 160);
|
const preview = rawBody.replace(/\s+/g, " ").slice(0, 160);
|
||||||
const inboundLabel = isDirectMessage
|
const inboundLabel = isDirectMessage
|
||||||
? `Slack DM from ${senderName}`
|
? `Slack DM from ${senderName}`
|
||||||
@@ -380,7 +446,6 @@ export async function prepareSlackMessage(params: {
|
|||||||
const storePath = resolveStorePath(ctx.cfg.session?.store, {
|
const storePath = resolveStorePath(ctx.cfg.session?.store, {
|
||||||
agentId: route.agentId,
|
agentId: route.agentId,
|
||||||
});
|
});
|
||||||
const envelopeOptions = resolveEnvelopeFormatOptions(ctx.cfg);
|
|
||||||
const previousTimestamp = readSessionUpdatedAt({
|
const previousTimestamp = readSessionUpdatedAt({
|
||||||
storePath,
|
storePath,
|
||||||
sessionKey: route.sessionKey,
|
sessionKey: route.sessionKey,
|
||||||
@@ -431,50 +496,6 @@ export async function prepareSlackMessage(params: {
|
|||||||
].filter((entry): entry is string => Boolean(entry));
|
].filter((entry): entry is string => Boolean(entry));
|
||||||
const groupSystemPrompt =
|
const groupSystemPrompt =
|
||||||
systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined;
|
systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined;
|
||||||
|
|
||||||
let threadStarterBody: string | undefined;
|
|
||||||
let threadLabel: string | undefined;
|
|
||||||
let threadStarterMedia: Awaited<ReturnType<typeof resolveSlackMedia>> = null;
|
|
||||||
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}` : ""}`;
|
|
||||||
// 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 {
|
|
||||||
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,
|
||||||
@@ -503,6 +524,9 @@ export async function prepareSlackMessage(params: {
|
|||||||
MediaPath: effectiveMedia?.path,
|
MediaPath: effectiveMedia?.path,
|
||||||
MediaType: effectiveMedia?.contentType,
|
MediaType: effectiveMedia?.contentType,
|
||||||
MediaUrl: effectiveMedia?.path,
|
MediaUrl: effectiveMedia?.path,
|
||||||
|
MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
|
||||||
|
MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined,
|
||||||
|
MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
|
||||||
CommandAuthorized: commandAuthorized,
|
CommandAuthorized: commandAuthorized,
|
||||||
OriginatingChannel: "slack" as const,
|
OriginatingChannel: "slack" as const,
|
||||||
OriginatingTo: slackTo,
|
OriginatingTo: slackTo,
|
||||||
|
|||||||
@@ -1,96 +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 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, resolveSlackReplyToMode } from "./accounts.js";
|
import { resolveSlackAccount } 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 account = resolveSlackAccount({
|
const configuredReplyToMode =
|
||||||
cfg: params.cfg,
|
resolveSlackAccount({
|
||||||
accountId: params.accountId,
|
cfg: params.cfg,
|
||||||
});
|
accountId: params.accountId,
|
||||||
const configuredReplyToMode = resolveSlackReplyToMode(account, params.context.ChatType);
|
}).replyToMode ?? "off";
|
||||||
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 {
|
||||||
|
|||||||
Reference in new issue
Block a user