Discord: preserve accountId in message actions (refs #1489)

This commit is contained in:
Sergii Kozak
2026-01-22 23:51:58 -08:00
parent 551685351f
commit dc89bc4004
9 changed files with 181 additions and 9 deletions

View File

@@ -5,6 +5,7 @@ import { truncateUtf16Safe } from "../../utils.js";
import { optionalStringEnum, stringEnum } from "../schema/typebox.js"; import { optionalStringEnum, stringEnum } from "../schema/typebox.js";
import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js"; import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js";
import { callGatewayTool, type GatewayCallOptions } from "./gateway.js"; import { callGatewayTool, type GatewayCallOptions } from "./gateway.js";
import { resolveSessionAgentId } from "../agent-scope.js";
import { resolveInternalSessionKey, resolveMainSessionAlias } from "./sessions-helpers.js"; import { resolveInternalSessionKey, resolveMainSessionAlias } from "./sessions-helpers.js";
// NOTE: We use Type.Object({}, { additionalProperties: true }) for job/patch // NOTE: We use Type.Object({}, { additionalProperties: true }) for job/patch
@@ -158,6 +159,15 @@ export function createCronTool(opts?: CronToolOptions): AnyAgentTool {
throw new Error("job required"); throw new Error("job required");
} }
const job = normalizeCronJobCreate(params.job) ?? params.job; const job = normalizeCronJobCreate(params.job) ?? params.job;
if (job && typeof job === "object") {
const cfg = loadConfig();
const agentId = opts?.agentSessionKey
? resolveSessionAgentId({ sessionKey: opts.agentSessionKey, config: cfg })
: undefined;
if (agentId && !(job as { agentId?: unknown }).agentId) {
(job as { agentId?: string }).agentId = agentId;
}
}
const contextMessages = const contextMessages =
typeof params.contextMessages === "number" && Number.isFinite(params.contextMessages) typeof params.contextMessages === "number" && Number.isFinite(params.contextMessages)
? params.contextMessages ? params.contextMessages

View File

