mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:30:42 +00:00
fix(discord): surface final reply permission context
This commit is contained in:
@@ -98,6 +98,21 @@ function isProcessAborted(abortSignal?: AbortSignal): boolean {
|
||||
return Boolean(abortSignal?.aborted);
|
||||
}
|
||||
|
||||
function formatDiscordReplyDeliveryFailure(params: {
|
||||
kind: string;
|
||||
err: unknown;
|
||||
target: string;
|
||||
sessionKey?: string;
|
||||
}) {
|
||||
const context = [
|
||||
`target=${params.target}`,
|
||||
params.sessionKey ? `session=${params.sessionKey}` : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
return `discord ${params.kind} reply failed (${context}): ${String(params.err)}`;
|
||||
}
|
||||
|
||||
type DiscordMessageProcessObserver = {
|
||||
onFinalReplyStart?: () => void;
|
||||
onFinalReplyDelivered?: () => void;
|
||||
@@ -889,7 +904,16 @@ export async function processDiscordMessage(
|
||||
}
|
||||
},
|
||||
onError: (err, info) => {
|
||||
runtime.error?.(danger(`discord ${info.kind} reply failed: ${String(err)}`));
|
||||
runtime.error?.(
|
||||
danger(
|
||||
formatDiscordReplyDeliveryFailure({
|
||||
kind: info.kind,
|
||||
err,
|
||||
target: deliverTarget,
|
||||
sessionKey: ctxPayload.SessionKey,
|
||||
}),
|
||||
),
|
||||
);
|
||||
},
|
||||
onReplyStart: async () => {
|
||||
if (isProcessAborted(abortSignal)) {
|
||||
|
||||
@@ -10,6 +10,9 @@ const sendMessageDiscordMock = vi.hoisted(() => vi.fn());
|
||||
const sendVoiceMessageDiscordMock = vi.hoisted(() => vi.fn());
|
||||
const sendWebhookMessageDiscordMock = vi.hoisted(() => vi.fn());
|
||||
const sendDiscordTextMock = vi.hoisted(() => vi.fn());
|
||||
const buildDiscordSendErrorMock = vi.hoisted(() =>
|
||||
vi.fn<(err: unknown, ctx?: unknown) => Promise<unknown>>(async (err: unknown) => err),
|
||||
);
|
||||
const retryAsyncMock = vi.hoisted(() =>
|
||||
vi.fn(
|
||||
async (
|
||||
@@ -47,6 +50,7 @@ vi.mock("../send.js", async () => {
|
||||
});
|
||||
|
||||
vi.mock("../send.shared.js", () => ({
|
||||
buildDiscordSendError: (err: unknown, ctx: unknown) => buildDiscordSendErrorMock(err, ctx),
|
||||
sendDiscordText: (...args: unknown[]) => sendDiscordTextMock(...args),
|
||||
}));
|
||||
|
||||
@@ -135,6 +139,7 @@ describe("deliverDiscordReply", () => {
|
||||
id: "msg-direct-1",
|
||||
channel_id: "channel-1",
|
||||
});
|
||||
buildDiscordSendErrorMock.mockClear().mockImplementation(async (err: unknown) => err);
|
||||
retryAsyncMock.mockClear();
|
||||
threadBindingTesting.resetThreadBindingsForTests();
|
||||
});
|
||||
@@ -485,6 +490,40 @@ describe("deliverDiscordReply", () => {
|
||||
expect(sendMessageDiscordMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("wraps direct REST permission errors with channel context", async () => {
|
||||
const apiErr = Object.assign(new Error("Missing Permissions"), {
|
||||
code: 50013,
|
||||
status: 403,
|
||||
});
|
||||
const wrappedErr = new Error(
|
||||
"discord missing permissions in channel 789; permission probe did not identify missing ViewChannel/SendMessages (code=50013 status=403)",
|
||||
);
|
||||
sendDiscordTextMock.mockRejectedValueOnce(apiErr);
|
||||
buildDiscordSendErrorMock.mockResolvedValueOnce(wrappedErr);
|
||||
|
||||
const fakeRest = {
|
||||
post: vi.fn(),
|
||||
get: vi.fn(),
|
||||
} as unknown as import("@buape/carbon").RequestClient;
|
||||
|
||||
await expect(
|
||||
deliverDiscordReply({
|
||||
replies: [{ text: "fail" }],
|
||||
target: "channel:789",
|
||||
token: "token",
|
||||
rest: fakeRest,
|
||||
runtime,
|
||||
cfg,
|
||||
textLimit: 2000,
|
||||
}),
|
||||
).rejects.toThrow("discord missing permissions in channel 789");
|
||||
|
||||
expect(buildDiscordSendErrorMock).toHaveBeenCalledWith(
|
||||
apiErr,
|
||||
expect.objectContaining({ channelId: "789", hasMedia: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it("throws after exhausting retry attempts", async () => {
|
||||
const rateLimitErr = Object.assign(new Error("rate limited"), { status: 429 });
|
||||
sendMessageDiscordMock.mockRejectedValue(rateLimitErr);
|
||||
|
||||
@@ -23,7 +23,7 @@ import { chunkDiscordTextWithMode } from "../chunk.js";
|
||||
import { isLikelyDiscordVideoMedia } from "../media-detection.js";
|
||||
import { createDiscordRetryRunner } from "../retry.js";
|
||||
import { sendMessageDiscord, sendVoiceMessageDiscord, sendWebhookMessageDiscord } from "../send.js";
|
||||
import { sendDiscordText } from "../send.shared.js";
|
||||
import { buildDiscordSendError, sendDiscordText } from "../send.shared.js";
|
||||
|
||||
export type DiscordThreadBindingLookupRecord = {
|
||||
accountId: string;
|
||||
@@ -322,21 +322,31 @@ async function sendDiscordChunkWithFallback(params: {
|
||||
// that can cause ordering issues under queue contention or rate limiting.
|
||||
if (params.channelId && params.request && params.rest) {
|
||||
const { channelId, request, rest } = params;
|
||||
await sendWithRetry(
|
||||
() =>
|
||||
sendDiscordText(
|
||||
rest,
|
||||
channelId,
|
||||
text,
|
||||
params.replyTo,
|
||||
request,
|
||||
params.maxLinesPerMessage,
|
||||
undefined,
|
||||
undefined,
|
||||
params.chunkMode,
|
||||
),
|
||||
params.retryConfig,
|
||||
);
|
||||
try {
|
||||
await sendWithRetry(
|
||||
() =>
|
||||
sendDiscordText(
|
||||
rest,
|
||||
channelId,
|
||||
text,
|
||||
params.replyTo,
|
||||
request,
|
||||
params.maxLinesPerMessage,
|
||||
undefined,
|
||||
undefined,
|
||||
params.chunkMode,
|
||||
),
|
||||
params.retryConfig,
|
||||
);
|
||||
} catch (err) {
|
||||
throw await buildDiscordSendError(err, {
|
||||
channelId,
|
||||
cfg: params.cfg,
|
||||
rest,
|
||||
token: params.token,
|
||||
hasMedia: false,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
await sendWithRetry(
|
||||
|
||||
@@ -355,6 +355,41 @@ describe("sendMessageDiscord", () => {
|
||||
expect(String(error)).toMatch(/SendMessages/);
|
||||
});
|
||||
|
||||
it("keeps 50013 context when permission probe finds baseline permissions", async () => {
|
||||
const { rest, postMock, getMock } = makeDiscordRest();
|
||||
const perms = PermissionFlagsBits.ViewChannel | PermissionFlagsBits.SendMessages;
|
||||
const apiError = Object.assign(new Error("Missing Permissions"), {
|
||||
code: 50013,
|
||||
status: 403,
|
||||
});
|
||||
postMock.mockRejectedValueOnce(apiError);
|
||||
getMock
|
||||
.mockResolvedValueOnce({ type: ChannelType.GuildText })
|
||||
.mockResolvedValueOnce({
|
||||
id: "789",
|
||||
guild_id: "guild1",
|
||||
type: 0,
|
||||
permission_overwrites: [],
|
||||
})
|
||||
.mockResolvedValueOnce({ id: "bot1" })
|
||||
.mockResolvedValueOnce({
|
||||
id: "guild1",
|
||||
roles: [{ id: "guild1", permissions: perms.toString() }],
|
||||
})
|
||||
.mockResolvedValueOnce({ roles: [] });
|
||||
|
||||
let error: unknown;
|
||||
try {
|
||||
await sendMessageDiscord("channel:789", "hello", { rest, token: "t", cfg: DISCORD_TEST_CFG });
|
||||
} catch (err) {
|
||||
error = err;
|
||||
}
|
||||
expect(String(error)).toMatch(
|
||||
/permission probe did not identify missing ViewChannel\/SendMessages/,
|
||||
);
|
||||
expect(String(error)).toMatch(/code=50013 status=403/);
|
||||
});
|
||||
|
||||
it("uploads media attachments", async () => {
|
||||
const { rest, postMock } = makeDiscordRest();
|
||||
postMock.mockResolvedValue({ id: "msg", channel_id: "789" });
|
||||
|
||||
@@ -116,6 +116,25 @@ function getDiscordErrorCode(err: unknown) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getDiscordErrorStatus(err: unknown) {
|
||||
if (!err || typeof err !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const candidate =
|
||||
"status" in err && err.status !== undefined
|
||||
? err.status
|
||||
: "statusCode" in err && err.statusCode !== undefined
|
||||
? err.statusCode
|
||||
: undefined;
|
||||
if (typeof candidate === "number" && Number.isFinite(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
if (typeof candidate === "string" && /^\d+$/.test(candidate)) {
|
||||
return Number(candidate);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function buildDiscordSendError(
|
||||
err: unknown,
|
||||
ctx: {
|
||||
@@ -132,8 +151,8 @@ async function buildDiscordSendError(
|
||||
const code = getDiscordErrorCode(err);
|
||||
if (code === DISCORD_CANNOT_DM) {
|
||||
return new DiscordSendError(
|
||||
"discord dm failed: user blocks dms or privacy settings disallow it",
|
||||
{ kind: "dm-blocked" },
|
||||
`discord dm failed: user blocks dms or privacy settings disallow it (code=${code})`,
|
||||
{ kind: "dm-blocked", discordCode: code, status: getDiscordErrorStatus(err) },
|
||||
);
|
||||
}
|
||||
if (code !== DISCORD_MISSING_PERMISSIONS) {
|
||||
@@ -141,15 +160,17 @@ async function buildDiscordSendError(
|
||||
}
|
||||
|
||||
let missing: string[] = [];
|
||||
let probedChannelType: number | undefined;
|
||||
try {
|
||||
const permissions = await fetchChannelPermissionsDiscord(ctx.channelId, {
|
||||
rest: ctx.rest,
|
||||
token: ctx.token,
|
||||
cfg: ctx.cfg,
|
||||
});
|
||||
probedChannelType = permissions.channelType;
|
||||
const current = new Set(permissions.permissions);
|
||||
const required = ["ViewChannel", "SendMessages"];
|
||||
if (isThreadChannelType(permissions.channelType)) {
|
||||
if (isThreadChannelType(probedChannelType)) {
|
||||
required.push("SendMessagesInThreads");
|
||||
}
|
||||
if (ctx.hasMedia) {
|
||||
@@ -160,15 +181,29 @@ async function buildDiscordSendError(
|
||||
/* ignore permission probe errors */
|
||||
}
|
||||
|
||||
const status = getDiscordErrorStatus(err);
|
||||
const apiDetails = [`code=${code}`, status != null ? `status=${status}` : undefined]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const probedPermissions = ["ViewChannel", "SendMessages"];
|
||||
if (isThreadChannelType(probedChannelType)) {
|
||||
probedPermissions.push("SendMessagesInThreads");
|
||||
}
|
||||
if (ctx.hasMedia) {
|
||||
probedPermissions.push("AttachFiles");
|
||||
}
|
||||
const probeSummary = probedPermissions.join("/");
|
||||
const missingLabel = missing.length
|
||||
? `missing permissions in channel ${ctx.channelId}: ${missing.join(", ")}`
|
||||
: `missing permissions in channel ${ctx.channelId}`;
|
||||
? `discord missing permissions in channel ${ctx.channelId}: ${missing.join(", ")}`
|
||||
: `discord missing permissions in channel ${ctx.channelId}; permission probe did not identify missing ${probeSummary}`;
|
||||
return new DiscordSendError(
|
||||
`${missingLabel}. bot might be muted or blocked by role/channel overrides`,
|
||||
`${missingLabel} (${apiDetails}). bot might be blocked by channel/thread overrides, archived thread state, reply target visibility, or app-role position`,
|
||||
{
|
||||
kind: "missing-permissions",
|
||||
channelId: ctx.channelId,
|
||||
missingPermissions: missing,
|
||||
discordCode: code,
|
||||
status,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ export class DiscordSendError extends Error {
|
||||
kind?: "missing-permissions" | "dm-blocked";
|
||||
channelId?: string;
|
||||
missingPermissions?: string[];
|
||||
discordCode?: number;
|
||||
status?: number;
|
||||
|
||||
constructor(message: string, opts?: Partial<DiscordSendError>) {
|
||||
super(message);
|
||||
|
||||
Reference in New Issue
Block a user