feat: add reply tags and replyToMode
This commit is contained in:
@@ -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}`);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user