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

@@ -386,3 +386,87 @@ describe("trigger handling", () => {
});
});
});
describe("group intro prompts", () => {
it("labels Discord groups using the surface metadata", async () => {
const commandSpy = vi
.spyOn(commandReply, "runCommandReply")
.mockResolvedValue({ payloads: [{ text: "ok" }], meta: { durationMs: 1 } });
await getReplyFromConfig(
{
Body: "status update",
From: "group:dev",
To: "+1888",
ChatType: "group",
GroupSubject: "Release Squad",
GroupMembers: "Alice, Bob",
Surface: "discord",
},
{},
baseCfg,
);
expect(commandSpy).toHaveBeenCalledOnce();
const body =
commandSpy.mock.calls.at(-1)?.[0]?.templatingCtx.Body ?? "";
const intro = body.split("\n\n")[0];
expect(intro).toBe(
'You are replying inside the Discord group "Release Squad". Group members: Alice, Bob. Address the specific sender noted in the message context.',
);
});
it("keeps WhatsApp labeling for WhatsApp group chats", async () => {
const commandSpy = vi
.spyOn(commandReply, "runCommandReply")
.mockResolvedValue({ payloads: [{ text: "ok" }], meta: { durationMs: 1 } });
await getReplyFromConfig(
{
Body: "ping",
From: "123@g.us",
To: "+1999",
ChatType: "group",
GroupSubject: "Ops",
Surface: "whatsapp",
},
{},
baseCfg,
);
expect(commandSpy).toHaveBeenCalledOnce();
const body =
commandSpy.mock.calls.at(-1)?.[0]?.templatingCtx.Body ?? "";
const intro = body.split("\n\n")[0];
expect(intro).toBe(
'You are replying inside the WhatsApp group "Ops". Address the specific sender noted in the message context.',
);
});
it("labels Telegram groups using their own surface", async () => {
const commandSpy = vi
.spyOn(commandReply, "runCommandReply")
.mockResolvedValue({ payloads: [{ text: "ok" }], meta: { durationMs: 1 } });
await getReplyFromConfig(
{
Body: "ping",
From: "group:tg",
To: "+1777",
ChatType: "group",
GroupSubject: "Dev Chat",
Surface: "telegram",
},
{},
baseCfg,
);
expect(commandSpy).toHaveBeenCalledOnce();
const body =
commandSpy.mock.calls.at(-1)?.[0]?.templatingCtx.Body ?? "";
const intro = body.split("\n\n")[0];
expect(intro).toBe(
'You are replying inside the Telegram group "Dev Chat". Address the specific sender noted in the message context.',
);
});
});

View File

