feat: add reply tags and replyToMode

This commit is contained in:
Peter Steinberger
2026-01-02 23:18:41 +01:00
parent a9ff03acaf
commit 2c92ccd66e
19 changed files with 353 additions and 27 deletions

View File

@@ -14,7 +14,7 @@ import { formatAgentEnvelope } from "../auto-reply/envelope.js";
import { getReplyFromConfig } from "../auto-reply/reply.js";
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
import type { ReplyPayload } from "../auto-reply/types.js";
import type { DiscordSlashCommandConfig } from "../config/config.js";
import type { DiscordSlashCommandConfig, ReplyToMode } from "../config/config.js";
import { loadConfig } from "../config/config.js";
import { resolveStorePath, updateLastRoute } from "../config/sessions.js";
import { danger, isVerbose, logVerbose, warn } from "../globals.js";
@@ -32,6 +32,7 @@ export type MonitorDiscordOpts = {
slashCommand?: DiscordSlashCommandConfig;
mediaMaxMb?: number;
historyLimit?: number;
replyToMode?: ReplyToMode;
};
type DiscordMediaInfo = {
@@ -117,6 +118,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
0,
opts.historyLimit ?? cfg.discord?.historyLimit ?? 20,
);
const replyToMode = opts.replyToMode ?? cfg.discord?.replyToMode ?? "off";
const dmEnabled = dmConfig?.enabled ?? true;
const groupDmEnabled = dmConfig?.groupEnabled ?? false;
const groupDmChannels = dmConfig?.groupChannels;
@@ -417,6 +419,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
target: ctxPayload.To,
token,
runtime,
replyToMode,
});
if (isVerbose()) {
logVerbose(
@@ -984,20 +987,34 @@ async function deliverReplies({
target,
token,
runtime,
replyToMode,
}: {
replies: ReplyPayload[];
target: string;
token: string;
runtime: RuntimeEnv;
replyToMode: ReplyToMode;
}) {
let hasReplied = false;
for (const payload of replies) {
const mediaList =
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const text = payload.text ?? "";
const replyToId =
replyToMode === "off" ? undefined : payload.replyToId?.trim();
if (!text && mediaList.length === 0) continue;
if (mediaList.length === 0) {
for (const chunk of chunkText(text, 2000)) {
await sendMessageDiscord(target, chunk, { token });
await sendMessageDiscord(target, chunk, {
token,
replyTo:
replyToId && (replyToMode === "all" || !hasReplied)
? replyToId
: undefined,
});
if (replyToId && !hasReplied) {
hasReplied = true;
}
}
} else {
let first = true;
@@ -1007,7 +1024,14 @@ async function deliverReplies({
await sendMessageDiscord(target, caption, {
token,
mediaUrl,
replyTo:
replyToId && (replyToMode === "all" || !hasReplied)
? replyToId
: undefined,
});
if (replyToId && !hasReplied) {
hasReplied = true;
}
}
}
runtime.log?.(`delivered reply to ${target}`);

View File

@@ -82,4 +82,37 @@ describe("sendMessageDiscord", () => {
}),
);
});
it("includes message_reference when replying", async () => {
const { rest, postMock } = makeRest();
postMock.mockResolvedValue({ id: "msg1", channel_id: "789" });
await sendMessageDiscord("channel:789", "hello", {
rest,
token: "t",
replyTo: "orig-123",
});
const body = postMock.mock.calls[0]?.[1]?.body;
expect(body?.message_reference).toEqual({
message_id: "orig-123",
fail_if_not_exists: false,
});
});
it("replies only on the first chunk", async () => {
const { rest, postMock } = makeRest();
postMock.mockResolvedValue({ id: "msg1", channel_id: "789" });
await sendMessageDiscord("channel:789", "a".repeat(2001), {
rest,
token: "t",
replyTo: "orig-123",
});
expect(postMock).toHaveBeenCalledTimes(2);
const firstBody = postMock.mock.calls[0]?.[1]?.body;
const secondBody = postMock.mock.calls[1]?.[1]?.body;
expect(firstBody?.message_reference).toEqual({
message_id: "orig-123",
fail_if_not_exists: false,
});
expect(secondBody?.message_reference).toBeUndefined();
});
});

View File

@@ -22,6 +22,7 @@ type DiscordSendOpts = {
mediaUrl?: string;
verbose?: boolean;
rest?: REST;
replyTo?: string;
};
export type DiscordSendResult = {
@@ -105,22 +106,35 @@ async function resolveChannelId(
return { channelId: dmChannel.id, dm: true };
}
async function sendDiscordText(rest: REST, channelId: string, text: string) {
async function sendDiscordText(
rest: REST,
channelId: string,
text: string,
replyTo?: string,
) {
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;
if (text.length <= DISCORD_TEXT_LIMIT) {
const res = (await rest.post(Routes.channelMessages(channelId), {
body: { content: text },
body: { content: text, message_reference: messageReference },
})) as { id: string; channel_id: string };
return res;
}
const chunks = chunkText(text, DISCORD_TEXT_LIMIT);
let last: { id: string; channel_id: string } | null = null;
let isFirst = true;
for (const chunk of chunks) {
last = (await rest.post(Routes.channelMessages(channelId), {
body: { content: chunk },
body: {
content: chunk,
message_reference: isFirst ? messageReference : undefined,
},
})) as { id: string; channel_id: string };
isFirst = false;
}
if (!last) {
throw new Error("Discord send failed (empty chunk result)");
@@ -133,13 +147,18 @@ async function sendDiscordMedia(
channelId: string,
text: string,
mediaUrl: string,
replyTo?: string,
) {
const media = await loadWebMedia(mediaUrl);
const caption =
text.length > DISCORD_TEXT_LIMIT ? text.slice(0, DISCORD_TEXT_LIMIT) : text;
const messageReference = replyTo
? { message_id: replyTo, fail_if_not_exists: false }
: undefined;
const res = (await rest.post(Routes.channelMessages(channelId), {
body: {
content: caption || undefined,
message_reference: messageReference,
},
files: [
{
@@ -171,9 +190,15 @@ export async function sendMessageDiscord(
| { id: string | null; channel_id: string };
if (opts.mediaUrl) {
result = await sendDiscordMedia(rest, channelId, text, opts.mediaUrl);
result = await sendDiscordMedia(
rest,
channelId,
text,
opts.mediaUrl,
opts.replyTo,
);
} else {
result = await sendDiscordText(rest, channelId, text);
result = await sendDiscordText(rest, channelId, text, opts.replyTo);
}
return {