@@ -114,7 +114,8 @@ export async function handleDiscordMessagingAction(
required: true, required: true,
label: "stickerIds", label: "stickerIds",
}); });
await sendStickerDiscord(to, stickerIds, { content }); const accountId = readStringParam(params, "accountId");
await sendStickerDiscord(to, stickerIds, { content, accountId: accountId ?? undefined });
return jsonResult({ ok: true }); return jsonResult({ ok: true });
} }
case "poll": { case "poll": {
@@ -137,10 +138,11 @@ export async function handleDiscordMessagingAction(
const durationHours = const durationHours =
typeof durationRaw === "number" && Number.isFinite(durationRaw) ? durationRaw : undefined; typeof durationRaw === "number" && Number.isFinite(durationRaw) ? durationRaw : undefined;
const maxSelections = allowMultiselect ? Math.max(2, answers.length) : 1; const maxSelections = allowMultiselect ? Math.max(2, answers.length) : 1;
const accountId = readStringParam(params, "accountId");
await sendPollDiscord( await sendPollDiscord(
to, to,
{ question, options: answers, maxSelections, durationHours }, { question, options: answers, maxSelections, durationHours },
{ content }, { content, accountId: accountId ?? undefined },
); );
return jsonResult({ ok: true }); return jsonResult({ ok: true });
} }
@@ -211,7 +213,10 @@ export async function handleDiscordMessagingAction(
const replyTo = readStringParam(params, "replyTo"); const replyTo = readStringParam(params, "replyTo");
const embeds = const embeds =
Array.isArray(params.embeds) && params.embeds.length > 0 ? params.embeds : undefined; Array.isArray(params.embeds) && params.embeds.length > 0 ? params.embeds : undefined;
const accountId = readStringParam(params, "accountId");
const result = await sendMessageDiscord(to, content, { const result = await sendMessageDiscord(to, content, {
accountId: accountId ?? undefined,
mediaUrl, mediaUrl,
replyTo, replyTo,
embeds, embeds,
@@ -298,7 +303,9 @@ export async function handleDiscordMessagingAction(
}); });
const mediaUrl = readStringParam(params, "mediaUrl"); const mediaUrl = readStringParam(params, "mediaUrl");
const replyTo = readStringParam(params, "replyTo"); const replyTo = readStringParam(params, "replyTo");
const accountId = readStringParam(params, "accountId");
const result = await sendMessageDiscord(`channel:${channelId}`, content, { const result = await sendMessageDiscord(`channel:${channelId}`, content, {
accountId: accountId ?? undefined,
mediaUrl, mediaUrl,
replyTo, replyTo,
}); });

View File

@@ -342,6 +342,9 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
}) as ChannelMessageActionName; }) as ChannelMessageActionName;
const accountId = readStringParam(params, "accountId") ?? agentAccountId; const accountId = readStringParam(params, "accountId") ?? agentAccountId;
if (accountId) {
params.accountId = accountId;
}
const gateway = { const gateway = {
url: readStringParam(params, "gatewayUrl", { trim: false }), url: readStringParam(params, "gatewayUrl", { trim: false }),

View File

@@ -1,11 +1,41 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../../../config/config.js"; import type { ClawdbotConfig } from "../../../config/config.js";
import { discordMessageActions } from "./discord.js"; type SendMessageDiscord = typeof import("../../../discord/send.js").sendMessageDiscord;
type SendPollDiscord = typeof import("../../../discord/send.js").sendPollDiscord;
const sendMessageDiscord = vi.fn<Parameters<SendMessageDiscord>, ReturnType<SendMessageDiscord>>(
async () => ({ ok: true }) as Awaited<ReturnType<SendMessageDiscord>>,
);
const sendPollDiscord = vi.fn<Parameters<SendPollDiscord>, ReturnType<SendPollDiscord>>(
async () => ({ ok: true }) as Awaited<ReturnType<SendPollDiscord>>,
);
vi.mock("../../../discord/send.js", async () => {
const actual = await vi.importActual<typeof import("../../../discord/send.js")>(
"../../../discord/send.js",
);
return {
...actual,
sendMessageDiscord: (...args: Parameters<SendMessageDiscord>) => sendMessageDiscord(...args),
sendPollDiscord: (...args: Parameters<SendPollDiscord>) => sendPollDiscord(...args),
};
});
const loadHandleDiscordMessageAction = async () => {
const mod = await import("./discord/handle-action.js");
return mod.handleDiscordMessageAction;
};
const loadDiscordMessageActions = async () => {
const mod = await import("./discord.js");
return mod.discordMessageActions;
};
describe("discord message actions", () => { describe("discord message actions", () => {
it("lists channel and upload actions by default", () => { it("lists channel and upload actions by default", async () => {
const cfg = { channels: { discord: { token: "d0" } } } as ClawdbotConfig; const cfg = { channels: { discord: { token: "d0" } } } as ClawdbotConfig;
const discordMessageActions = await loadDiscordMessageActions();
const actions = discordMessageActions.listActions?.({ cfg }) ?? []; const actions = discordMessageActions.listActions?.({ cfg }) ?? [];
expect(actions).toContain("emoji-upload"); expect(actions).toContain("emoji-upload");
@@ -13,12 +43,65 @@ describe("discord message actions", () => {
expect(actions).toContain("channel-create"); expect(actions).toContain("channel-create");
}); });
it("respects disabled channel actions", () => { it("respects disabled channel actions", async () => {
const cfg = { const cfg = {
channels: { discord: { token: "d0", actions: { channels: false } } }, channels: { discord: { token: "d0", actions: { channels: false } } },
} as ClawdbotConfig; } as ClawdbotConfig;
const discordMessageActions = await loadDiscordMessageActions();
const actions = discordMessageActions.listActions?.({ cfg }) ?? []; const actions = discordMessageActions.listActions?.({ cfg }) ?? [];
expect(actions).not.toContain("channel-create"); expect(actions).not.toContain("channel-create");
}); });
}); });
describe("handleDiscordMessageAction", () => {
it("forwards context accountId for send", async () => {
sendMessageDiscord.mockClear();
const handleDiscordMessageAction = await loadHandleDiscordMessageAction();
await handleDiscordMessageAction({
action: "send",
params: {
to: "channel:123",
message: "hi",
},
cfg: {} as ClawdbotConfig,
accountId: "ops",
});
expect(sendMessageDiscord).toHaveBeenCalledWith(
"channel:123",
"hi",
expect.objectContaining({
accountId: "ops",
}),
);
});
it("falls back to params accountId when context missing", async () => {
sendPollDiscord.mockClear();
const handleDiscordMessageAction = await loadHandleDiscordMessageAction();
await handleDiscordMessageAction({
action: "poll",
params: {
to: "channel:123",
pollQuestion: "Ready?",
pollOption: ["Yes", "No"],
accountId: "marve",
},
cfg: {} as ClawdbotConfig,
});
expect(sendPollDiscord).toHaveBeenCalledWith(
"channel:123",
expect.objectContaining({
question: "Ready?",
options: ["Yes", "No"],
}),
expect.objectContaining({
accountId: "marve",
}),
);
});
});

View File

@@ -80,7 +80,7 @@ export const discordMessageActions: ChannelMessageActionAdapter = {
} }
return null; return null;
}, },
handleAction: async ({ action, params, cfg }) => { handleAction: async ({ action, params, cfg, accountId }) => {
return await handleDiscordMessageAction({ action, params, cfg }); return await handleDiscordMessageAction({ action, params, cfg, accountId });
}, },
}; };

