Slack: add some fixes and connect it all up
This commit is contained in:
@@ -8,6 +8,7 @@ import { createNodesTool } from "./tools/nodes-tool.js";
|
||||
import { createSessionsHistoryTool } from "./tools/sessions-history-tool.js";
|
||||
import { createSessionsListTool } from "./tools/sessions-list-tool.js";
|
||||
import { createSessionsSendTool } from "./tools/sessions-send-tool.js";
|
||||
import { createSlackTool } from "./tools/slack-tool.js";
|
||||
|
||||
export function createClawdisTools(options?: {
|
||||
browserControlUrl?: string;
|
||||
@@ -20,6 +21,7 @@ export function createClawdisTools(options?: {
|
||||
createNodesTool(),
|
||||
createCronTool(),
|
||||
createDiscordTool(),
|
||||
createSlackTool(),
|
||||
createGatewayTool(),
|
||||
createSessionsListTool(),
|
||||
createSessionsHistoryTool(),
|
||||
|
||||
@@ -88,6 +88,14 @@ describe("createClawdisCodingTools", () => {
|
||||
expect(discord.some((tool) => tool.name === "discord")).toBe(true);
|
||||
});
|
||||
|
||||
it("scopes slack tool to slack surface", () => {
|
||||
const other = createClawdisCodingTools({ surface: "whatsapp" });
|
||||
expect(other.some((tool) => tool.name === "slack")).toBe(false);
|
||||
|
||||
const slack = createClawdisCodingTools({ surface: "slack" });
|
||||
expect(slack.some((tool) => tool.name === "slack")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps read tool image metadata intact", async () => {
|
||||
const tools = createClawdisCodingTools();
|
||||
const readTool = tools.find((tool) => tool.name === "read");
|
||||
|
||||
@@ -441,6 +441,12 @@ function shouldIncludeDiscordTool(surface?: string): boolean {
|
||||
return normalized === "discord" || normalized.startsWith("discord:");
|
||||
}
|
||||
|
||||
function shouldIncludeSlackTool(surface?: string): boolean {
|
||||
const normalized = normalizeSurface(surface);
|
||||
if (!normalized) return false;
|
||||
return normalized === "slack" || normalized.startsWith("slack:");
|
||||
}
|
||||
|
||||
export function createClawdisCodingTools(options?: {
|
||||
bash?: BashToolDefaults & ProcessToolDefaults;
|
||||
surface?: string;
|
||||
@@ -494,9 +500,12 @@ export function createClawdisCodingTools(options?: {
|
||||
}),
|
||||
];
|
||||
const allowDiscord = shouldIncludeDiscordTool(options?.surface);
|
||||
const filtered = allowDiscord
|
||||
? tools
|
||||
: tools.filter((tool) => tool.name !== "discord");
|
||||
const allowSlack = shouldIncludeSlackTool(options?.surface);
|
||||
const filtered = tools.filter((tool) => {
|
||||
if (tool.name === "discord") return allowDiscord;
|
||||
if (tool.name === "slack") return allowSlack;
|
||||
return true;
|
||||
});
|
||||
const sandboxed = sandbox
|
||||
? filterToolsByPolicy(filtered, sandbox.tools)
|
||||
: filtered;
|
||||
|
||||
@@ -66,10 +66,8 @@ export async function handleSlackAction(
|
||||
const to = readStringParam(params, "to", { required: true });
|
||||
const content = readStringParam(params, "content", { required: true });
|
||||
const mediaUrl = readStringParam(params, "mediaUrl");
|
||||
const replyTo = readStringParam(params, "replyTo");
|
||||
const result = await sendSlackMessage(to, content, {
|
||||
mediaUrl: mediaUrl ?? undefined,
|
||||
replyTo: replyTo ?? undefined,
|
||||
});
|
||||
return jsonResult({ ok: true, result });
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ export const SlackToolSchema = Type.Union([
|
||||
to: Type.String(),
|
||||
content: Type.String(),
|
||||
mediaUrl: Type.Optional(Type.String()),
|
||||
replyTo: Type.Optional(Type.String()),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("editMessage"),
|
||||
|
||||
@@ -63,7 +63,10 @@ describe("resolveTextChunkLimit", () => {
|
||||
});
|
||||
|
||||
it("uses the matching provider override", () => {
|
||||
const cfg = { discord: { textChunkLimit: 111 }, slack: { textChunkLimit: 222 } };
|
||||
const cfg = {
|
||||
discord: { textChunkLimit: 111 },
|
||||
slack: { textChunkLimit: 222 },
|
||||
};
|
||||
expect(resolveTextChunkLimit(cfg, "discord")).toBe(111);
|
||||
expect(resolveTextChunkLimit(cfg, "slack")).toBe(222);
|
||||
expect(resolveTextChunkLimit(cfg, "telegram")).toBe(4000);
|
||||
|
||||
@@ -2,12 +2,14 @@ import { sendMessageDiscord } from "../discord/send.js";
|
||||
import { sendMessageIMessage } from "../imessage/send.js";
|
||||
import { logWebSelfId, sendMessageWhatsApp } from "../providers/web/index.js";
|
||||
import { sendMessageSignal } from "../signal/send.js";
|
||||
import { sendMessageSlack } from "../slack/send.js";
|
||||
import { sendMessageTelegram } from "../telegram/send.js";
|
||||
|
||||
export type CliDeps = {
|
||||
sendMessageWhatsApp: typeof sendMessageWhatsApp;
|
||||
sendMessageTelegram: typeof sendMessageTelegram;
|
||||
sendMessageDiscord: typeof sendMessageDiscord;
|
||||
sendMessageSlack: typeof sendMessageSlack;
|
||||
sendMessageSignal: typeof sendMessageSignal;
|
||||
sendMessageIMessage: typeof sendMessageIMessage;
|
||||
};
|
||||
@@ -17,6 +19,7 @@ export function createDefaultDeps(): CliDeps {
|
||||
sendMessageWhatsApp,
|
||||
sendMessageTelegram,
|
||||
sendMessageDiscord,
|
||||
sendMessageSlack,
|
||||
sendMessageSignal,
|
||||
sendMessageIMessage,
|
||||
};
|
||||
|
||||
@@ -300,7 +300,7 @@ export function buildProgram() {
|
||||
program
|
||||
.command("send")
|
||||
.description(
|
||||
"Send a message (WhatsApp Web, Telegram bot, Discord, Signal, iMessage)",
|
||||
"Send a message (WhatsApp Web, Telegram bot, Discord, Slack, Signal, iMessage)",
|
||||
)
|
||||
.requiredOption(
|
||||
"-t, --to <number>",
|
||||
@@ -318,7 +318,7 @@ export function buildProgram() {
|
||||
)
|
||||
.option(
|
||||
"--provider <provider>",
|
||||
"Delivery provider: whatsapp|telegram|discord|signal|imessage (default: whatsapp)",
|
||||
"Delivery provider: whatsapp|telegram|discord|slack|signal|imessage (default: whatsapp)",
|
||||
)
|
||||
.option("--dry-run", "Print payload and skip sending", false)
|
||||
.option("--json", "Output result as JSON", false)
|
||||
@@ -361,7 +361,7 @@ Examples:
|
||||
.option("--verbose <on|off>", "Persist agent verbose level for the session")
|
||||
.option(
|
||||
"--provider <provider>",
|
||||
"Delivery provider: whatsapp|telegram|discord|signal|imessage (default: whatsapp)",
|
||||
"Delivery provider: whatsapp|telegram|discord|slack|signal|imessage (default: whatsapp)",
|
||||
)
|
||||
.option(
|
||||
"--deliver",
|
||||
@@ -411,7 +411,7 @@ Examples:
|
||||
.option("--json", "Output JSON instead of text", false)
|
||||
.option(
|
||||
"--deep",
|
||||
"Probe providers (WhatsApp Web + Telegram + Discord + Signal)",
|
||||
"Probe providers (WhatsApp Web + Telegram + Discord + Slack + Signal)",
|
||||
false,
|
||||
)
|
||||
.option("--timeout <ms>", "Probe timeout in milliseconds", "10000")
|
||||
@@ -422,7 +422,7 @@ Examples:
|
||||
Examples:
|
||||
clawdis status # show linked account + session store summary
|
||||
clawdis status --json # machine-readable output
|
||||
clawdis status --deep # run provider probes (WA + Telegram + Discord + Signal)
|
||||
clawdis status --deep # run provider probes (WA + Telegram + Discord + Slack + Signal)
|
||||
clawdis status --deep --timeout 5000 # tighten probe timeout`,
|
||||
)
|
||||
.action(async (opts) => {
|
||||
|
||||
@@ -473,6 +473,7 @@ export async function agentCommand(
|
||||
const whatsappTarget = opts.to ? normalizeE164(opts.to) : allowFrom[0];
|
||||
const telegramTarget = opts.to?.trim() || undefined;
|
||||
const discordTarget = opts.to?.trim() || undefined;
|
||||
const slackTarget = opts.to?.trim() || undefined;
|
||||
const signalTarget = opts.to?.trim() || undefined;
|
||||
const imessageTarget = opts.to?.trim() || undefined;
|
||||
|
||||
@@ -484,11 +485,13 @@ export async function agentCommand(
|
||||
? whatsappTarget
|
||||
: deliveryProvider === "discord"
|
||||
? discordTarget
|
||||
: deliveryProvider === "signal"
|
||||
? signalTarget
|
||||
: deliveryProvider === "imessage"
|
||||
? imessageTarget
|
||||
: undefined;
|
||||
: deliveryProvider === "slack"
|
||||
? slackTarget
|
||||
: deliveryProvider === "signal"
|
||||
? signalTarget
|
||||
: deliveryProvider === "imessage"
|
||||
? imessageTarget
|
||||
: undefined;
|
||||
const message = `Delivery failed (${deliveryProvider}${deliveryTarget ? ` to ${deliveryTarget}` : ""}): ${String(err)}`;
|
||||
runtime.error?.(message);
|
||||
if (!runtime.error) runtime.log(message);
|
||||
@@ -514,6 +517,13 @@ export async function agentCommand(
|
||||
if (!bestEffortDeliver) throw err;
|
||||
logDeliveryError(err);
|
||||
}
|
||||
if (deliveryProvider === "slack" && !slackTarget) {
|
||||
const err = new Error(
|
||||
"Delivering to Slack requires --to <channelId|user:ID|channel:ID>",
|
||||
);
|
||||
if (!bestEffortDeliver) throw err;
|
||||
logDeliveryError(err);
|
||||
}
|
||||
if (deliveryProvider === "signal" && !signalTarget) {
|
||||
const err = new Error(
|
||||
"Delivering to Signal requires --to <E.164|group:ID|signal:group:ID|signal:+E.164>",
|
||||
@@ -539,6 +549,7 @@ export async function agentCommand(
|
||||
deliveryProvider !== "whatsapp" &&
|
||||
deliveryProvider !== "telegram" &&
|
||||
deliveryProvider !== "discord" &&
|
||||
deliveryProvider !== "slack" &&
|
||||
deliveryProvider !== "signal" &&
|
||||
deliveryProvider !== "imessage" &&
|
||||
deliveryProvider !== "webchat"
|
||||
@@ -574,6 +585,7 @@ export async function agentCommand(
|
||||
deliveryProvider === "whatsapp" ||
|
||||
deliveryProvider === "telegram" ||
|
||||
deliveryProvider === "discord" ||
|
||||
deliveryProvider === "slack" ||
|
||||
deliveryProvider === "signal" ||
|
||||
deliveryProvider === "imessage"
|
||||
? resolveTextChunkLimit(cfg, deliveryProvider)
|
||||
@@ -666,6 +678,26 @@ export async function agentCommand(
|
||||
}
|
||||
}
|
||||
|
||||
if (deliveryProvider === "slack" && slackTarget) {
|
||||
try {
|
||||
if (media.length === 0) {
|
||||
await deps.sendMessageSlack(slackTarget, text);
|
||||
} else {
|
||||
let first = true;
|
||||
for (const url of media) {
|
||||
const caption = first ? text : "";
|
||||
first = false;
|
||||
await deps.sendMessageSlack(slackTarget, caption, {
|
||||
mediaUrl: url,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!bestEffortDeliver) throw err;
|
||||
logDeliveryError(err);
|
||||
}
|
||||
}
|
||||
|
||||
if (deliveryProvider === "signal" && signalTarget) {
|
||||
try {
|
||||
if (media.length === 0) {
|
||||
|
||||
@@ -31,6 +31,7 @@ async function noteProviderPrimer(prompter: WizardPrompter): Promise<void> {
|
||||
"WhatsApp: dedicated second number recommended; primary number OK (self-chat).",
|
||||
"Telegram: Bot API (token from @BotFather), replies via your bot.",
|
||||
"Discord: Bot token from Discord Developer Portal; invite bot to your server.",
|
||||
"Slack: Socket Mode app token + bot token, DMs via App Home Messages tab.",
|
||||
"Signal: signal-cli as a linked device; separate number recommended.",
|
||||
"iMessage: local imsg CLI; separate Apple ID recommended only on a separate Mac.",
|
||||
].join("\n"),
|
||||
@@ -74,6 +75,10 @@ function buildSlackManifest(botName: string) {
|
||||
display_name: safeName,
|
||||
always_online: false,
|
||||
},
|
||||
app_home: {
|
||||
messages_tab_enabled: true,
|
||||
messages_tab_read_only_enabled: false,
|
||||
},
|
||||
slash_commands: [
|
||||
{
|
||||
command: "/clawd",
|
||||
@@ -94,6 +99,7 @@ function buildSlackManifest(botName: string) {
|
||||
"users:read",
|
||||
"app_mentions:read",
|
||||
"reactions:read",
|
||||
"reactions:write",
|
||||
"pins:read",
|
||||
"pins:write",
|
||||
"emoji:read",
|
||||
@@ -137,6 +143,7 @@ async function noteSlackTokenHelp(
|
||||
"2) Add Socket Mode + enable it to get the app-level token (xapp-...)",
|
||||
"3) OAuth & Permissions → install app to workspace (xoxb- bot token)",
|
||||
"4) Enable Event Subscriptions (socket) for message events",
|
||||
"5) App Home → enable the Messages tab for DMs",
|
||||
"Tip: set SLACK_BOT_TOKEN + SLACK_APP_TOKEN in your env.",
|
||||
"",
|
||||
"Manifest (JSON):",
|
||||
@@ -237,10 +244,16 @@ export async function setupProviders(
|
||||
const whatsappLinked = await detectWhatsAppLinked();
|
||||
const telegramEnv = Boolean(process.env.TELEGRAM_BOT_TOKEN?.trim());
|
||||
const discordEnv = Boolean(process.env.DISCORD_BOT_TOKEN?.trim());
|
||||
const slackBotEnv = Boolean(process.env.SLACK_BOT_TOKEN?.trim());
|
||||
const slackAppEnv = Boolean(process.env.SLACK_APP_TOKEN?.trim());
|
||||
const telegramConfigured = Boolean(
|
||||
telegramEnv || cfg.telegram?.botToken || cfg.telegram?.tokenFile,
|
||||
);
|
||||
const discordConfigured = Boolean(discordEnv || cfg.discord?.token);
|
||||
const slackConfigured = Boolean(
|
||||
(slackBotEnv && slackAppEnv) ||
|
||||
(cfg.slack?.botToken && cfg.slack?.appToken),
|
||||
);
|
||||
const signalConfigured = Boolean(
|
||||
cfg.signal?.account || cfg.signal?.httpUrl || cfg.signal?.httpPort,
|
||||
);
|
||||
@@ -257,6 +270,7 @@ export async function setupProviders(
|
||||
`WhatsApp: ${whatsappLinked ? "linked" : "not linked"}`,
|
||||
`Telegram: ${telegramConfigured ? "configured" : "needs token"}`,
|
||||
`Discord: ${discordConfigured ? "configured" : "needs token"}`,
|
||||
`Slack: ${slackConfigured ? "configured" : "needs tokens"}`,
|
||||
`Signal: ${signalConfigured ? "configured" : "needs setup"}`,
|
||||
`iMessage: ${imessageConfigured ? "configured" : "needs setup"}`,
|
||||
`signal-cli: ${signalCliDetected ? "found" : "missing"} (${signalCliPath})`,
|
||||
@@ -291,6 +305,11 @@ export async function setupProviders(
|
||||
label: "Discord (Bot API)",
|
||||
hint: discordConfigured ? "configured" : "needs token",
|
||||
},
|
||||
{
|
||||
value: "slack",
|
||||
label: "Slack (Socket Mode)",
|
||||
hint: slackConfigured ? "configured" : "needs tokens",
|
||||
},
|
||||
{
|
||||
value: "signal",
|
||||
label: "Signal (signal-cli)",
|
||||
@@ -695,6 +714,19 @@ export async function setupProviders(
|
||||
}
|
||||
}
|
||||
|
||||
if (!selection.includes("slack") && slackConfigured) {
|
||||
const disable = await prompter.confirm({
|
||||
message: "Disable Slack provider?",
|
||||
initialValue: false,
|
||||
});
|
||||
if (disable) {
|
||||
next = {
|
||||
...next,
|
||||
slack: { ...next.slack, enabled: false },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!selection.includes("signal") && signalConfigured) {
|
||||
const disable = await prompter.confirm({
|
||||
message: "Disable Signal provider?",
|
||||
@@ -724,4 +756,3 @@ export async function setupProviders(
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ export type ProviderChoice =
|
||||
| "whatsapp"
|
||||
| "telegram"
|
||||
| "discord"
|
||||
| "slack"
|
||||
| "signal"
|
||||
| "imessage";
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ const makeDeps = (overrides: Partial<CliDeps> = {}): CliDeps => ({
|
||||
sendMessageWhatsApp: vi.fn(),
|
||||
sendMessageTelegram: vi.fn(),
|
||||
sendMessageDiscord: vi.fn(),
|
||||
sendMessageSlack: vi.fn(),
|
||||
sendMessageSignal: vi.fn(),
|
||||
sendMessageIMessage: vi.fn(),
|
||||
...overrides,
|
||||
@@ -173,6 +174,25 @@ describe("sendCommand", () => {
|
||||
expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes to slack provider", async () => {
|
||||
const deps = makeDeps({
|
||||
sendMessageSlack: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ messageId: "s1", channelId: "C123" }),
|
||||
});
|
||||
await sendCommand(
|
||||
{ to: "channel:C123", message: "hi", provider: "slack" },
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(deps.sendMessageSlack).toHaveBeenCalledWith(
|
||||
"channel:C123",
|
||||
"hi",
|
||||
expect.objectContaining({ mediaUrl: undefined }),
|
||||
);
|
||||
expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes to imessage provider", async () => {
|
||||
const deps = makeDeps({
|
||||
sendMessageIMessage: vi.fn().mockResolvedValue({ messageId: "i1" }),
|
||||
|
||||
@@ -86,6 +86,34 @@ export async function sendCommand(
|
||||
return;
|
||||
}
|
||||
|
||||
if (provider === "slack") {
|
||||
const result = await deps.sendMessageSlack(opts.to, opts.message, {
|
||||
mediaUrl: opts.media,
|
||||
});
|
||||
runtime.log(
|
||||
success(
|
||||
`✅ Sent via slack. Message ID: ${result.messageId} (channel ${result.channelId})`,
|
||||
),
|
||||
);
|
||||
if (opts.json) {
|
||||
runtime.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
provider: "slack",
|
||||
via: "direct",
|
||||
to: opts.to,
|
||||
channelId: result.channelId,
|
||||
messageId: result.messageId,
|
||||
mediaUrl: opts.media ?? null,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (provider === "signal") {
|
||||
const result = await deps.sendMessageSignal(opts.to, opts.message, {
|
||||
mediaUrl: opts.media,
|
||||
|
||||
@@ -61,6 +61,7 @@ export type SessionEntry = {
|
||||
| "whatsapp"
|
||||
| "telegram"
|
||||
| "discord"
|
||||
| "slack"
|
||||
| "signal"
|
||||
| "imessage"
|
||||
| "webchat";
|
||||
|
||||
@@ -65,6 +65,7 @@ export type AgentElevatedAllowFromConfig = {
|
||||
whatsapp?: string[];
|
||||
telegram?: Array<string | number>;
|
||||
discord?: Array<string | number>;
|
||||
slack?: Array<string | number>;
|
||||
signal?: Array<string | number>;
|
||||
imessage?: Array<string | number>;
|
||||
webchat?: Array<string | number>;
|
||||
@@ -337,7 +338,6 @@ export type SlackConfig = {
|
||||
botToken?: string;
|
||||
appToken?: string;
|
||||
textChunkLimit?: number;
|
||||
replyToMode?: ReplyToMode;
|
||||
mediaMaxMb?: number;
|
||||
/** Reaction notification mode (off|own|all|allowlist). Default: own. */
|
||||
reactionNotifications?: SlackReactionNotificationMode;
|
||||
|
||||
@@ -417,6 +417,7 @@ export const ClawdisSchema = z.object({
|
||||
whatsapp: z.array(z.string()).optional(),
|
||||
telegram: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
discord: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
slack: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
signal: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
imessage: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
webchat: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
@@ -628,7 +629,6 @@ export const ClawdisSchema = z.object({
|
||||
botToken: z.string().optional(),
|
||||
appToken: z.string().optional(),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
replyToMode: ReplyToModeSchema.optional(),
|
||||
mediaMaxMb: z.number().positive().optional(),
|
||||
reactionNotifications: z
|
||||
.enum(["off", "own", "all", "allowlist"])
|
||||
|
||||
@@ -64,6 +64,7 @@ function resolveDeliveryTarget(
|
||||
| "whatsapp"
|
||||
| "telegram"
|
||||
| "discord"
|
||||
| "slack"
|
||||
| "signal"
|
||||
| "imessage";
|
||||
to?: string;
|
||||
@@ -92,6 +93,7 @@ function resolveDeliveryTarget(
|
||||
requestedChannel === "whatsapp" ||
|
||||
requestedChannel === "telegram" ||
|
||||
requestedChannel === "discord" ||
|
||||
requestedChannel === "slack" ||
|
||||
requestedChannel === "signal" ||
|
||||
requestedChannel === "imessage"
|
||||
) {
|
||||
@@ -447,6 +449,43 @@ export async function runCronIsolatedAgentTurn(params: {
|
||||
return { status: "error", summary, error: String(err) };
|
||||
return { status: "ok", summary };
|
||||
}
|
||||
} else if (resolvedDelivery.channel === "slack") {
|
||||
if (!resolvedDelivery.to) {
|
||||
if (!bestEffortDeliver)
|
||||
return {
|
||||
status: "error",
|
||||
summary,
|
||||
error:
|
||||
"Cron delivery to Slack requires --channel slack and --to <channelId|user:ID>",
|
||||
};
|
||||
return {
|
||||
status: "skipped",
|
||||
summary: "Delivery skipped (no Slack destination).",
|
||||
};
|
||||
}
|
||||
const slackTarget = resolvedDelivery.to;
|
||||
try {
|
||||
for (const payload of payloads) {
|
||||
const mediaList =
|
||||
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||
if (mediaList.length === 0) {
|
||||
await params.deps.sendMessageSlack(slackTarget, payload.text ?? "");
|
||||
} else {
|
||||
let first = true;
|
||||
for (const url of mediaList) {
|
||||
const caption = first ? (payload.text ?? "") : "";
|
||||
first = false;
|
||||
await params.deps.sendMessageSlack(slackTarget, caption, {
|
||||
mediaUrl: url,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!bestEffortDeliver)
|
||||
return { status: "error", summary, error: String(err) };
|
||||
return { status: "ok", summary };
|
||||
}
|
||||
} else if (resolvedDelivery.channel === "signal") {
|
||||
if (!resolvedDelivery.to) {
|
||||
if (!bestEffortDeliver)
|
||||
|
||||
@@ -19,6 +19,7 @@ export type CronPayload =
|
||||
| "whatsapp"
|
||||
| "telegram"
|
||||
| "discord"
|
||||
| "slack"
|
||||
| "signal"
|
||||
| "imessage";
|
||||
to?: string;
|
||||
|
||||
@@ -15,6 +15,7 @@ export type ProviderKind =
|
||||
| "whatsapp"
|
||||
| "telegram"
|
||||
| "discord"
|
||||
| "slack"
|
||||
| "signal"
|
||||
| "imessage";
|
||||
|
||||
@@ -47,6 +48,7 @@ type ReloadAction =
|
||||
| "restart-provider:whatsapp"
|
||||
| "restart-provider:telegram"
|
||||
| "restart-provider:discord"
|
||||
| "restart-provider:slack"
|
||||
| "restart-provider:signal"
|
||||
| "restart-provider:imessage";
|
||||
|
||||
@@ -70,6 +72,7 @@ const RELOAD_RULES: ReloadRule[] = [
|
||||
{ prefix: "web", kind: "hot", actions: ["restart-provider:whatsapp"] },
|
||||
{ prefix: "telegram", kind: "hot", actions: ["restart-provider:telegram"] },
|
||||
{ prefix: "discord", kind: "hot", actions: ["restart-provider:discord"] },
|
||||
{ prefix: "slack", kind: "hot", actions: ["restart-provider:slack"] },
|
||||
{ prefix: "signal", kind: "hot", actions: ["restart-provider:signal"] },
|
||||
{ prefix: "imessage", kind: "hot", actions: ["restart-provider:imessage"] },
|
||||
{ prefix: "identity", kind: "none" },
|
||||
@@ -200,6 +203,9 @@ export function buildGatewayReloadPlan(
|
||||
case "restart-provider:discord":
|
||||
plan.restartProviders.add("discord");
|
||||
break;
|
||||
case "restart-provider:slack":
|
||||
plan.restartProviders.add("slack");
|
||||
break;
|
||||
case "restart-provider:signal":
|
||||
plan.restartProviders.add("signal");
|
||||
break;
|
||||
|
||||
@@ -23,6 +23,7 @@ export type HookMappingResolved = {
|
||||
| "whatsapp"
|
||||
| "telegram"
|
||||
| "discord"
|
||||
| "slack"
|
||||
| "signal"
|
||||
| "imessage";
|
||||
to?: string;
|
||||
@@ -61,6 +62,7 @@ export type HookAction =
|
||||
| "whatsapp"
|
||||
| "telegram"
|
||||
| "discord"
|
||||
| "slack"
|
||||
| "signal"
|
||||
| "imessage";
|
||||
to?: string;
|
||||
@@ -99,7 +101,14 @@ type HookTransformResult = Partial<{
|
||||
name: string;
|
||||
sessionKey: string;
|
||||
deliver: boolean;
|
||||
channel: "last" | "whatsapp" | "telegram" | "discord" | "signal" | "imessage";
|
||||
channel:
|
||||
| "last"
|
||||
| "whatsapp"
|
||||
| "telegram"
|
||||
| "discord"
|
||||
| "slack"
|
||||
| "signal"
|
||||
| "imessage";
|
||||
to: string;
|
||||
thinking: string;
|
||||
timeoutSeconds: number;
|
||||
|
||||
@@ -635,6 +635,7 @@ export const CronPayloadSchema = Type.Union([
|
||||
Type.Literal("whatsapp"),
|
||||
Type.Literal("telegram"),
|
||||
Type.Literal("discord"),
|
||||
Type.Literal("slack"),
|
||||
]),
|
||||
),
|
||||
to: Type.Optional(Type.String()),
|
||||
|
||||
@@ -37,6 +37,7 @@ type HookDispatchers = {
|
||||
| "whatsapp"
|
||||
| "telegram"
|
||||
| "discord"
|
||||
| "slack"
|
||||
| "signal"
|
||||
| "imessage";
|
||||
to?: string;
|
||||
|
||||
@@ -8,6 +8,11 @@ import { type DiscordProbe, probeDiscord } from "../../discord/probe.js";
|
||||
import { type IMessageProbe, probeIMessage } from "../../imessage/probe.js";
|
||||
import { webAuthExists } from "../../providers/web/index.js";
|
||||
import { probeSignal, type SignalProbe } from "../../signal/probe.js";
|
||||
import { probeSlack, type SlackProbe } from "../../slack/probe.js";
|
||||
import {
|
||||
resolveSlackAppToken,
|
||||
resolveSlackBotToken,
|
||||
} from "../../slack/token.js";
|
||||
import { probeTelegram, type TelegramProbe } from "../../telegram/probe.js";
|
||||
import { resolveTelegramToken } from "../../telegram/token.js";
|
||||
import { getWebAuthAgeMs, readWebSelfId } from "../../web/session.js";
|
||||
@@ -74,6 +79,41 @@ export const providersHandlers: GatewayRequestHandlers = {
|
||||
discordLastProbeAt = Date.now();
|
||||
}
|
||||
|
||||
const slackCfg = cfg.slack;
|
||||
const slackEnabled = slackCfg?.enabled !== false;
|
||||
const slackBotEnvToken = slackEnabled
|
||||
? resolveSlackBotToken(process.env.SLACK_BOT_TOKEN)
|
||||
: undefined;
|
||||
const slackBotConfigToken = slackEnabled
|
||||
? resolveSlackBotToken(slackCfg?.botToken)
|
||||
: undefined;
|
||||
const slackBotToken = slackBotEnvToken ?? slackBotConfigToken ?? "";
|
||||
const slackBotTokenSource = slackBotEnvToken
|
||||
? "env"
|
||||
: slackBotConfigToken
|
||||
? "config"
|
||||
: "none";
|
||||
const slackAppEnvToken = slackEnabled
|
||||
? resolveSlackAppToken(process.env.SLACK_APP_TOKEN)
|
||||
: undefined;
|
||||
const slackAppConfigToken = slackEnabled
|
||||
? resolveSlackAppToken(slackCfg?.appToken)
|
||||
: undefined;
|
||||
const slackAppToken = slackAppEnvToken ?? slackAppConfigToken ?? "";
|
||||
const slackAppTokenSource = slackAppEnvToken
|
||||
? "env"
|
||||
: slackAppConfigToken
|
||||
? "config"
|
||||
: "none";
|
||||
const slackConfigured =
|
||||
slackEnabled && Boolean(slackBotToken) && Boolean(slackAppToken);
|
||||
let slackProbe: SlackProbe | undefined;
|
||||
let slackLastProbeAt: number | null = null;
|
||||
if (probe && slackConfigured) {
|
||||
slackProbe = await probeSlack(slackBotToken, timeoutMs);
|
||||
slackLastProbeAt = Date.now();
|
||||
}
|
||||
|
||||
const signalCfg = cfg.signal;
|
||||
const signalEnabled = signalCfg?.enabled !== false;
|
||||
const signalHost = signalCfg?.httpHost?.trim() || "127.0.0.1";
|
||||
@@ -152,6 +192,17 @@ export const providersHandlers: GatewayRequestHandlers = {
|
||||
probe: discordProbe,
|
||||
lastProbeAt: discordLastProbeAt,
|
||||
},
|
||||
slack: {
|
||||
configured: slackConfigured,
|
||||
botTokenSource: slackBotTokenSource,
|
||||
appTokenSource: slackAppTokenSource,
|
||||
running: runtime.slack.running,
|
||||
lastStartAt: runtime.slack.lastStartAt ?? null,
|
||||
lastStopAt: runtime.slack.lastStopAt ?? null,
|
||||
lastError: runtime.slack.lastError ?? null,
|
||||
probe: slackProbe,
|
||||
lastProbeAt: slackLastProbeAt,
|
||||
},
|
||||
signal: {
|
||||
configured: signalConfigured,
|
||||
baseUrl: signalBaseUrl,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { sendMessageDiscord } from "../../discord/index.js";
|
||||
import { shouldLogVerbose } from "../../globals.js";
|
||||
import { sendMessageIMessage } from "../../imessage/index.js";
|
||||
import { sendMessageSignal } from "../../signal/index.js";
|
||||
import { sendMessageSlack } from "../../slack/send.js";
|
||||
import { sendMessageTelegram } from "../../telegram/send.js";
|
||||
import { resolveTelegramToken } from "../../telegram/token.js";
|
||||
import { sendMessageWhatsApp } from "../../web/outbound.js";
|
||||
@@ -87,6 +88,22 @@ export const sendHandlers: GatewayRequestHandlers = {
|
||||
payload,
|
||||
});
|
||||
respond(true, payload, undefined, { provider });
|
||||
} else if (provider === "slack") {
|
||||
const result = await sendMessageSlack(to, message, {
|
||||
mediaUrl: request.mediaUrl,
|
||||
});
|
||||
const payload = {
|
||||
runId: idem,
|
||||
messageId: result.messageId,
|
||||
channelId: result.channelId,
|
||||
provider,
|
||||
};
|
||||
context.dedupe.set(`send:${idem}`, {
|
||||
ts: Date.now(),
|
||||
ok: true,
|
||||
payload,
|
||||
});
|
||||
respond(true, payload, undefined, { provider });
|
||||
} else if (provider === "signal") {
|
||||
const cfg = loadConfig();
|
||||
const host = cfg.signal?.httpHost?.trim() || "127.0.0.1";
|
||||
|
||||
@@ -7,6 +7,11 @@ import type { createSubsystemLogger } from "../logging.js";
|
||||
import { monitorWebProvider, webAuthExists } from "../providers/web/index.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { monitorSignalProvider } from "../signal/index.js";
|
||||
import {
|
||||
monitorSlackProvider,
|
||||
resolveSlackAppToken,
|
||||
resolveSlackBotToken,
|
||||
} from "../slack/index.js";
|
||||
import { monitorTelegramProvider } from "../telegram/monitor.js";
|
||||
import { probeTelegram } from "../telegram/probe.js";
|
||||
import { resolveTelegramToken } from "../telegram/token.js";
|
||||
@@ -29,6 +34,13 @@ export type DiscordRuntimeStatus = {
|
||||
lastError?: string | null;
|
||||
};
|
||||
|
||||
export type SlackRuntimeStatus = {
|
||||
running: boolean;
|
||||
lastStartAt?: number | null;
|
||||
lastStopAt?: number | null;
|
||||
lastError?: string | null;
|
||||
};
|
||||
|
||||
export type SignalRuntimeStatus = {
|
||||
running: boolean;
|
||||
lastStartAt?: number | null;
|
||||
@@ -50,6 +62,7 @@ export type ProviderRuntimeSnapshot = {
|
||||
whatsapp: WebProviderStatus;
|
||||
telegram: TelegramRuntimeStatus;
|
||||
discord: DiscordRuntimeStatus;
|
||||
slack: SlackRuntimeStatus;
|
||||
signal: SignalRuntimeStatus;
|
||||
imessage: IMessageRuntimeStatus;
|
||||
};
|
||||
@@ -61,11 +74,13 @@ type ProviderManagerOptions = {
|
||||
logWhatsApp: SubsystemLogger;
|
||||
logTelegram: SubsystemLogger;
|
||||
logDiscord: SubsystemLogger;
|
||||
logSlack: SubsystemLogger;
|
||||
logSignal: SubsystemLogger;
|
||||
logIMessage: SubsystemLogger;
|
||||
whatsappRuntimeEnv: RuntimeEnv;
|
||||
telegramRuntimeEnv: RuntimeEnv;
|
||||
discordRuntimeEnv: RuntimeEnv;
|
||||
slackRuntimeEnv: RuntimeEnv;
|
||||
signalRuntimeEnv: RuntimeEnv;
|
||||
imessageRuntimeEnv: RuntimeEnv;
|
||||
};
|
||||
@@ -79,6 +94,8 @@ export type ProviderManager = {
|
||||
stopTelegramProvider: () => Promise<void>;
|
||||
startDiscordProvider: () => Promise<void>;
|
||||
stopDiscordProvider: () => Promise<void>;
|
||||
startSlackProvider: () => Promise<void>;
|
||||
stopSlackProvider: () => Promise<void>;
|
||||
startSignalProvider: () => Promise<void>;
|
||||
stopSignalProvider: () => Promise<void>;
|
||||
startIMessageProvider: () => Promise<void>;
|
||||
@@ -94,11 +111,13 @@ export function createProviderManager(
|
||||
logWhatsApp,
|
||||
logTelegram,
|
||||
logDiscord,
|
||||
logSlack,
|
||||
logSignal,
|
||||
logIMessage,
|
||||
whatsappRuntimeEnv,
|
||||
telegramRuntimeEnv,
|
||||
discordRuntimeEnv,
|
||||
slackRuntimeEnv,
|
||||
signalRuntimeEnv,
|
||||
imessageRuntimeEnv,
|
||||
} = opts;
|
||||
@@ -106,11 +125,13 @@ export function createProviderManager(
|
||||
let whatsappAbort: AbortController | null = null;
|
||||
let telegramAbort: AbortController | null = null;
|
||||
let discordAbort: AbortController | null = null;
|
||||
let slackAbort: AbortController | null = null;
|
||||
let signalAbort: AbortController | null = null;
|
||||
let imessageAbort: AbortController | null = null;
|
||||
let whatsappTask: Promise<unknown> | null = null;
|
||||
let telegramTask: Promise<unknown> | null = null;
|
||||
let discordTask: Promise<unknown> | null = null;
|
||||
let slackTask: Promise<unknown> | null = null;
|
||||
let signalTask: Promise<unknown> | null = null;
|
||||
let imessageTask: Promise<unknown> | null = null;
|
||||
|
||||
@@ -137,6 +158,12 @@ export function createProviderManager(
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
};
|
||||
let slackRuntime: SlackRuntimeStatus = {
|
||||
running: false,
|
||||
lastStartAt: null,
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
};
|
||||
let signalRuntime: SignalRuntimeStatus = {
|
||||
running: false,
|
||||
lastStartAt: null,
|
||||
@@ -432,6 +459,93 @@ export function createProviderManager(
|
||||
};
|
||||
};
|
||||
|
||||
const startSlackProvider = async () => {
|
||||
if (slackTask) return;
|
||||
const cfg = loadConfig();
|
||||
if (cfg.slack?.enabled === false) {
|
||||
slackRuntime = {
|
||||
...slackRuntime,
|
||||
running: false,
|
||||
lastError: "disabled",
|
||||
};
|
||||
if (shouldLogVerbose()) {
|
||||
logSlack.debug("slack provider disabled (slack.enabled=false)");
|
||||
}
|
||||
return;
|
||||
}
|
||||
const botToken = resolveSlackBotToken(
|
||||
process.env.SLACK_BOT_TOKEN ?? cfg.slack?.botToken ?? undefined,
|
||||
);
|
||||
const appToken = resolveSlackAppToken(
|
||||
process.env.SLACK_APP_TOKEN ?? cfg.slack?.appToken ?? undefined,
|
||||
);
|
||||
if (!botToken || !appToken) {
|
||||
slackRuntime = {
|
||||
...slackRuntime,
|
||||
running: false,
|
||||
lastError: "not configured",
|
||||
};
|
||||
if (shouldLogVerbose()) {
|
||||
logSlack.debug(
|
||||
"slack provider not configured (missing SLACK_BOT_TOKEN/SLACK_APP_TOKEN)",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
logSlack.info(
|
||||
`starting provider${cfg.slack ? "" : " (no slack config; tokens via env)"}`,
|
||||
);
|
||||
slackAbort = new AbortController();
|
||||
slackRuntime = {
|
||||
...slackRuntime,
|
||||
running: true,
|
||||
lastStartAt: Date.now(),
|
||||
lastError: null,
|
||||
};
|
||||
const task = monitorSlackProvider({
|
||||
botToken,
|
||||
appToken,
|
||||
runtime: slackRuntimeEnv,
|
||||
abortSignal: slackAbort.signal,
|
||||
mediaMaxMb: cfg.slack?.mediaMaxMb,
|
||||
slashCommand: cfg.slack?.slashCommand,
|
||||
})
|
||||
.catch((err) => {
|
||||
slackRuntime = {
|
||||
...slackRuntime,
|
||||
lastError: formatError(err),
|
||||
};
|
||||
logSlack.error(`provider exited: ${formatError(err)}`);
|
||||
})
|
||||
.finally(() => {
|
||||
slackAbort = null;
|
||||
slackTask = null;
|
||||
slackRuntime = {
|
||||
...slackRuntime,
|
||||
running: false,
|
||||
lastStopAt: Date.now(),
|
||||
};
|
||||
});
|
||||
slackTask = task;
|
||||
};
|
||||
|
||||
const stopSlackProvider = async () => {
|
||||
if (!slackAbort && !slackTask) return;
|
||||
slackAbort?.abort();
|
||||
try {
|
||||
await slackTask;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
slackAbort = null;
|
||||
slackTask = null;
|
||||
slackRuntime = {
|
||||
...slackRuntime,
|
||||
running: false,
|
||||
lastStopAt: Date.now(),
|
||||
};
|
||||
};
|
||||
|
||||
const startSignalProvider = async () => {
|
||||
if (signalTask) return;
|
||||
const cfg = loadConfig();
|
||||
@@ -634,6 +748,7 @@ export function createProviderManager(
|
||||
const startProviders = async () => {
|
||||
await startWhatsAppProvider();
|
||||
await startDiscordProvider();
|
||||
await startSlackProvider();
|
||||
await startTelegramProvider();
|
||||
await startSignalProvider();
|
||||
await startIMessageProvider();
|
||||
@@ -652,6 +767,7 @@ export function createProviderManager(
|
||||
whatsapp: { ...whatsappRuntime },
|
||||
telegram: { ...telegramRuntime },
|
||||
discord: { ...discordRuntime },
|
||||
slack: { ...slackRuntime },
|
||||
signal: { ...signalRuntime },
|
||||
imessage: { ...imessageRuntime },
|
||||
});
|
||||
@@ -665,6 +781,8 @@ export function createProviderManager(
|
||||
stopTelegramProvider,
|
||||
startDiscordProvider,
|
||||
stopDiscordProvider,
|
||||
startSlackProvider,
|
||||
stopSlackProvider,
|
||||
startSignalProvider,
|
||||
stopSignalProvider,
|
||||
startIMessageProvider,
|
||||
|
||||
@@ -55,6 +55,12 @@ const hoisted = vi.hoisted(() => {
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
},
|
||||
slack: {
|
||||
running: false,
|
||||
lastStartAt: null,
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
},
|
||||
signal: {
|
||||
running: false,
|
||||
lastStartAt: null,
|
||||
@@ -78,6 +84,8 @@ const hoisted = vi.hoisted(() => {
|
||||
stopTelegramProvider: vi.fn(async () => {}),
|
||||
startDiscordProvider: vi.fn(async () => {}),
|
||||
stopDiscordProvider: vi.fn(async () => {}),
|
||||
startSlackProvider: vi.fn(async () => {}),
|
||||
stopSlackProvider: vi.fn(async () => {}),
|
||||
startSignalProvider: vi.fn(async () => {}),
|
||||
stopSignalProvider: vi.fn(async () => {}),
|
||||
startIMessageProvider: vi.fn(async () => {}),
|
||||
|
||||
@@ -148,12 +148,14 @@ const logWsControl = log.child("ws");
|
||||
const logWhatsApp = logProviders.child("whatsapp");
|
||||
const logTelegram = logProviders.child("telegram");
|
||||
const logDiscord = logProviders.child("discord");
|
||||
const logSlack = logProviders.child("slack");
|
||||
const logSignal = logProviders.child("signal");
|
||||
const logIMessage = logProviders.child("imessage");
|
||||
const canvasRuntime = runtimeForLogger(logCanvas);
|
||||
const whatsappRuntimeEnv = runtimeForLogger(logWhatsApp);
|
||||
const telegramRuntimeEnv = runtimeForLogger(logTelegram);
|
||||
const discordRuntimeEnv = runtimeForLogger(logDiscord);
|
||||
const slackRuntimeEnv = runtimeForLogger(logSlack);
|
||||
const signalRuntimeEnv = runtimeForLogger(logSignal);
|
||||
const imessageRuntimeEnv = runtimeForLogger(logIMessage);
|
||||
|
||||
@@ -478,6 +480,7 @@ export async function startGatewayServer(
|
||||
| "whatsapp"
|
||||
| "telegram"
|
||||
| "discord"
|
||||
| "slack"
|
||||
| "signal"
|
||||
| "imessage";
|
||||
to?: string;
|
||||
@@ -722,11 +725,13 @@ export async function startGatewayServer(
|
||||
logWhatsApp,
|
||||
logTelegram,
|
||||
logDiscord,
|
||||
logSlack,
|
||||
logSignal,
|
||||
logIMessage,
|
||||
whatsappRuntimeEnv,
|
||||
telegramRuntimeEnv,
|
||||
discordRuntimeEnv,
|
||||
slackRuntimeEnv,
|
||||
signalRuntimeEnv,
|
||||
imessageRuntimeEnv,
|
||||
});
|
||||
@@ -736,11 +741,13 @@ export async function startGatewayServer(
|
||||
startWhatsAppProvider,
|
||||
startTelegramProvider,
|
||||
startDiscordProvider,
|
||||
startSlackProvider,
|
||||
startSignalProvider,
|
||||
startIMessageProvider,
|
||||
stopWhatsAppProvider,
|
||||
stopTelegramProvider,
|
||||
stopDiscordProvider,
|
||||
stopSlackProvider,
|
||||
stopSignalProvider,
|
||||
stopIMessageProvider,
|
||||
markWhatsAppLoggedOut,
|
||||
@@ -1593,7 +1600,7 @@ export async function startGatewayServer(
|
||||
}
|
||||
}
|
||||
|
||||
// Launch configured providers (WhatsApp Web, Discord, Telegram) so gateway replies via the
|
||||
// Launch configured providers (WhatsApp Web, Discord, Slack, Telegram) so gateway replies via the
|
||||
// surface the message came from. Tests can opt out via CLAWDIS_SKIP_PROVIDERS.
|
||||
if (process.env.CLAWDIS_SKIP_PROVIDERS !== "1") {
|
||||
try {
|
||||
@@ -1703,6 +1710,9 @@ export async function startGatewayServer(
|
||||
startDiscordProvider,
|
||||
);
|
||||
}
|
||||
if (plan.restartProviders.has("slack")) {
|
||||
await restartProvider("slack", stopSlackProvider, startSlackProvider);
|
||||
}
|
||||
if (plan.restartProviders.has("signal")) {
|
||||
await restartProvider(
|
||||
"signal",
|
||||
@@ -1806,6 +1816,7 @@ export async function startGatewayServer(
|
||||
await stopWhatsAppProvider();
|
||||
await stopTelegramProvider();
|
||||
await stopDiscordProvider();
|
||||
await stopSlackProvider();
|
||||
await stopSignalProvider();
|
||||
await stopIMessageProvider();
|
||||
await stopGmailWatcher();
|
||||
|
||||
@@ -22,6 +22,7 @@ import { getQueueSize } from "../process/command-queue.js";
|
||||
import { webAuthExists } from "../providers/web/index.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
import { sendMessageSignal } from "../signal/send.js";
|
||||
import { sendMessageSlack } from "../slack/send.js";
|
||||
import { sendMessageTelegram } from "../telegram/send.js";
|
||||
import { normalizeE164 } from "../utils.js";
|
||||
import { getActiveWebListener } from "../web/active-listener.js";
|
||||
@@ -38,12 +39,20 @@ export type HeartbeatTarget =
|
||||
| "whatsapp"
|
||||
| "telegram"
|
||||
| "discord"
|
||||
| "slack"
|
||||
| "signal"
|
||||
| "imessage"
|
||||
| "none";
|
||||
|
||||
export type HeartbeatDeliveryTarget = {
|
||||
channel: "whatsapp" | "telegram" | "discord" | "signal" | "imessage" | "none";
|
||||
channel:
|
||||
| "whatsapp"
|
||||
| "telegram"
|
||||
| "discord"
|
||||
| "slack"
|
||||
| "signal"
|
||||
| "imessage"
|
||||
| "none";
|
||||
to?: string;
|
||||
reason?: string;
|
||||
};
|
||||
@@ -53,6 +62,7 @@ type HeartbeatDeps = {
|
||||
sendWhatsApp?: typeof sendMessageWhatsApp;
|
||||
sendTelegram?: typeof sendMessageTelegram;
|
||||
sendDiscord?: typeof sendMessageDiscord;
|
||||
sendSlack?: typeof sendMessageSlack;
|
||||
sendSignal?: typeof sendMessageSignal;
|
||||
sendIMessage?: typeof sendMessageIMessage;
|
||||
getQueueSize?: (lane?: string) => number;
|
||||
@@ -183,6 +193,7 @@ export function resolveHeartbeatDeliveryTarget(params: {
|
||||
rawTarget === "whatsapp" ||
|
||||
rawTarget === "telegram" ||
|
||||
rawTarget === "discord" ||
|
||||
rawTarget === "slack" ||
|
||||
rawTarget === "signal" ||
|
||||
rawTarget === "imessage" ||
|
||||
rawTarget === "none" ||
|
||||
@@ -209,6 +220,7 @@ export function resolveHeartbeatDeliveryTarget(params: {
|
||||
| "whatsapp"
|
||||
| "telegram"
|
||||
| "discord"
|
||||
| "slack"
|
||||
| "signal"
|
||||
| "imessage"
|
||||
| undefined =
|
||||
@@ -217,6 +229,7 @@ export function resolveHeartbeatDeliveryTarget(params: {
|
||||
: target === "whatsapp" ||
|
||||
target === "telegram" ||
|
||||
target === "discord" ||
|
||||
target === "slack" ||
|
||||
target === "signal" ||
|
||||
target === "imessage"
|
||||
? target
|
||||
@@ -288,7 +301,13 @@ function normalizeHeartbeatReply(
|
||||
}
|
||||
|
||||
async function deliverHeartbeatReply(params: {
|
||||
channel: "whatsapp" | "telegram" | "discord" | "signal" | "imessage";
|
||||
channel:
|
||||
| "whatsapp"
|
||||
| "telegram"
|
||||
| "discord"
|
||||
| "slack"
|
||||
| "signal"
|
||||
| "imessage";
|
||||
to: string;
|
||||
text: string;
|
||||
mediaUrls: string[];
|
||||
@@ -299,6 +318,7 @@ async function deliverHeartbeatReply(params: {
|
||||
| "sendWhatsApp"
|
||||
| "sendTelegram"
|
||||
| "sendDiscord"
|
||||
| "sendSlack"
|
||||
| "sendSignal"
|
||||
| "sendIMessage"
|
||||
>
|
||||
@@ -369,6 +389,20 @@ async function deliverHeartbeatReply(params: {
|
||||
return;
|
||||
}
|
||||
|
||||
if (channel === "slack") {
|
||||
if (mediaUrls.length === 0) {
|
||||
await deps.sendSlack(to, text);
|
||||
return;
|
||||
}
|
||||
let first = true;
|
||||
for (const url of mediaUrls) {
|
||||
const caption = first ? text : "";
|
||||
first = false;
|
||||
await deps.sendSlack(to, caption, { mediaUrl: url });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mediaUrls.length === 0) {
|
||||
await deps.sendDiscord(to, text, { verbose: false });
|
||||
return;
|
||||
@@ -498,6 +532,7 @@ export async function runHeartbeatOnce(opts: {
|
||||
sendWhatsApp: opts.deps?.sendWhatsApp ?? sendMessageWhatsApp,
|
||||
sendTelegram: opts.deps?.sendTelegram ?? sendMessageTelegram,
|
||||
sendDiscord: opts.deps?.sendDiscord ?? sendMessageDiscord,
|
||||
sendSlack: opts.deps?.sendSlack ?? sendMessageSlack,
|
||||
sendSignal: opts.deps?.sendSignal ?? sendMessageSignal,
|
||||
sendIMessage: opts.deps?.sendIMessage ?? sendMessageIMessage,
|
||||
};
|
||||
|
||||
@@ -86,12 +86,11 @@ export async function listSlackReactions(
|
||||
export async function sendSlackMessage(
|
||||
to: string,
|
||||
content: string,
|
||||
opts: SlackActionClientOpts & { mediaUrl?: string; replyTo?: string } = {},
|
||||
opts: SlackActionClientOpts & { mediaUrl?: string } = {},
|
||||
) {
|
||||
return await sendMessageSlack(to, content, {
|
||||
token: opts.token,
|
||||
mediaUrl: opts.mediaUrl,
|
||||
threadTs: opts.replyTo,
|
||||
client: opts.client,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,4 +12,6 @@ export {
|
||||
unpinSlackMessage,
|
||||
} from "./actions.js";
|
||||
export { monitorSlackProvider } from "./monitor.js";
|
||||
export { probeSlack } from "./probe.js";
|
||||
export { sendMessageSlack } from "./send.js";
|
||||
export { resolveSlackAppToken, resolveSlackBotToken } from "./token.js";
|
||||
|
||||
@@ -6,7 +6,6 @@ import { getReplyFromConfig } from "../auto-reply/reply.js";
|
||||
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
|
||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||
import type {
|
||||
ReplyToMode,
|
||||
SlackReactionNotificationMode,
|
||||
SlackSlashCommandConfig,
|
||||
} from "../config/config.js";
|
||||
@@ -26,7 +25,6 @@ export type MonitorSlackOpts = {
|
||||
appToken?: string;
|
||||
runtime?: RuntimeEnv;
|
||||
abortSignal?: AbortSignal;
|
||||
replyToMode?: ReplyToMode;
|
||||
mediaMaxMb?: number;
|
||||
slashCommand?: SlackSlashCommandConfig;
|
||||
};
|
||||
@@ -135,18 +133,6 @@ type SlackChannelConfigResolved = {
|
||||
requireMention: boolean;
|
||||
};
|
||||
|
||||
export function resolveSlackReplyTarget(opts: {
|
||||
replyToMode: ReplyToMode;
|
||||
replyToId?: string;
|
||||
hasReplied: boolean;
|
||||
}): string | undefined {
|
||||
if (opts.replyToMode === "off") return undefined;
|
||||
const replyToId = opts.replyToId?.trim();
|
||||
if (!replyToId) return undefined;
|
||||
if (opts.replyToMode === "all") return replyToId;
|
||||
return opts.hasReplied ? undefined : replyToId;
|
||||
}
|
||||
|
||||
function normalizeSlackSlug(raw?: string) {
|
||||
const trimmed = raw?.trim().toLowerCase() ?? "";
|
||||
if (!trimmed) return "";
|
||||
@@ -353,7 +339,6 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
||||
const groupDmChannels = normalizeAllowList(dmConfig?.groupChannels);
|
||||
const channelsConfig = cfg.slack?.channels;
|
||||
const dmEnabled = dmConfig?.enabled ?? true;
|
||||
const replyToMode = opts.replyToMode ?? cfg.slack?.replyToMode ?? "off";
|
||||
const reactionMode = cfg.slack?.reactionNotifications ?? "own";
|
||||
const reactionAllowlist = cfg.slack?.reactionAllowlist ?? [];
|
||||
const slashCommand = resolveSlackSlashCommandConfig(
|
||||
@@ -583,6 +568,14 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
||||
const senderName = sender?.name ?? message.user;
|
||||
const roomLabel = channelName ? `#${channelName}` : `#${message.channel}`;
|
||||
|
||||
const preview = rawBody.replace(/\s+/g, " ").slice(0, 160);
|
||||
const inboundLabel = isDirectMessage
|
||||
? `Slack DM from ${senderName}`
|
||||
: `Slack message in ${roomLabel} from ${senderName}`;
|
||||
enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
|
||||
contextKey: `slack:message:${message.channel}:${message.ts ?? "unknown"}`,
|
||||
});
|
||||
|
||||
const textWithId = `${rawBody}\n[slack message id: ${message.ts} channel: ${message.channel}]`;
|
||||
const body = formatAgentEnvelope({
|
||||
surface: "Slack",
|
||||
@@ -634,7 +627,6 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
||||
}
|
||||
|
||||
if (shouldLogVerbose()) {
|
||||
const preview = rawBody.replace(/\s+/g, " ").slice(0, 160);
|
||||
logVerbose(
|
||||
`slack inbound: channel=${message.channel} from=${ctxPayload.From} preview="${preview}"`,
|
||||
);
|
||||
@@ -656,7 +648,6 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
||||
target: replyTarget,
|
||||
token: botToken,
|
||||
runtime,
|
||||
replyToMode,
|
||||
textLimit,
|
||||
});
|
||||
})
|
||||
@@ -685,7 +676,6 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
||||
target: replyTarget,
|
||||
token: botToken,
|
||||
runtime,
|
||||
replyToMode,
|
||||
textLimit,
|
||||
});
|
||||
if (shouldLogVerbose()) {
|
||||
@@ -1222,49 +1212,36 @@ async function deliverReplies(params: {
|
||||
target: string;
|
||||
token: string;
|
||||
runtime: RuntimeEnv;
|
||||
replyToMode: ReplyToMode;
|
||||
textLimit: number;
|
||||
}) {
|
||||
const chunkLimit = Math.min(params.textLimit, 4000);
|
||||
let hasReplied = false;
|
||||
for (const payload of params.replies) {
|
||||
const mediaList =
|
||||
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||
const text = payload.text ?? "";
|
||||
const replyToId = payload.replyToId;
|
||||
if (!text && mediaList.length === 0) continue;
|
||||
|
||||
if (mediaList.length === 0) {
|
||||
for (const chunk of chunkText(text, chunkLimit)) {
|
||||
const threadTs = resolveSlackReplyTarget({
|
||||
replyToMode: params.replyToMode,
|
||||
replyToId,
|
||||
hasReplied,
|
||||
});
|
||||
const threadTs = undefined;
|
||||
const trimmed = chunk.trim();
|
||||
if (!trimmed || trimmed === SILENT_REPLY_TOKEN) continue;
|
||||
await sendMessageSlack(params.target, trimmed, {
|
||||
token: params.token,
|
||||
threadTs,
|
||||
});
|
||||
if (threadTs && !hasReplied) hasReplied = true;
|
||||
}
|
||||
} else {
|
||||
let first = true;
|
||||
for (const mediaUrl of mediaList) {
|
||||
const caption = first ? text : "";
|
||||
first = false;
|
||||
const threadTs = resolveSlackReplyTarget({
|
||||
replyToMode: params.replyToMode,
|
||||
replyToId,
|
||||
hasReplied,
|
||||
});
|
||||
const threadTs = undefined;
|
||||
await sendMessageSlack(params.target, caption, {
|
||||
token: params.token,
|
||||
mediaUrl,
|
||||
threadTs,
|
||||
});
|
||||
if (threadTs && !hasReplied) hasReplied = true;
|
||||
}
|
||||
}
|
||||
params.runtime.log?.(`delivered reply to ${params.target}`);
|
||||
|
||||
59
src/slack/probe.ts
Normal file
59
src/slack/probe.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { WebClient } from "@slack/web-api";
|
||||
|
||||
export type SlackProbe = {
|
||||
ok: boolean;
|
||||
status?: number | null;
|
||||
error?: string | null;
|
||||
elapsedMs?: number | null;
|
||||
bot?: { id?: string; name?: string };
|
||||
team?: { id?: string; name?: string };
|
||||
};
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
||||
if (!timeoutMs || timeoutMs <= 0) return promise;
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
const timeout = new Promise<T>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error("timeout")), timeoutMs);
|
||||
});
|
||||
return Promise.race([promise, timeout]).finally(() => {
|
||||
if (timer) clearTimeout(timer);
|
||||
});
|
||||
}
|
||||
|
||||
export async function probeSlack(
|
||||
token: string,
|
||||
timeoutMs = 2500,
|
||||
): Promise<SlackProbe> {
|
||||
const client = new WebClient(token);
|
||||
const start = Date.now();
|
||||
try {
|
||||
const result = await withTimeout(client.auth.test(), timeoutMs);
|
||||
if (!result.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 200,
|
||||
error: result.error ?? "unknown",
|
||||
elapsedMs: Date.now() - start,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
elapsedMs: Date.now() - start,
|
||||
bot: { id: result.user_id ?? undefined, name: result.user ?? undefined },
|
||||
team: { id: result.team_id ?? undefined, name: result.team ?? undefined },
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const status =
|
||||
typeof (err as { status?: number }).status === "number"
|
||||
? (err as { status?: number }).status
|
||||
: null;
|
||||
return {
|
||||
ok: false,
|
||||
status,
|
||||
error: message,
|
||||
elapsedMs: Date.now() - start,
|
||||
};
|
||||
}
|
||||
}
|
||||
187
src/slack/send.ts
Normal file
187
src/slack/send.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { type FilesUploadV2Arguments, WebClient } from "@slack/web-api";
|
||||
|
||||
import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { loadWebMedia } from "../web/media.js";
|
||||
import { resolveSlackBotToken } from "./token.js";
|
||||
|
||||
const SLACK_TEXT_LIMIT = 4000;
|
||||
|
||||
type SlackRecipient =
|
||||
| {
|
||||
kind: "user";
|
||||
id: string;
|
||||
}
|
||||
| {
|
||||
kind: "channel";
|
||||
id: string;
|
||||
};
|
||||
|
||||
type SlackSendOpts = {
|
||||
token?: string;
|
||||
mediaUrl?: string;
|
||||
client?: WebClient;
|
||||
threadTs?: string;
|
||||
};
|
||||
|
||||
export type SlackSendResult = {
|
||||
messageId: string;
|
||||
channelId: string;
|
||||
};
|
||||
|
||||
function resolveToken(explicit?: string) {
|
||||
const cfgToken = loadConfig().slack?.botToken;
|
||||
const token = resolveSlackBotToken(
|
||||
explicit ?? process.env.SLACK_BOT_TOKEN ?? cfgToken ?? undefined,
|
||||
);
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
"SLACK_BOT_TOKEN or slack.botToken is required for Slack sends",
|
||||
);
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
function parseRecipient(raw: string): SlackRecipient {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("Recipient is required for Slack sends");
|
||||
}
|
||||
const mentionMatch = trimmed.match(/^<@([A-Z0-9]+)>$/i);
|
||||
if (mentionMatch) {
|
||||
return { kind: "user", id: mentionMatch[1] };
|
||||
}
|
||||
if (trimmed.startsWith("user:")) {
|
||||
return { kind: "user", id: trimmed.slice("user:".length) };
|
||||
}
|
||||
if (trimmed.startsWith("channel:")) {
|
||||
return { kind: "channel", id: trimmed.slice("channel:".length) };
|
||||
}
|
||||
if (trimmed.startsWith("slack:")) {
|
||||
return { kind: "user", id: trimmed.slice("slack:".length) };
|
||||
}
|
||||
if (trimmed.startsWith("@")) {
|
||||
const candidate = trimmed.slice(1);
|
||||
if (!/^[A-Z0-9]+$/i.test(candidate)) {
|
||||
throw new Error("Slack DMs require a user id (use user:<id> or <@id>)");
|
||||
}
|
||||
return { kind: "user", id: candidate };
|
||||
}
|
||||
if (trimmed.startsWith("#")) {
|
||||
const candidate = trimmed.slice(1);
|
||||
if (!/^[A-Z0-9]+$/i.test(candidate)) {
|
||||
throw new Error("Slack channels require a channel id (use channel:<id>)");
|
||||
}
|
||||
return { kind: "channel", id: candidate };
|
||||
}
|
||||
return { kind: "channel", id: trimmed };
|
||||
}
|
||||
|
||||
async function resolveChannelId(
|
||||
client: WebClient,
|
||||
recipient: SlackRecipient,
|
||||
): Promise<{ channelId: string; isDm?: boolean }> {
|
||||
if (recipient.kind === "channel") {
|
||||
return { channelId: recipient.id };
|
||||
}
|
||||
const response = await client.conversations.open({ users: recipient.id });
|
||||
const channelId = response.channel?.id;
|
||||
if (!channelId) {
|
||||
throw new Error("Failed to open Slack DM channel");
|
||||
}
|
||||
return { channelId, isDm: true };
|
||||
}
|
||||
|
||||
async function uploadSlackFile(params: {
|
||||
client: WebClient;
|
||||
channelId: string;
|
||||
mediaUrl: string;
|
||||
caption?: string;
|
||||
threadTs?: string;
|
||||
maxBytes?: number;
|
||||
}): Promise<string> {
|
||||
const { buffer, contentType, fileName } = await loadWebMedia(
|
||||
params.mediaUrl,
|
||||
params.maxBytes,
|
||||
);
|
||||
const basePayload = {
|
||||
channel_id: params.channelId,
|
||||
file: buffer,
|
||||
filename: fileName,
|
||||
...(params.caption ? { initial_comment: params.caption } : {}),
|
||||
...(contentType ? { filetype: contentType } : {}),
|
||||
};
|
||||
const payload: FilesUploadV2Arguments = params.threadTs
|
||||
? { ...basePayload, thread_ts: params.threadTs }
|
||||
: basePayload;
|
||||
const response = await params.client.files.uploadV2(payload);
|
||||
const parsed = response as {
|
||||
files?: Array<{ id?: string; name?: string }>;
|
||||
file?: { id?: string; name?: string };
|
||||
};
|
||||
const fileId =
|
||||
parsed.files?.[0]?.id ??
|
||||
parsed.file?.id ??
|
||||
parsed.files?.[0]?.name ??
|
||||
parsed.file?.name ??
|
||||
"unknown";
|
||||
return fileId;
|
||||
}
|
||||
|
||||
export async function sendMessageSlack(
|
||||
to: string,
|
||||
message: string,
|
||||
opts: SlackSendOpts = {},
|
||||
): Promise<SlackSendResult> {
|
||||
const trimmedMessage = message?.trim() ?? "";
|
||||
if (!trimmedMessage && !opts.mediaUrl) {
|
||||
throw new Error("Slack send requires text or media");
|
||||
}
|
||||
const token = resolveToken(opts.token);
|
||||
const client = opts.client ?? new WebClient(token);
|
||||
const recipient = parseRecipient(to);
|
||||
const { channelId } = await resolveChannelId(client, recipient);
|
||||
const cfg = loadConfig();
|
||||
const textLimit = resolveTextChunkLimit(cfg, "slack");
|
||||
const chunkLimit = Math.min(textLimit, SLACK_TEXT_LIMIT);
|
||||
const chunks = chunkText(trimmedMessage, chunkLimit);
|
||||
const mediaMaxBytes =
|
||||
typeof cfg.slack?.mediaMaxMb === "number"
|
||||
? cfg.slack.mediaMaxMb * 1024 * 1024
|
||||
: undefined;
|
||||
|
||||
let lastMessageId = "";
|
||||
if (opts.mediaUrl) {
|
||||
const [firstChunk, ...rest] = chunks;
|
||||
lastMessageId = await uploadSlackFile({
|
||||
client,
|
||||
channelId,
|
||||
mediaUrl: opts.mediaUrl,
|
||||
caption: firstChunk,
|
||||
threadTs: opts.threadTs,
|
||||
maxBytes: mediaMaxBytes,
|
||||
});
|
||||
for (const chunk of rest) {
|
||||
const response = await client.chat.postMessage({
|
||||
channel: channelId,
|
||||
text: chunk,
|
||||
thread_ts: opts.threadTs,
|
||||
});
|
||||
lastMessageId = response.ts ?? lastMessageId;
|
||||
}
|
||||
} else {
|
||||
for (const chunk of chunks.length ? chunks : [""]) {
|
||||
const response = await client.chat.postMessage({
|
||||
channel: channelId,
|
||||
text: chunk,
|
||||
thread_ts: opts.threadTs,
|
||||
});
|
||||
lastMessageId = response.ts ?? lastMessageId;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
messageId: lastMessageId || "unknown",
|
||||
channelId,
|
||||
};
|
||||
}
|
||||
12
src/slack/token.ts
Normal file
12
src/slack/token.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export function normalizeSlackToken(raw?: string): string | undefined {
|
||||
const trimmed = raw?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
export function resolveSlackBotToken(raw?: string): string | undefined {
|
||||
return normalizeSlackToken(raw);
|
||||
}
|
||||
|
||||
export function resolveSlackAppToken(raw?: string): string | undefined {
|
||||
return normalizeSlackToken(raw);
|
||||
}
|
||||
Reference in New Issue
Block a user