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,
textChunkLimit: 2000,
pollMaxOptions: 10,
sendText: async ({ to, text, accountId, deps, replyToId }) => {
sendText: async ({ to, text, accountId, deps, replyToId, silent }) => {
const send = deps?.sendDiscord ?? sendMessageDiscord;
const result = await send(to, text, {
verbose: false,
replyTo: replyToId ?? undefined,
accountId: accountId ?? undefined,
silent: silent ?? undefined,
});
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 result = await send(to, text, {
verbose: false,
mediaUrl,
replyTo: replyToId ?? undefined,
accountId: accountId ?? undefined,
silent: silent ?? undefined,
});
return { channel: "discord", ...result };
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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