feat(discord): add silent message support (SUPPRESS_NOTIFICATIONS flag)

- Add silent option to message tool for Discord
- Passes SUPPRESS_NOTIFICATIONS flag (4096) to Discord API
- Threads silent param through entire outbound chain:
  - message-action-runner.ts
  - outbound-send-service.ts
  - message.ts
  - deliver.ts
  - discord outbound adapter
  - send.outbound.ts
  - send.shared.ts

Usage: message tool with silent=true suppresses push/desktop notifications
This commit is contained in:
nyanjou
2026-02-03 14:19:24 +01:00
committed by Shadow
parent b9da2c4679
commit 77df8b1104
7 changed files with 30 additions and 2 deletions

View File

@@ -6,22 +6,24 @@ export const discordOutbound: ChannelOutboundAdapter = {
chunker: null, chunker: null,
textChunkLimit: 2000, textChunkLimit: 2000,
pollMaxOptions: 10, pollMaxOptions: 10,
sendText: async ({ to, text, accountId, deps, replyToId }) => { sendText: async ({ to, text, accountId, deps, replyToId, silent }) => {
const send = deps?.sendDiscord ?? sendMessageDiscord; const send = deps?.sendDiscord ?? sendMessageDiscord;
const result = await send(to, text, { const result = await send(to, text, {
verbose: false, verbose: false,
replyTo: replyToId ?? undefined, replyTo: replyToId ?? undefined,
accountId: accountId ?? undefined, accountId: accountId ?? undefined,
silent: silent ?? undefined,
}); });
return { channel: "discord", ...result }; return { channel: "discord", ...result };
}, },
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId }) => { sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId, silent }) => {
const send = deps?.sendDiscord ?? sendMessageDiscord; const send = deps?.sendDiscord ?? sendMessageDiscord;
const result = await send(to, text, { const result = await send(to, text, {
verbose: false, verbose: false,
mediaUrl, mediaUrl,
replyTo: replyToId ?? undefined, replyTo: replyToId ?? undefined,
accountId: accountId ?? undefined, accountId: accountId ?? undefined,
silent: silent ?? undefined,
}); });
return { channel: "discord", ...result }; return { channel: "discord", ...result };
}, },

View File