View File

@@ -18,9 +18,10 @@ function readParentIdParam(params: Record<string, unknown>): string | null | und
} }
export async function handleDiscordMessageAction( export async function handleDiscordMessageAction(
ctx: Pick<ChannelMessageActionContext, "action" | "params" | "cfg">, ctx: Pick<ChannelMessageActionContext, "action" | "params" | "cfg" | "accountId">,
): Promise<AgentToolResult<unknown>> { ): Promise<AgentToolResult<unknown>> {
const { action, params, cfg } = ctx; const { action, params, cfg } = ctx;
const accountId = ctx.accountId ?? readStringParam(params, "accountId");
const resolveChannelId = () => const resolveChannelId = () =>
resolveDiscordChannelId( resolveDiscordChannelId(
@@ -39,6 +40,7 @@ export async function handleDiscordMessageAction(
return await handleDiscordAction( return await handleDiscordAction(
{ {
action: "sendMessage", action: "sendMessage",
accountId: accountId ?? undefined,
to, to,
content, content,
mediaUrl: mediaUrl ?? undefined, mediaUrl: mediaUrl ?? undefined,
@@ -62,6 +64,7 @@ export async function handleDiscordMessageAction(
return await handleDiscordAction( return await handleDiscordAction(
{ {
action: "poll", action: "poll",
accountId: accountId ?? undefined,
to, to,
question, question,
answers, answers,

View File

@@ -299,6 +299,7 @@ export async function runCronIsolatedAgentTurn(params: {
sessionId: cronSession.sessionEntry.sessionId, sessionId: cronSession.sessionEntry.sessionId,
sessionKey: agentSessionKey, sessionKey: agentSessionKey,
messageChannel, messageChannel,
agentAccountId: resolvedDelivery.accountId,
sessionFile, sessionFile,
workspaceDir, workspaceDir,
config: cfgWithAgentDefaults, config: cfgWithAgentDefaults,

View File

@@ -410,3 +410,65 @@ describe("runMessageAction sendAttachment hydration", () => {
); );
}); });
}); });
describe("runMessageAction accountId defaults", () => {
const handleAction = vi.fn(async () => jsonResult({ ok: true }));
const accountPlugin: ChannelPlugin = {
id: "discord",
meta: {
id: "discord",
label: "Discord",
selectionLabel: "Discord",
docsPath: "/channels/discord",
blurb: "Discord test plugin.",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => ["default"],
resolveAccount: () => ({}),
},
actions: {
listActions: () => ["send"],
handleAction,
},
};
beforeEach(() => {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "discord",
source: "test",
plugin: accountPlugin,
},
]),
);
handleAction.mockClear();
});
afterEach(() => {
setActivePluginRegistry(createTestRegistry([]));
vi.clearAllMocks();
});
it("propagates defaultAccountId into params", async () => {
await runMessageAction({
cfg: {} as ClawdbotConfig,
action: "send",
params: {
channel: "discord",
target: "channel:123",
message: "hi",
},
defaultAccountId: "ops",
});
expect(handleAction).toHaveBeenCalled();
const ctx = handleAction.mock.calls[0]?.[0] as {
accountId?: string | null;
params: Record<string, unknown>;
};
expect(ctx.accountId).toBe("ops");
expect(ctx.params.accountId).toBe("ops");
});
});

View File

@@ -803,6 +803,9 @@ export async function runMessageAction(
const channel = await resolveChannel(cfg, params); const channel = await resolveChannel(cfg, params);
const accountId = readStringParam(params, "accountId") ?? input.defaultAccountId; const accountId = readStringParam(params, "accountId") ?? input.defaultAccountId;
if (accountId) {
params.accountId = accountId;
}
const dryRun = Boolean(input.dryRun ?? readBooleanParam(params, "dryRun")); const dryRun = Boolean(input.dryRun ?? readBooleanParam(params, "dryRun"));
await hydrateSendAttachmentParams({ await hydrateSendAttachmentParams({