feat(discord): Discord transport
This commit is contained in:
committed by
Peter Steinberger
parent
557f8e5a04
commit
ac659ff5a7
@@ -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.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)"));
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
2
src/discord/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { monitorDiscordProvider } from "./monitor.js";
|
||||
export { sendMessageDiscord } from "./send.js";
|
||||
323
src/discord/monitor.ts
Normal file
323
src/discord/monitor.ts
Normal 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
73
src/discord/probe.ts
Normal 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
85
src/discord/send.test.ts
Normal 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
166
src/discord/send.ts
Normal 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
7
src/discord/token.ts
Normal 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, "");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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-"));
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user