@@ -790,9 +790,18 @@ export async function getReplyFromConfig(
defaultGroupActivation();
const subject = sessionCtx.GroupSubject?.trim();
const members = sessionCtx.GroupMembers?.trim();
const surface = sessionCtx.Surface?.trim().toLowerCase();
const surfaceLabel = (() => {
if (!surface) return "chat";
if (surface === "whatsapp") return "WhatsApp";
if (surface === "telegram") return "Telegram";
if (surface === "discord") return "Discord";
if (surface === "webchat") return "WebChat";
return `${surface.at(0)?.toUpperCase() ?? ""}${surface.slice(1)}`;
})();
const subjectLine = subject
? `You are replying inside the WhatsApp group "${subject}".`
: "You are replying inside a WhatsApp group chat.";
? `You are replying inside the ${surfaceLabel} group "${subject}".`
: `You are replying inside a ${surfaceLabel} group chat.`;
const membersLine = members ? `Group members: ${members}.` : undefined;
const activationLine =
activation === "always"

View File

@@ -155,10 +155,13 @@ export function registerCronCli(program: Command) {
.option("--deliver", "Deliver agent output", false)
.option(
"--channel <channel>",
"Delivery channel (last|whatsapp|telegram)",
"Delivery channel (last|whatsapp|telegram|discord)",
"last",
)
.option("--to <dest>", "Delivery destination (E.164 or Telegram chatId)")
.option(
"--to <dest>",
"Delivery destination (E.164, Telegram chatId, or Discord channel/user)",
)
.option(
"--best-effort-deliver",
"Do not fail the job if delivery fails",
@@ -411,9 +414,12 @@ export function registerCronCli(program: Command) {
.option("--deliver", "Deliver agent output", false)
.option(
"--channel <channel>",
"Delivery channel (last|whatsapp|telegram)",
"Delivery channel (last|whatsapp|telegram|discord)",
)
.option(
"--to <dest>",
"Delivery destination (E.164, Telegram chatId, or Discord channel/user)",
)
.option("--to <dest>", "Delivery destination")
.option(
"--best-effort-deliver",
"Do not fail job if delivery fails",

View File

@@ -1,15 +1,18 @@
import { sendMessageDiscord } from "../discord/send.js";
import { logWebSelfId, sendMessageWhatsApp } from "../providers/web/index.js";
import { sendMessageTelegram } from "../telegram/send.js";
export type CliDeps = {
sendMessageWhatsApp: typeof sendMessageWhatsApp;
sendMessageTelegram: typeof sendMessageTelegram;
sendMessageDiscord: typeof sendMessageDiscord;
};
export function createDefaultDeps(): CliDeps {
return {
sendMessageWhatsApp,
sendMessageTelegram,
sendMessageDiscord,
};
}

View File

@@ -149,10 +149,10 @@ export function buildProgram() {
program
.command("send")
.description("Send a message (WhatsApp web or Telegram bot)")
.description("Send a message (WhatsApp Web, Telegram bot, or Discord)")
.requiredOption(
"-t, --to <number>",
"Recipient: E.164 for WhatsApp (e.g. +15555550123) or Telegram chat id/@username",
"Recipient: E.164 for WhatsApp, Telegram chat id/@username, or Discord channel/user",
)
.requiredOption("-m, --message <text>", "Message body")
.option(
@@ -161,7 +161,7 @@ export function buildProgram() {
)
.option(
"--provider <provider>",
"Delivery provider: whatsapp|telegram (default: whatsapp)",
"Delivery provider: whatsapp|telegram|discord (default: whatsapp)",
)
.option("--dry-run", "Print payload and skip sending", false)
.option("--json", "Output result as JSON", false)
@@ -202,9 +202,13 @@ Examples:
"Thinking level: off | minimal | low | medium | high",
)
.option("--verbose <on|off>", "Persist agent verbose level for the session")
.option(
"--provider <provider>",
"Delivery provider: whatsapp|telegram|discord (default: whatsapp)",
)
.option(
"--deliver",
"Send the agent's reply back to WhatsApp (requires --to)",
"Send the agent's reply back to the selected provider (requires --to)",
false,
)
.option("--json", "Output result as JSON", false)
@@ -247,7 +251,11 @@ Examples:
.command("status")
.description("Show web session health and recent session recipients")
.option("--json", "Output JSON instead of text", false)
.option("--deep", "Probe providers (WA connect + Telegram API)", false)
.option(
"--deep",
"Probe providers (WhatsApp Web + Telegram + Discord)",
false,
)
.option("--timeout <ms>", "Probe timeout in milliseconds", "10000")
.option("--verbose", "Verbose logging", false)
.addHelpText(
@@ -256,7 +264,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)
clawdis status --deep # run provider probes (WA + Telegram + Discord)
clawdis status --deep --timeout 5000 # tighten probe timeout`,
)
.action(async (opts) => {

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)"));
}

View File

@@ -84,7 +84,7 @@ export type HookMappingConfig = {
messageTemplate?: string;
textTemplate?: string;
deliver?: boolean;
channel?: "last" | "whatsapp" | "telegram";
channel?: "last" | "whatsapp" | "telegram" | "discord";
to?: string;
thinking?: string;
timeoutSeconds?: number;
@@ -136,6 +136,13 @@ export type TelegramConfig = {
webhookPath?: string;
};
export type DiscordConfig = {
token?: string;
allowFrom?: Array<string | number>;
requireMention?: boolean;
mediaMaxMb?: number;
};
export type GroupChatConfig = {
requireMention?: boolean;
mentionPatterns?: string[];
@@ -329,8 +336,8 @@ export type ClawdisConfig = {
every?: string;
/** Heartbeat model override (provider/model). */
model?: string;
/** Delivery target (last|whatsapp|telegram|none). */
target?: "last" | "whatsapp" | "telegram" | "none";
/** Delivery target (last|whatsapp|telegram|discord|none). */
target?: "last" | "whatsapp" | "telegram" | "discord" | "none";
/** Optional delivery override (E.164 for WhatsApp, chat id for Telegram). */
to?: string;
/** Override the heartbeat prompt body (default: "HEARTBEAT"). */
@@ -353,6 +360,7 @@ export type ClawdisConfig = {
session?: SessionConfig;
web?: WebConfig;
telegram?: TelegramConfig;
discord?: DiscordConfig;
cron?: CronConfig;
hooks?: HooksConfig;
bridge?: BridgeConfig;
@@ -512,7 +520,12 @@ const HookMappingSchema = z
textTemplate: z.string().optional(),
deliver: z.boolean().optional(),
channel: z
.union([z.literal("last"), z.literal("whatsapp"), z.literal("telegram")])
.union([
z.literal("last"),
z.literal("whatsapp"),
z.literal("telegram"),
z.literal("discord"),
])
.optional(),
to: z.string().optional(),
thinking: z.string().optional(),
@@ -681,6 +694,14 @@ const ClawdisSchema = z.object({
webhookPath: z.string().optional(),
})
.optional(),
discord: z
.object({
token: z.string().optional(),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
requireMention: z.boolean().optional(),
mediaMaxMb: z.number().positive().optional(),
})
.optional(),
bridge: z
.object({
enabled: z.boolean().optional(),

View File

@@ -26,7 +26,7 @@ export type SessionEntry = {
totalTokens?: number;
model?: string;
contextTokens?: number;
lastChannel?: "whatsapp" | "telegram" | "webchat";
lastChannel?: "whatsapp" | "telegram" | "discord" | "webchat";
lastTo?: string;
skillsSnapshot?: SessionSkillSnapshot;
};

View File

@@ -87,6 +87,7 @@ describe("runCronIsolatedAgentTurn", () => {
const deps: CliDeps = {
sendMessageWhatsApp: vi.fn(),
sendMessageTelegram: vi.fn(),
sendMessageDiscord: vi.fn(),
};
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "first" }, { text: " " }, { text: " last " }],
@@ -116,6 +117,7 @@ describe("runCronIsolatedAgentTurn", () => {
const deps: CliDeps = {
sendMessageWhatsApp: vi.fn(),
sendMessageTelegram: vi.fn(),
sendMessageDiscord: vi.fn(),
};
const long = "a".repeat(2001);
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
@@ -146,6 +148,7 @@ describe("runCronIsolatedAgentTurn", () => {
const deps: CliDeps = {
sendMessageWhatsApp: vi.fn(),
sendMessageTelegram: vi.fn(),
sendMessageDiscord: vi.fn(),
};
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "hello" }],
@@ -183,6 +186,7 @@ describe("runCronIsolatedAgentTurn", () => {
const deps: CliDeps = {
sendMessageWhatsApp: vi.fn(),
sendMessageTelegram: vi.fn(),
sendMessageDiscord: vi.fn(),
};
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "hello" }],
@@ -212,4 +216,47 @@ describe("runCronIsolatedAgentTurn", () => {
expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
});
});
it("delivers via discord when configured", async () => {
await withTempHome(async (home) => {
const storePath = await writeSessionStore(home);
const deps: CliDeps = {
sendMessageWhatsApp: vi.fn(),
sendMessageTelegram: vi.fn(),
sendMessageDiscord: vi.fn().mockResolvedValue({
messageId: "d1",
channelId: "chan",
}),
};
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "hello from cron" }],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
const res = await runCronIsolatedAgentTurn({
cfg: makeCfg(home, storePath),
deps,
job: makeJob({
kind: "agentTurn",
message: "do it",
deliver: true,
channel: "discord",
to: "channel:1122",
}),
message: "do it",
sessionKey: "cron:job-1",
lane: "cron",
});
expect(res.status).toBe("ok");
expect(deps.sendMessageDiscord).toHaveBeenCalledWith(
"channel:1122",
"hello from cron",
expect.objectContaining({ token: process.env.DISCORD_BOT_TOKEN }),
);
});
});
});

View File

@@ -53,7 +53,7 @@ function pickSummaryFromPayloads(
function resolveDeliveryTarget(
cfg: ClawdisConfig,
jobPayload: {
channel?: "last" | "whatsapp" | "telegram";
channel?: "last" | "whatsapp" | "telegram" | "discord";
to?: string;
},
) {
@@ -76,7 +76,11 @@ function resolveDeliveryTarget(
const lastTo = typeof main?.lastTo === "string" ? main.lastTo.trim() : "";
const channel = (() => {
if (requestedChannel === "whatsapp" || requestedChannel === "telegram") {
if (
requestedChannel === "whatsapp" ||
requestedChannel === "telegram" ||
requestedChannel === "discord"
) {
return requestedChannel;
}
return lastChannel ?? "whatsapp";
@@ -366,6 +370,50 @@ export async function runCronIsolatedAgentTurn(params: {
return { status: "error", summary, error: String(err) };
return { status: "ok", summary };
}
} else if (resolvedDelivery.channel === "discord") {
if (!resolvedDelivery.to) {
if (!bestEffortDeliver)
return {
status: "error",
summary,
error:
"Cron delivery to Discord requires --channel discord and --to <channelId|user:ID>",
};
return {
status: "skipped",
summary: "Delivery skipped (no Discord destination).",
};
}
const discordTarget = resolvedDelivery.to;
try {
for (const payload of payloads) {
const mediaList =
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
if (mediaList.length === 0) {
await params.deps.sendMessageDiscord(
discordTarget,
payload.text ?? "",
{
token: process.env.DISCORD_BOT_TOKEN,
},
);
} else {
let first = true;
for (const url of mediaList) {
const caption = first ? (payload.text ?? "") : "";
first = false;
await params.deps.sendMessageDiscord(discordTarget, caption, {
token: process.env.DISCORD_BOT_TOKEN,
mediaUrl: url,
});
}
}
}
} catch (err) {
if (!bestEffortDeliver)
return { status: "error", summary, error: String(err) };
return { status: "ok", summary };
}
}
}

View File

@@ -14,7 +14,7 @@ export type CronPayload =
thinking?: string;
timeoutSeconds?: number;
deliver?: boolean;
channel?: "last" | "whatsapp" | "telegram";
channel?: "last" | "whatsapp" | "telegram" | "discord";
to?: string;
bestEffortDeliver?: boolean;
};

2
src/discord/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export { monitorDiscordProvider } from "./monitor.js";
export { sendMessageDiscord } from "./send.js";

323
src/discord/monitor.ts Normal file
View File

@@ -0,0 +1,323 @@
import {
Client,
Events,
GatewayIntentBits,
type Message,
Partials,
} from "discord.js";
import { chunkText } from "../auto-reply/chunk.js";
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
import { getReplyFromConfig } from "../auto-reply/reply.js";
import type { ReplyPayload } from "../auto-reply/types.js";
import { loadConfig } from "../config/config.js";
import { resolveStorePath, updateLastRoute } from "../config/sessions.js";
import { danger, isVerbose, logVerbose } from "../globals.js";
import { getChildLogger } from "../logging.js";
import { detectMime } from "../media/mime.js";
import { saveMediaBuffer } from "../media/store.js";
import type { RuntimeEnv } from "../runtime.js";
import { sendMessageDiscord } from "./send.js";
import { normalizeDiscordToken } from "./token.js";
export type MonitorDiscordOpts = {
token?: string;
runtime?: RuntimeEnv;
abortSignal?: AbortSignal;
allowFrom?: Array<string | number>;
requireMention?: boolean;
mediaMaxMb?: number;
};
type DiscordMediaInfo = {
path: string;
contentType?: string;
placeholder: string;
};
export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
const cfg = loadConfig();
const token = normalizeDiscordToken(
opts.token ?? process.env.DISCORD_BOT_TOKEN ?? cfg.discord?.token ?? undefined,
);
if (!token) {
throw new Error(
"DISCORD_BOT_TOKEN or discord.token is required for Discord gateway",
);
}
const runtime: RuntimeEnv = opts.runtime ?? {
log: console.log,
error: console.error,
exit: (code: number): never => {
throw new Error(`exit ${code}`);
},
};
const allowFrom = opts.allowFrom ?? cfg.discord?.allowFrom;
const requireMention =
opts.requireMention ?? cfg.discord?.requireMention ?? true;
const mediaMaxBytes =
(opts.mediaMaxMb ?? cfg.discord?.mediaMaxMb ?? 8) * 1024 * 1024;
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
GatewayIntentBits.DirectMessages,
],
partials: [Partials.Channel],
});
const logger = getChildLogger({ module: "discord-auto-reply" });
client.once(Events.ClientReady, () => {
runtime.log?.(`discord: logged in as ${client.user?.tag ?? "unknown"}`);
});
client.on(Events.Error, (err) => {
runtime.error?.(danger(`discord client error: ${String(err)}`));
});
client.on(Events.MessageCreate, async (message) => {
try {
if (message.author?.bot) return;
if (!message.author) return;
const isDirectMessage = !message.guild;
if (!isDirectMessage && requireMention) {
const botId = client.user?.id;
if (botId && !message.mentions.has(botId)) {
logger.info(
{
channelId: message.channelId,
reason: "no-mention",
},
"discord: skipping guild message",
);
return;
}
}
if (isDirectMessage && Array.isArray(allowFrom) && allowFrom.length > 0) {
const allowed = allowFrom
.map((entry) => String(entry).trim())
.filter(Boolean);
const candidate = message.author.id;
const normalized = new Set(
allowed
.filter((entry) => entry !== "*")
.map((entry) => entry.replace(/^discord:/i, "")),
);
const permitted =
allowed.includes("*") ||
normalized.has(candidate) ||
allowed.includes(candidate);
if (!permitted) {
logVerbose(
`Blocked unauthorized discord sender ${candidate} (not in allowFrom)`,
);
return;
}
}
const media = await resolveMedia(message, mediaMaxBytes);
const text =
message.content?.trim() ??
media?.placeholder ??
message.embeds[0]?.description ??
"";
if (!text) return;
const fromLabel = isDirectMessage
? buildDirectLabel(message)
: buildGuildLabel(message);
const body = formatAgentEnvelope({
surface: "Discord",
from: fromLabel,
timestamp: message.createdTimestamp,
body: text,
});
const ctxPayload = {
Body: body,
From: isDirectMessage
? `discord:${message.author.id}`
: `group:${message.channelId}`,
To: isDirectMessage
? `user:${message.author.id}`
: `channel:${message.channelId}`,
ChatType: isDirectMessage ? "direct" : "group",
SenderName: message.member?.displayName ?? message.author.tag,
GroupSubject:
!isDirectMessage && "name" in message.channel
? message.channel.name
: undefined,
Surface: "discord" as const,
MessageSid: message.id,
Timestamp: message.createdTimestamp,
MediaPath: media?.path,
MediaType: media?.contentType,
MediaUrl: media?.path,
};
if (isDirectMessage) {
const sessionCfg = cfg.inbound?.reply?.session;
const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main";
const storePath = resolveStorePath(sessionCfg?.store);
await updateLastRoute({
storePath,
sessionKey: mainKey,
channel: "discord",
to: `user:${message.author.id}`,
});
}
if (isVerbose()) {
const preview = body.slice(0, 200).replace(/\n/g, "\\n");
logVerbose(
`discord inbound: channel=${message.channelId} from=${ctxPayload.From} preview="${preview}"`,
);
}
const replyResult = await getReplyFromConfig(
ctxPayload,
{
onReplyStart: () => sendTyping(message),
},
cfg,
);
const replies = replyResult
? Array.isArray(replyResult)
? replyResult
: [replyResult]
: [];
if (replies.length === 0) return;
await deliverReplies({
replies,
target: ctxPayload.To,
token,
runtime,
});
} catch (err) {
runtime.error?.(danger(`Discord handler failed: ${String(err)}`));
}
});
await client.login(token);
await new Promise<void>((resolve, reject) => {
const onAbort = () => {
cleanup();
client.destroy();
resolve();
};
const onError = (err: Error) => {
cleanup();
reject(err);
};
const cleanup = () => {
opts.abortSignal?.removeEventListener("abort", onAbort);
client.off(Events.Error, onError);
};
opts.abortSignal?.addEventListener("abort", onAbort, { once: true });
client.on(Events.Error, onError);
});
}
async function resolveMedia(
message: import("discord.js").Message,
maxBytes: number,
): Promise<DiscordMediaInfo | null> {
const attachment = message.attachments.first();
if (!attachment) return null;
const res = await fetch(attachment.url);
if (!res.ok) {
throw new Error(
`Failed to download discord attachment: HTTP ${res.status}`,
);
}
const buffer = Buffer.from(await res.arrayBuffer());
const saved = await saveMediaBuffer(
buffer,
detectMime({
buffer,
headerMime: attachment.contentType ?? res.headers.get("content-type"),
filePath: attachment.name ?? attachment.url,
}),
"inbound",
maxBytes,
);
return {
path: saved.path,
contentType: saved.contentType,
placeholder: inferPlaceholder(attachment),
};
}
function inferPlaceholder(attachment: import("discord.js").Attachment): string {
const mime = attachment.contentType ?? "";
if (mime.startsWith("image/")) return "<media:image>";
if (mime.startsWith("video/")) return "<media:video>";
if (mime.startsWith("audio/")) return "<media:audio>";
return "<media:document>";
}
function buildDirectLabel(message: import("discord.js").Message) {
const username = message.author.tag;
return `${username} id:${message.author.id}`;
}
function buildGuildLabel(message: import("discord.js").Message) {
const channelName =
"name" in message.channel ? message.channel.name : message.channelId;
return `${message.guild?.name ?? "Guild"} #${channelName} id:${message.channelId}`;
}
async function sendTyping(message: Message) {
try {
const channel = message.channel;
if (channel.isSendable()) {
await channel.sendTyping();
}
} catch {
/* ignore */
}
}
async function deliverReplies({
replies,
target,
token,
runtime,
}: {
replies: ReplyPayload[];
target: string;
token: string;
runtime: RuntimeEnv;
}) {
for (const payload of replies) {
const mediaList =
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const text = payload.text ?? "";
if (!text && mediaList.length === 0) continue;
if (mediaList.length === 0) {
for (const chunk of chunkText(text, 2000)) {
await sendMessageDiscord(target, chunk, { token });
}
} else {
let first = true;
for (const mediaUrl of mediaList) {
const caption = first ? text : "";
first = false;
await sendMessageDiscord(target, caption, {
token,
mediaUrl,
});
}
}
runtime.log?.(`discord: delivered reply to ${target}`);
}
}

73
src/discord/probe.ts Normal file
View File

@@ -0,0 +1,73 @@
import { normalizeDiscordToken } from "./token.js";
const DISCORD_API_BASE = "https://discord.com/api/v10";
export type DiscordProbe = {
ok: boolean;
status?: number | null;
error?: string | null;
elapsedMs: number;
bot?: { id?: string | null; username?: string | null };
};
async function fetchWithTimeout(
url: string,
timeoutMs: number,
fetcher: typeof fetch,
headers?: HeadersInit,
): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetcher(url, { signal: controller.signal, headers });
} finally {
clearTimeout(timer);
}
}
export async function probeDiscord(
token: string,
timeoutMs: number,
): Promise<DiscordProbe> {
const started = Date.now();
const normalized = normalizeDiscordToken(token);
const result: DiscordProbe = {
ok: false,
status: null,
error: null,
elapsedMs: 0,
};
if (!normalized) {
return { ...result, error: "missing token", elapsedMs: Date.now() - started };
}
try {
const res = await fetchWithTimeout(
`${DISCORD_API_BASE}/users/@me`,
timeoutMs,
fetch,
{
Authorization: `Bot ${normalized}`,
},
);
if (!res.ok) {
result.status = res.status;
result.error = `getMe failed (${res.status})`;
return { ...result, elapsedMs: Date.now() - started };
}
const json = (await res.json()) as { id?: string; username?: string };
result.ok = true;
result.bot = {
id: json.id ?? null,
username: json.username ?? null,
};
return { ...result, elapsedMs: Date.now() - started };
} catch (err) {
return {
...result,
status: err instanceof Response ? err.status : result.status,
error: err instanceof Error ? err.message : String(err),
elapsedMs: Date.now() - started,
};
}
}

85
src/discord/send.test.ts Normal file
View File

@@ -0,0 +1,85 @@
import { Routes } from "discord.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { sendMessageDiscord } from "./send.js";
vi.mock("../web/media.js", () => ({
loadWebMedia: vi.fn().mockResolvedValue({
buffer: Buffer.from("img"),
fileName: "photo.jpg",
contentType: "image/jpeg",
kind: "image",
}),
}));
const makeRest = () => {
const postMock = vi.fn();
return {
rest: {
post: postMock,
} as unknown as import("discord.js").REST,
postMock,
};
};
describe("sendMessageDiscord", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("sends basic channel messages", async () => {
const { rest, postMock } = makeRest();
postMock.mockResolvedValue({
id: "msg1",
channel_id: "789",
});
const res = await sendMessageDiscord("channel:789", "hello world", {
rest,
token: "t",
});
expect(res).toEqual({ messageId: "msg1", channelId: "789" });
expect(postMock).toHaveBeenCalledWith(
Routes.channelMessages("789"),
expect.objectContaining({ body: { content: "hello world" } }),
);
});
it("starts DM when recipient is a user", async () => {
const { rest, postMock } = makeRest();
postMock
.mockResolvedValueOnce({ id: "chan1" })
.mockResolvedValueOnce({ id: "msg1", channel_id: "chan1" });
const res = await sendMessageDiscord("user:123", "hiya", {
rest,
token: "t",
});
expect(postMock).toHaveBeenNthCalledWith(
1,
Routes.userChannels(),
expect.objectContaining({ body: { recipient_id: "123" } }),
);
expect(postMock).toHaveBeenNthCalledWith(
2,
Routes.channelMessages("chan1"),
expect.objectContaining({ body: { content: "hiya" } }),
);
expect(res.channelId).toBe("chan1");
});
it("uploads media attachments", async () => {
const { rest, postMock } = makeRest();
postMock.mockResolvedValue({ id: "msg", channel_id: "789" });
const res = await sendMessageDiscord("channel:789", "photo", {
rest,
token: "t",
mediaUrl: "file:///tmp/photo.jpg",
});
expect(res.messageId).toBe("msg");
expect(postMock).toHaveBeenCalledWith(
Routes.channelMessages("789"),
expect.objectContaining({
files: [expect.objectContaining({ name: "photo.jpg" })],
}),
);
});
});

166
src/discord/send.ts Normal file
View File

@@ -0,0 +1,166 @@
import { REST, Routes } from "discord.js";
import { chunkText } from "../auto-reply/chunk.js";
import { loadConfig } from "../config/config.js";
import { loadWebMedia } from "../web/media.js";
import { normalizeDiscordToken } from "./token.js";
const DISCORD_TEXT_LIMIT = 2000;
type DiscordRecipient =
| {
kind: "user";
id: string;
}
| {
kind: "channel";
id: string;
};
type DiscordSendOpts = {
token?: string;
mediaUrl?: string;
verbose?: boolean;
rest?: REST;
};
export type DiscordSendResult = {
messageId: string;
channelId: string;
};
function resolveToken(explicit?: string) {
const cfgToken = loadConfig().discord?.token;
const token = normalizeDiscordToken(
explicit ?? process.env.DISCORD_BOT_TOKEN ?? cfgToken ?? undefined,
);
if (!token) {
throw new Error(
"DISCORD_BOT_TOKEN or discord.token is required for Discord sends",
);
}
return token;
}
function parseRecipient(raw: string): DiscordRecipient {
const trimmed = raw.trim();
if (!trimmed) {
throw new Error("Recipient is required for Discord sends");
}
const mentionMatch = trimmed.match(/^<@!?(\d+)>$/);
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("discord:")) {
return { kind: "user", id: trimmed.slice("discord:".length) };
}
if (trimmed.startsWith("@")) {
const candidate = trimmed.slice(1);
if (!/^\d+$/.test(candidate)) {
throw new Error(
"Discord DMs require a user id (use user:<id> or a <@id> mention)",
);
}
return { kind: "user", id: candidate };
}
return { kind: "channel", id: trimmed };
}
async function resolveChannelId(
rest: REST,
recipient: DiscordRecipient,
): Promise<{ channelId: string; dm?: boolean }> {
if (recipient.kind === "channel") {
return { channelId: recipient.id };
}
const dmChannel = (await rest.post(Routes.userChannels(), {
body: { recipient_id: recipient.id },
})) as { id: string };
if (!dmChannel?.id) {
throw new Error("Failed to create Discord DM channel");
}
return { channelId: dmChannel.id, dm: true };
}
async function sendDiscordText(rest: REST, channelId: string, text: string) {
if (!text.trim()) {
throw new Error("Message must be non-empty for Discord sends");
}
if (text.length <= DISCORD_TEXT_LIMIT) {
const res = (await rest.post(Routes.channelMessages(channelId), {
body: { content: text },
})) as { id: string; channel_id: string };
return res;
}
const chunks = chunkText(text, DISCORD_TEXT_LIMIT);
let last: { id: string; channel_id: string } | null = null;
for (const chunk of chunks) {
last = (await rest.post(Routes.channelMessages(channelId), {
body: { content: chunk },
})) as { id: string; channel_id: string };
}
if (!last) {
throw new Error("Discord send failed (empty chunk result)");
}
return last;
}
async function sendDiscordMedia(
rest: REST,
channelId: string,
text: string,
mediaUrl: string,
) {
const media = await loadWebMedia(mediaUrl);
const caption =
text.length > DISCORD_TEXT_LIMIT ? text.slice(0, DISCORD_TEXT_LIMIT) : text;
const res = (await rest.post(Routes.channelMessages(channelId), {
body: {
content: caption || undefined,
},
files: [
{
data: media.buffer,
name: media.fileName ?? "upload",
},
],
})) as { id: string; channel_id: string };
if (text.length > DISCORD_TEXT_LIMIT) {
const remaining = text.slice(DISCORD_TEXT_LIMIT).trim();
if (remaining) {
await sendDiscordText(rest, channelId, remaining);
}
}
return res;
}
export async function sendMessageDiscord(
to: string,
text: string,
opts: DiscordSendOpts = {},
): Promise<DiscordSendResult> {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const recipient = parseRecipient(to);
const { channelId } = await resolveChannelId(rest, recipient);
let result:
| { id: string; channel_id: string }
| { id: string | null; channel_id: string };
if (opts.mediaUrl) {
result = await sendDiscordMedia(rest, channelId, text, opts.mediaUrl);
} else {
result = await sendDiscordText(rest, channelId, text);
}
return {
messageId: result.id ? String(result.id) : "unknown",
channelId: String(result.channel_id ?? channelId),
};
}

7
src/discord/token.ts Normal file
View File

@@ -0,0 +1,7 @@
export function normalizeDiscordToken(raw?: string | null): string | undefined {
if (!raw) return undefined;
const trimmed = raw.trim();
if (!trimmed) return undefined;
return trimmed.replace(/^Bot\s+/i, "");
}

View File

@@ -18,7 +18,7 @@ export type HookMappingResolved = {
messageTemplate?: string;
textTemplate?: string;
deliver?: boolean;
channel?: "last" | "whatsapp" | "telegram";
channel?: "last" | "whatsapp" | "telegram" | "discord";
to?: string;
thinking?: string;
timeoutSeconds?: number;
@@ -50,7 +50,7 @@ export type HookAction =
wakeMode: "now" | "next-heartbeat";
sessionKey?: string;
deliver?: boolean;
channel?: "last" | "whatsapp" | "telegram";
channel?: "last" | "whatsapp" | "telegram" | "discord";
to?: string;
thinking?: string;
timeoutSeconds?: number;
@@ -86,7 +86,7 @@ type HookTransformResult = Partial<{
name: string;
sessionKey: string;
deliver: boolean;
channel: "last" | "whatsapp" | "telegram";
channel: "last" | "whatsapp" | "telegram" | "discord";
to: string;
thinking: string;
timeoutSeconds: number;

View File

@@ -450,6 +450,7 @@ export const CronPayloadSchema = Type.Union([
Type.Literal("last"),
Type.Literal("whatsapp"),
Type.Literal("telegram"),
Type.Literal("discord"),
]),
),
to: Type.Optional(Type.String()),

View File

@@ -1793,6 +1793,61 @@ describe("gateway server", () => {
await server.close();
});
test("agent routes main last-channel discord", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-"));
testSessionStorePath = path.join(dir, "sessions.json");
await fs.writeFile(
testSessionStorePath,
JSON.stringify(
{
main: {
sessionId: "sess-discord",
updatedAt: Date.now(),
lastChannel: "discord",
lastTo: "channel:discord-123",
},
},
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
ws.send(
JSON.stringify({
type: "req",
id: "agent-last-discord",
method: "agent",
params: {
message: "hi",
sessionKey: "main",
channel: "last",
deliver: true,
idempotencyKey: "idem-agent-last-discord",
},
}),
);
await onceMessage(
ws,
(o) => o.type === "res" && o.id === "agent-last-discord",
);
const spy = vi.mocked(agentCommand);
expect(spy).toHaveBeenCalled();
const call = spy.mock.calls.at(-1)?.[0] as Record<string, unknown>;
expect(call.provider).toBe("discord");
expect(call.to).toBe("channel:discord-123");
expect(call.deliver).toBe(true);
expect(call.bestEffortDeliver).toBe(true);
expect(call.sessionId).toBe("sess-discord");
ws.close();
await server.close();
});
test("agent ignores webchat last-channel for routing", async () => {
testAllowFrom = ["+1555"];
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-"));

View File

@@ -67,6 +67,8 @@ import {
import { CronService } from "../cron/service.js";
import { resolveCronStorePath } from "../cron/store.js";
import type { CronJob, CronJobCreate, CronJobPatch } from "../cron/types.js";
import { monitorDiscordProvider, sendMessageDiscord } from "../discord/index.js";
import { probeDiscord, type DiscordProbe } from "../discord/probe.js";
import { isVerbose } from "../globals.js";
import { onAgentEvent } from "../infra/agent-events.js";
import { startGatewayBonjourAdvertiser } from "../infra/bonjour.js";
@@ -273,9 +275,11 @@ const logHooks = log.child("hooks");
const logWsControl = log.child("ws");
const logWhatsApp = logProviders.child("whatsapp");
const logTelegram = logProviders.child("telegram");
const logDiscord = logProviders.child("discord");
const canvasRuntime = runtimeForLogger(logCanvas);
const whatsappRuntimeEnv = runtimeForLogger(logWhatsApp);
const telegramRuntimeEnv = runtimeForLogger(logTelegram);
const discordRuntimeEnv = runtimeForLogger(logDiscord);
function resolveBonjourCliPath(): string | undefined {
const envPath = process.env.CLAWDIS_CLI_PATH?.trim();
@@ -1378,13 +1382,17 @@ export async function startGatewayServer(
const channel =
channelRaw === "whatsapp" ||
channelRaw === "telegram" ||
channelRaw === "discord" ||
channelRaw === "last"
? channelRaw
: channelRaw === undefined
? "last"
: null;
if (channel === null) {
return { ok: false, error: "channel must be last|whatsapp|telegram" };
return {
ok: false,
error: "channel must be last|whatsapp|telegram|discord",
};
}
const toRaw = payload.to;
const to =
@@ -1703,8 +1711,10 @@ export async function startGatewayServer(
});
let whatsappAbort: AbortController | null = null;
let telegramAbort: AbortController | null = null;
let discordAbort: AbortController | null = null;
let whatsappTask: Promise<unknown> | null = null;
let telegramTask: Promise<unknown> | null = null;
let discordTask: Promise<unknown> | null = null;
let whatsappRuntime: WebProviderStatus = {
running: false,
connected: false,
@@ -1728,6 +1738,17 @@ export async function startGatewayServer(
lastError: null,
mode: null,
};
let discordRuntime: {
running: boolean;
lastStartAt?: number | null;
lastStopAt?: number | null;
lastError?: string | null;
} = {
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
};
const clients = new Set<Client>();
let seq = 0;
// Track per-run sequence to detect out-of-order/lost agent events.
@@ -1954,9 +1975,88 @@ export async function startGatewayServer(
};
};
const startDiscordProvider = async () => {
if (discordTask) return;
const cfg = loadConfig();
const discordToken =
process.env.DISCORD_BOT_TOKEN ?? cfg.discord?.token ?? "";
if (!discordToken.trim()) {
discordRuntime = {
...discordRuntime,
running: false,
lastError: "not configured",
};
logDiscord.info(
"skipping provider start (no DISCORD_BOT_TOKEN/config)",
);
return;
}
let discordBotLabel = "";
try {
const probe = await probeDiscord(discordToken.trim(), 2500);
const username = probe.ok ? probe.bot?.username?.trim() : null;
if (username) discordBotLabel = ` (@${username})`;
} catch (err) {
if (isVerbose()) {
logDiscord.debug(`bot probe failed: ${String(err)}`);
}
}
logDiscord.info(`starting provider${discordBotLabel}`);
discordAbort = new AbortController();
discordRuntime = {
...discordRuntime,
running: true,
lastStartAt: Date.now(),
lastError: null,
};
const task = monitorDiscordProvider({
token: discordToken.trim(),
runtime: discordRuntimeEnv,
abortSignal: discordAbort.signal,
allowFrom: cfg.discord?.allowFrom,
requireMention: cfg.discord?.requireMention,
mediaMaxMb: cfg.discord?.mediaMaxMb,
})
.catch((err) => {
discordRuntime = {
...discordRuntime,
lastError: formatError(err),
};
logDiscord.error(`provider exited: ${formatError(err)}`);
})
.finally(() => {
discordAbort = null;
discordTask = null;
discordRuntime = {
...discordRuntime,
running: false,
lastStopAt: Date.now(),
};
});
discordTask = task;
};
const stopDiscordProvider = async () => {
if (!discordAbort && !discordTask) return;
discordAbort?.abort();
try {
await discordTask;
} catch {
// ignore
}
discordAbort = null;
discordTask = null;
discordRuntime = {
...discordRuntime,
running: false,
lastStopAt: Date.now(),
};
};
const startProviders = async () => {
await startWhatsAppProvider();
await startTelegramProvider();
await startDiscordProvider();
};
const broadcast = (
@@ -3784,6 +3884,21 @@ export async function startGatewayServer(
lastProbeAt = Date.now();
}
const discordEnvToken = process.env.DISCORD_BOT_TOKEN?.trim();
const discordConfigToken = cfg.discord?.token?.trim();
const discordToken = discordEnvToken || discordConfigToken || "";
const discordTokenSource = discordEnvToken
? "env"
: discordConfigToken
? "config"
: "none";
let discordProbe: DiscordProbe | undefined;
let discordLastProbeAt: number | null = null;
if (probe && discordToken) {
discordProbe = await probeDiscord(discordToken, timeoutMs);
discordLastProbeAt = Date.now();
}
const linked = await webAuthExists();
const authAgeMs = getWebAuthAgeMs();
const self = readWebSelfId();
@@ -3817,6 +3932,16 @@ export async function startGatewayServer(
probe: telegramProbe,
lastProbeAt,
},
discord: {
configured: Boolean(discordToken),
tokenSource: discordTokenSource,
running: discordRuntime.running,
lastStartAt: discordRuntime.lastStartAt ?? null,
lastStopAt: discordRuntime.lastStopAt ?? null,
lastError: discordRuntime.lastError ?? null,
probe: discordProbe,
lastProbeAt: discordLastProbeAt,
},
},
undefined,
);
@@ -5588,6 +5713,23 @@ export async function startGatewayServer(
payload,
});
respond(true, payload, undefined, { provider });
} else if (provider === "discord") {
const result = await sendMessageDiscord(to, message, {
mediaUrl: params.mediaUrl,
token: process.env.DISCORD_BOT_TOKEN,
});
const payload = {
runId: idem,
messageId: result.messageId,
channelId: result.channelId,
provider,
};
dedupe.set(`send:${idem}`, {
ts: Date.now(),
ok: true,
payload,
});
respond(true, payload, undefined, { provider });
} else {
const result = await sendMessageWhatsApp(to, message, {
mediaUrl: params.mediaUrl,
@@ -5723,6 +5865,7 @@ export async function startGatewayServer(
if (
requestedChannel === "whatsapp" ||
requestedChannel === "telegram" ||
requestedChannel === "discord" ||
requestedChannel === "webchat"
) {
return requestedChannel;
@@ -5740,7 +5883,8 @@ export async function startGatewayServer(
if (explicit) return explicit;
if (
resolvedChannel === "whatsapp" ||
resolvedChannel === "telegram"
resolvedChannel === "telegram" ||
resolvedChannel === "discord"
) {
return lastTo || undefined;
}
@@ -5975,6 +6119,7 @@ export async function startGatewayServer(
}
await stopWhatsAppProvider();
await stopTelegramProvider();
await stopDiscordProvider();
cron.stop();
heartbeatRunner.stop();
broadcast("shutdown", {

View File

@@ -13,14 +13,14 @@ export function isVerbose() {
}
export function logVerbose(message: string) {
if (globalVerbose) {
console.log(chalk.gray(message));
try {
getLogger().debug({ message }, "verbose");
} catch {
// ignore logger failures to avoid breaking verbose printing
}
// if (globalVerbose) {
console.log(chalk.gray(message));
try {
getLogger().debug({ message }, "verbose");
} catch {
// ignore logger failures to avoid breaking verbose printing
}
// }
}
export function setYes(v: boolean) {