feat(discord): Discord transport
This commit is contained in:
committed by
Peter Steinberger
parent
557f8e5a04
commit
ac659ff5a7
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,9 @@ describe("healthCommand (coverage)", () => {
|
||||
webhook: { url: "https://example.com/h" },
|
||||
},
|
||||
},
|
||||
discord: {
|
||||
configured: false,
|
||||
},
|
||||
heartbeatSeconds: 60,
|
||||
sessions: {
|
||||
path: "/tmp/sessions.json",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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)"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user