feat(discord): Discord transport

This commit is contained in:
Shadow
2025-12-15 10:11:18 -06:00
committed by Peter Steinberger
parent 557f8e5a04
commit ac659ff5a7
44 changed files with 1352 additions and 56 deletions

View File

@@ -414,6 +414,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 logDeliveryError = (err: unknown) => {
const deliveryTarget =
@@ -421,7 +422,9 @@ export async function agentCommand(
? telegramTarget
: deliveryProvider === "whatsapp"
? whatsappTarget
: undefined;
: deliveryProvider === "discord"
? discordTarget
: undefined;
const message = `Delivery failed (${deliveryProvider}${deliveryTarget ? ` to ${deliveryTarget}` : ""}): ${String(err)}`;
runtime.error?.(message);
if (!runtime.error) runtime.log(message);
@@ -440,6 +443,13 @@ export async function agentCommand(
if (!bestEffortDeliver) throw err;
logDeliveryError(err);
}
if (deliveryProvider === "discord" && !discordTarget) {
const err = new Error(
"Delivering to Discord requires --to <channelId|user:ID|channel:ID>",
);
if (!bestEffortDeliver) throw err;
logDeliveryError(err);
}
if (deliveryProvider === "webchat") {
const err = new Error(
"Delivering to WebChat is not supported via `clawdis agent`; use WhatsApp/Telegram or run with --deliver=false.",
@@ -450,6 +460,7 @@ export async function agentCommand(
if (
deliveryProvider !== "whatsapp" &&
deliveryProvider !== "telegram" &&
deliveryProvider !== "discord" &&
deliveryProvider !== "webchat"
) {
const err = new Error(`Unknown provider: ${deliveryProvider}`);
@@ -540,5 +551,28 @@ export async function agentCommand(
logDeliveryError(err);
}
}
if (deliveryProvider === "discord" && discordTarget) {
try {
if (media.length === 0) {
await deps.sendMessageDiscord(discordTarget, text, {
token: process.env.DISCORD_BOT_TOKEN,
});
} else {
let first = true;
for (const url of media) {
const caption = first ? text : "";
first = false;
await deps.sendMessageDiscord(discordTarget, caption, {
token: process.env.DISCORD_BOT_TOKEN,
mediaUrl: url,
});
}
}
} catch (err) {
if (!bestEffortDeliver) throw err;
logDeliveryError(err);
}
}
}
}

View File

@@ -46,6 +46,9 @@ describe("healthCommand (coverage)", () => {
webhook: { url: "https://example.com/h" },
},
},
discord: {
configured: false,
},
heartbeatSeconds: 60,
sessions: {
path: "/tmp/sessions.json",

View File

@@ -40,6 +40,7 @@ describe("getHealthSnapshot", () => {
foo: { updatedAt: 2000 },
};
vi.stubEnv("TELEGRAM_BOT_TOKEN", "");
vi.stubEnv("DISCORD_BOT_TOKEN", "");
const snap = (await getHealthSnapshot(10)) satisfies HealthSummary;
expect(snap.ok).toBe(true);
expect(snap.telegram.configured).toBe(false);
@@ -51,6 +52,7 @@ describe("getHealthSnapshot", () => {
it("probes telegram getMe + webhook info when configured", async () => {
testConfig = { telegram: { botToken: "t-1" } };
testStore = {};
vi.stubEnv("DISCORD_BOT_TOKEN", "");
const calls: string[] = [];
vi.stubGlobal(
@@ -100,6 +102,7 @@ describe("getHealthSnapshot", () => {
it("returns a structured telegram probe error when getMe fails", async () => {
testConfig = { telegram: { botToken: "bad-token" } };
testStore = {};
vi.stubEnv("DISCORD_BOT_TOKEN", "");
vi.stubGlobal(
"fetch",
@@ -125,6 +128,7 @@ describe("getHealthSnapshot", () => {
it("captures unexpected probe exceptions as errors", async () => {
testConfig = { telegram: { botToken: "t-err" } };
testStore = {};
vi.stubEnv("DISCORD_BOT_TOKEN", "");
vi.stubGlobal(
"fetch",

View File

@@ -1,5 +1,6 @@
import { loadConfig } from "../config/config.js";
import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
import { probeDiscord, type DiscordProbe } from "../discord/probe.js";
import { callGateway } from "../gateway/call.js";
import { info } from "../globals.js";
import type { RuntimeEnv } from "../runtime.js";
@@ -34,6 +35,10 @@ export type HealthSummary = {
configured: boolean;
probe?: TelegramProbe;
};
discord: {
configured: boolean;
probe?: DiscordProbe;
};
heartbeatSeconds: number;
sessions: {
path: string;
@@ -77,12 +82,19 @@ export async function getHealthSnapshot(
? await probeTelegram(telegramToken.trim(), cappedTimeout, telegramProxy)
: undefined;
const discordToken = process.env.DISCORD_BOT_TOKEN ?? cfg.discord?.token ?? "";
const discordConfigured = discordToken.trim().length > 0;
const discordProbe = discordConfigured
? await probeDiscord(discordToken.trim(), cappedTimeout)
: undefined;
const summary: HealthSummary = {
ok: true,
ts: Date.now(),
durationMs: Date.now() - start,
web: { linked, authAgeMs },
telegram: { configured: telegramConfigured, probe: telegramProbe },
discord: { configured: discordConfigured, probe: discordProbe },
heartbeatSeconds,
sessions: {
path: storePath,
@@ -139,6 +151,15 @@ export async function healthCommand(
: "Telegram: not configured";
runtime.log(tgLabel);
const discordLabel = summary.discord.configured
? summary.discord.probe?.ok
? info(
`Discord: ok${summary.discord.probe.bot?.username ? ` (@${summary.discord.probe.bot.username})` : ""} (${summary.discord.probe.elapsedMs}ms)`,
)
: `Discord: failed (${summary.discord.probe?.status ?? "unknown"})${summary.discord.probe?.error ? ` - ${summary.discord.probe.error}` : ""}`
: "Discord: not configured";
runtime.log(discordLabel);
runtime.log(info(`Heartbeat interval: ${summary.heartbeatSeconds}s`));
runtime.log(
info(

View File

@@ -11,13 +11,16 @@ vi.mock("../gateway/call.js", () => ({
}));
const originalTelegramToken = process.env.TELEGRAM_BOT_TOKEN;
const originalDiscordToken = process.env.DISCORD_BOT_TOKEN;
beforeEach(() => {
process.env.TELEGRAM_BOT_TOKEN = "token-abc";
process.env.DISCORD_BOT_TOKEN = "token-discord";
});
afterAll(() => {
process.env.TELEGRAM_BOT_TOKEN = originalTelegramToken;
process.env.DISCORD_BOT_TOKEN = originalDiscordToken;
});
const runtime: RuntimeEnv = {
@@ -31,6 +34,7 @@ const runtime: RuntimeEnv = {
const makeDeps = (overrides: Partial<CliDeps> = {}): CliDeps => ({
sendMessageWhatsApp: vi.fn(),
sendMessageTelegram: vi.fn(),
sendMessageDiscord: vi.fn(),
...overrides,
});
@@ -83,6 +87,25 @@ describe("sendCommand", () => {
expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
});
it("routes to discord provider", async () => {
const deps = makeDeps({
sendMessageDiscord: vi
.fn()
.mockResolvedValue({ messageId: "d1", channelId: "chan" }),
});
await sendCommand(
{ to: "channel:chan", message: "hi", provider: "discord" },
deps,
runtime,
);
expect(deps.sendMessageDiscord).toHaveBeenCalledWith(
"channel:chan",
"hi",
expect.objectContaining({ token: "token-discord" }),
);
expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
});
it("emits json output", async () => {
callGatewayMock.mockResolvedValueOnce({ messageId: "direct2" });
const deps = makeDeps();

View File

@@ -53,6 +53,35 @@ export async function sendCommand(
return;
}
if (provider === "discord") {
const result = await deps.sendMessageDiscord(opts.to, opts.message, {
token: process.env.DISCORD_BOT_TOKEN,
mediaUrl: opts.media,
});
runtime.log(
success(
`✅ Sent via discord. Message ID: ${result.messageId} (channel ${result.channelId})`,
),
);
if (opts.json) {
runtime.log(
JSON.stringify(
{
provider: "discord",
via: "direct",
to: opts.to,
channelId: result.channelId,
messageId: result.messageId,
mediaUrl: opts.media ?? null,
},
null,
2,
),
);
}
return;
}
// Always send via gateway over WS to avoid multi-session corruption.
const sendViaGateway = async () =>
callGateway<{

View File

@@ -235,6 +235,15 @@ export async function statusCommand(
: `Telegram: failed (${health.telegram.probe?.status ?? "unknown"})${health.telegram.probe?.error ? ` - ${health.telegram.probe.error}` : ""}`
: info("Telegram: not configured");
runtime.log(tgLine);
const discordLine = health.discord.configured
? health.discord.probe?.ok
? info(
`Discord: ok${health.discord.probe.bot?.username ? ` (@${health.discord.probe.bot.username})` : ""} (${health.discord.probe.elapsedMs}ms)`,
)
: `Discord: failed (${health.discord.probe?.status ?? "unknown"})${health.discord.probe?.error ? ` - ${health.discord.probe.error}` : ""}`
: info("Discord: not configured");
runtime.log(discordLine);
} else {
runtime.log(info("Provider probes: skipped (use --deep)"));
}