@@ -80,6 +80,7 @@ export type ChannelOutboundContext = {
threadId?: string | number | null; threadId?: string | number | null;
accountId?: string | null; accountId?: string | null;
deps?: OutboundSendDeps; deps?: OutboundSendDeps;
silent?: boolean;
}; };
export type ChannelOutboundPayloadContext = ChannelOutboundContext & { export type ChannelOutboundPayloadContext = ChannelOutboundContext & {

View File

@@ -278,6 +278,9 @@ async function resolveChannelId(
return { channelId: dmChannel.id, dm: true }; return { channelId: dmChannel.id, dm: true };
} }
// Discord message flag for silent/suppress notifications
const SUPPRESS_NOTIFICATIONS_FLAG = 1 << 12;
export function buildDiscordTextChunks( export function buildDiscordTextChunks(
text: string, text: string,
opts: { maxLinesPerMessage?: number; chunkMode?: ChunkMode; maxChars?: number } = {}, opts: { maxLinesPerMessage?: number; chunkMode?: ChunkMode; maxChars?: number } = {},
@@ -305,11 +308,13 @@ async function sendDiscordText(
maxLinesPerMessage?: number, maxLinesPerMessage?: number,
embeds?: unknown[], embeds?: unknown[],
chunkMode?: ChunkMode, chunkMode?: ChunkMode,
silent?: boolean,
) { ) {
if (!text.trim()) { if (!text.trim()) {
throw new Error("Message must be non-empty for Discord sends"); throw new Error("Message must be non-empty for Discord sends");
} }
const messageReference = replyTo ? { message_id: replyTo, fail_if_not_exists: false } : undefined; const messageReference = replyTo ? { message_id: replyTo, fail_if_not_exists: false } : undefined;
const flags = silent ? SUPPRESS_NOTIFICATIONS_FLAG : undefined;
const chunks = buildDiscordTextChunks(text, { maxLinesPerMessage, chunkMode }); const chunks = buildDiscordTextChunks(text, { maxLinesPerMessage, chunkMode });
if (chunks.length === 1) { if (chunks.length === 1) {
const res = (await request( const res = (await request(
@@ -319,6 +324,7 @@ async function sendDiscordText(
content: chunks[0], content: chunks[0],
message_reference: messageReference, message_reference: messageReference,
...(embeds?.length ? { embeds } : {}), ...(embeds?.length ? { embeds } : {}),
...(flags ? { flags } : {}),
}, },
}) as Promise<{ id: string; channel_id: string }>, }) as Promise<{ id: string; channel_id: string }>,
"text", "text",
@@ -335,6 +341,7 @@ async function sendDiscordText(
content: chunk, content: chunk,
message_reference: isFirst ? messageReference : undefined, message_reference: isFirst ? messageReference : undefined,
...(isFirst && embeds?.length ? { embeds } : {}), ...(isFirst && embeds?.length ? { embeds } : {}),
...(flags ? { flags } : {}),
}, },
}) as Promise<{ id: string; channel_id: string }>, }) as Promise<{ id: string; channel_id: string }>,
"text", "text",
@@ -357,12 +364,14 @@ async function sendDiscordMedia(
maxLinesPerMessage?: number, maxLinesPerMessage?: number,
embeds?: unknown[], embeds?: unknown[],
chunkMode?: ChunkMode, chunkMode?: ChunkMode,
silent?: boolean,
) { ) {
const media = await loadWebMedia(mediaUrl); const media = await loadWebMedia(mediaUrl);
const chunks = text ? buildDiscordTextChunks(text, { maxLinesPerMessage, chunkMode }) : []; const chunks = text ? buildDiscordTextChunks(text, { maxLinesPerMessage, chunkMode }) : [];
const caption = chunks[0] ?? ""; const caption = chunks[0] ?? "";
const hasCaption = caption.trim().length > 0; const hasCaption = caption.trim().length > 0;
const messageReference = replyTo ? { message_id: replyTo, fail_if_not_exists: false } : undefined; const messageReference = replyTo ? { message_id: replyTo, fail_if_not_exists: false } : undefined;
const flags = silent ? SUPPRESS_NOTIFICATIONS_FLAG : undefined;
const res = (await request( const res = (await request(
() => () =>
rest.post(Routes.channelMessages(channelId), { rest.post(Routes.channelMessages(channelId), {
@@ -373,6 +382,7 @@ async function sendDiscordMedia(
...(hasCaption ? { content: caption } : {}), ...(hasCaption ? { content: caption } : {}),
...(messageReference ? { message_reference: messageReference } : {}), ...(messageReference ? { message_reference: messageReference } : {}),
...(embeds?.length ? { embeds } : {}), ...(embeds?.length ? { embeds } : {}),
...(flags ? { flags } : {}),
files: [ files: [
{ {
data: media.buffer, data: media.buffer,
@@ -396,6 +406,7 @@ async function sendDiscordMedia(
maxLinesPerMessage, maxLinesPerMessage,
undefined, undefined,
chunkMode, chunkMode,
silent,
); );
} }
return res; return res;

View File

@@ -86,6 +86,7 @@ async function createChannelHandler(params: {
threadId?: string | number | null; threadId?: string | number | null;
deps?: OutboundSendDeps; deps?: OutboundSendDeps;
gifPlayback?: boolean; gifPlayback?: boolean;
silent?: boolean;
}): Promise<ChannelHandler> { }): Promise<ChannelHandler> {
const outbound = await loadChannelOutboundAdapter(params.channel); const outbound = await loadChannelOutboundAdapter(params.channel);
if (!outbound?.sendText || !outbound?.sendMedia) { if (!outbound?.sendText || !outbound?.sendMedia) {
@@ -101,6 +102,7 @@ async function createChannelHandler(params: {
threadId: params.threadId, threadId: params.threadId,
deps: params.deps, deps: params.deps,
gifPlayback: params.gifPlayback, gifPlayback: params.gifPlayback,
silent: params.silent,
}); });
if (!handler) { if (!handler) {
throw new Error(`Outbound not configured for channel: ${params.channel}`); throw new Error(`Outbound not configured for channel: ${params.channel}`);
@@ -118,6 +120,7 @@ function createPluginHandler(params: {
threadId?: string | number | null; threadId?: string | number | null;
deps?: OutboundSendDeps; deps?: OutboundSendDeps;
gifPlayback?: boolean; gifPlayback?: boolean;
silent?: boolean;
}): ChannelHandler | null { }): ChannelHandler | null {
const outbound = params.outbound; const outbound = params.outbound;
if (!outbound?.sendText || !outbound?.sendMedia) { if (!outbound?.sendText || !outbound?.sendMedia) {
@@ -143,6 +146,7 @@ function createPluginHandler(params: {
threadId: params.threadId, threadId: params.threadId,
gifPlayback: params.gifPlayback, gifPlayback: params.gifPlayback,
deps: params.deps, deps: params.deps,
silent: params.silent,
payload, payload,
}) })
: undefined, : undefined,
@@ -156,6 +160,7 @@ function createPluginHandler(params: {
threadId: params.threadId, threadId: params.threadId,
gifPlayback: params.gifPlayback, gifPlayback: params.gifPlayback,
deps: params.deps, deps: params.deps,
silent: params.silent,
}), }),
sendMedia: async (caption, mediaUrl) => sendMedia: async (caption, mediaUrl) =>
sendMedia({ sendMedia({
@@ -168,6 +173,7 @@ function createPluginHandler(params: {
threadId: params.threadId, threadId: params.threadId,
gifPlayback: params.gifPlayback, gifPlayback: params.gifPlayback,
deps: params.deps, deps: params.deps,
silent: params.silent,
}), }),
}; };
} }
@@ -192,6 +198,7 @@ export async function deliverOutboundPayloads(params: {
text?: string; text?: string;
mediaUrls?: string[]; mediaUrls?: string[];
}; };
silent?: boolean;
}): Promise<OutboundDeliveryResult[]> { }): Promise<OutboundDeliveryResult[]> {
const { cfg, channel, to, payloads } = params; const { cfg, channel, to, payloads } = params;
const accountId = params.accountId; const accountId = params.accountId;
@@ -208,6 +215,7 @@ export async function deliverOutboundPayloads(params: {
replyToId: params.replyToId, replyToId: params.replyToId,
threadId: params.threadId, threadId: params.threadId,
gifPlayback: params.gifPlayback, gifPlayback: params.gifPlayback,
silent: params.silent,
}); });
const textLimit = handler.chunker const textLimit = handler.chunker
? resolveTextChunkLimit(cfg, channel, accountId, { ? resolveTextChunkLimit(cfg, channel, accountId, {

View File

@@ -820,6 +820,7 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
params.message = message; params.message = message;
const gifPlayback = readBooleanParam(params, "gifPlayback") ?? false; const gifPlayback = readBooleanParam(params, "gifPlayback") ?? false;
const bestEffort = readBooleanParam(params, "bestEffort"); const bestEffort = readBooleanParam(params, "bestEffort");
const silent = readBooleanParam(params, "silent");
const replyToId = readStringParam(params, "replyTo"); const replyToId = readStringParam(params, "replyTo");
const threadId = readStringParam(params, "threadId"); const threadId = readStringParam(params, "threadId");
@@ -884,6 +885,7 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
} }
: undefined, : undefined,
abortSignal, abortSignal,
silent: silent ?? undefined,
}, },
to, to,
message, message,

View File

@@ -51,6 +51,7 @@ type MessageSendParams = {
mediaUrls?: string[]; mediaUrls?: string[];
}; };
abortSignal?: AbortSignal; abortSignal?: AbortSignal;
silent?: boolean;
}; };
export type MessageSendResult = { export type MessageSendResult = {
@@ -173,6 +174,7 @@ export async function sendMessage(params: MessageSendParams): Promise<MessageSen
deps: params.deps, deps: params.deps,
bestEffort: params.bestEffort, bestEffort: params.bestEffort,
abortSignal: params.abortSignal, abortSignal: params.abortSignal,
silent: params.silent,
mirror: params.mirror mirror: params.mirror
? { ? {
...params.mirror, ...params.mirror,

View File

@@ -34,6 +34,7 @@ export type OutboundSendContext = {
mediaUrls?: string[]; mediaUrls?: string[];
}; };
abortSignal?: AbortSignal; abortSignal?: AbortSignal;
silent?: boolean;
}; };
function extractToolPayload(result: AgentToolResult<unknown>): unknown { function extractToolPayload(result: AgentToolResult<unknown>): unknown {
@@ -128,6 +129,7 @@ export async function executeSendAction(params: {
gateway: params.ctx.gateway, gateway: params.ctx.gateway,
mirror: params.ctx.mirror, mirror: params.ctx.mirror,
abortSignal: params.ctx.abortSignal, abortSignal: params.ctx.abortSignal,
silent: params.ctx.silent,
}); });
return { return {