fix(commands): /kick and /ban failures are no longer silent (#216)

rateLimitedActions now collects non-429 failures (and a 429 that exhausted its
retries) and returns them instead of swallowing them; existing callers ignore
the return. /kick and /ban turn the list into a CommandError whose message
names who and why, using the server's own sentence (MatrixError.data.error),
never the URL-bearing toString(); RoomInput's toast shows it verbatim.

Verified headless as a non-moderator: '/kick @alice' → "Could not kick
@alice:localhost: You cannot kick user @alice:localhost."; '/ban @nobody
@alice' → "Could not ban @nobody:localhost, @alice:localhost: You don't have
permission to ban".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
2026-09-19 12:27:15 -04:00
co-authored by Claude Opus 5
parent a475531b2b
commit 60076a48d0
3 changed files with 62 additions and 16 deletions
+34 -8
View File
@@ -20,6 +20,7 @@ import {
isServerName,
isUserId,
rateLimitedActions,
RateLimitedFailure,
removeRoomIdFromMDirect,
} from '../utils/matrix';
import { useRoomNavigate } from './useRoomNavigate';
@@ -138,6 +139,28 @@ export const parseTimestampFlag = (input: string): number | undefined => {
export type CommandExe = (payload: string) => Promise<void>;
/**
* [Gitea #216] A command that partially or fully failed. `message` is written
* for the user (the RoomInput toast shows it verbatim).
*/
export class CommandError extends Error {}
const throwCommandFailures = (
verb: 'kick' | 'ban',
failures: RateLimitedFailure<string>[],
): void => {
if (failures.length === 0) return;
const who = failures.map((f) => f.item).join(', ');
// MatrixError.data.error is the server's sentence ("You don't have
// permission to kick"); fall back to the errcode, never the URL-bearing
// toString().
const reason =
(failures[0].error.data as { error?: string } | undefined)?.error ??
failures[0].error.errcode ??
'unknown error';
throw new CommandError(`Could not ${verb} ${who}: ${reason}`);
};
export enum Command {
Me = 'me',
Notice = 'notice',
@@ -292,11 +315,13 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
});
}
// NOTE: rateLimitedActions' to() helper (utils/matrix.ts) swallows every
// non-429 error, so a failed kick (e.g. insufficient power level) resolves
// silently here — the RoomInput exe() catch cannot surface it. Propagating
// would require refactoring rateLimitedActions' shared error handling.
await rateLimitedActions(users, (id) => mx.kick(room.roomId, id, reason));
// [Gitea #216] rateLimitedActions collects per-user failures (it
// used to swallow them); turn them into one CommandError so the
// RoomInput toast can say who and why.
const { failures } = await rateLimitedActions(users, (id) =>
mx.kick(room.roomId, id, reason),
);
throwCommandFailures('kick', failures);
},
},
[Command.Ban]: {
@@ -318,9 +343,10 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
});
}
// See the /kick note: rateLimitedActions swallows non-429 errors, so a
// failed ban resolves silently and can't be surfaced by the exe() catch.
await rateLimitedActions(users, (id) => mx.ban(room.roomId, id, reason));
const { failures } = await rateLimitedActions(users, (id) =>
mx.ban(room.roomId, id, reason),
);
throwCommandFailures('ban', failures);
},
},
[Command.UnBan]: {