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
+14 -7
View File
@@ -113,7 +113,14 @@ import {
} from './msgContent';
import { getMemberName, getMentionContent, trimReplyFromBody } from '../../utils/room';
import { CommandAutocomplete } from './CommandAutocomplete';
import { Command, SHRUG, TABLEFLIP, UNFLIP, useCommands } from '../../hooks/useCommands';
import {
Command,
CommandError,
SHRUG,
TABLEFLIP,
UNFLIP,
useCommands,
} from '../../hooks/useCommands';
import { mobileOrTablet } from '../../utils/user-agent';
import { useElementSizeObserver } from '../../hooks/useElementSizeObserver';
import { ReplyLayout, ThreadIndicator } from '../../components/message';
@@ -665,16 +672,16 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const commandContent = commands[commandName as Command];
if (commandContent) {
// Fire-and-forget by design (the editor resets immediately for UX), but
// surface a rejection instead of failing silently. NOTE: /kick and /ban
// route through rateLimitedActions (utils/matrix.ts), whose to() helper
// swallows non-429 errors, so those two commands can still resolve even
// when the underlying kick/ban failed — this catch only covers errors
// that actually reject out of exe().
// surface a rejection instead of failing silently. /kick and /ban
// throw a CommandError whose message already names who and why
// (#216); anything else gets the generic sentence.
commandContent.exe(plainText).catch((err) => {
console.error(`Failed to run /${commandName} command:`, err);
setToast(
createErrorToast(
`The /${commandName} command failed. Please try again.`,
err instanceof CommandError
? err.message
: `The /${commandName} command failed. Please try again.`,
Icons.Warning,
'Command failed',
),
+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]: {
+14 -1
View File
@@ -467,15 +467,24 @@ export const declineInvite = async (mx: MatrixClient, roomId: string): Promise<v
await mx.forget(roomId).catch(() => undefined);
};
export type RateLimitedFailure<T> = { item: T; error: MatrixError };
/**
* Run `callback` over `data` sequentially, backing off on 429. Other errors do
* not stop the loop — they are collected and returned so callers can tell the
* user which items failed (a failed /kick used to vanish silently, #216).
*/
export const rateLimitedActions = async <T, R = void>(
data: T[],
callback: (item: T, index: number) => Promise<R>,
maxRetryCount?: number,
) => {
): Promise<{ failures: RateLimitedFailure<T>[] }> => {
let retryCount = 0;
let actionInterval = 0;
const failures: RateLimitedFailure<T>[] = [];
const sleepForMs = (ms: number) =>
new Promise((resolve) => {
setTimeout(resolve, ms);
@@ -486,6 +495,7 @@ export const rateLimitedActions = async <T, R = void>(
if (err?.httpStatus === 429) {
if (retryCount === maxRetryCount) {
failures.push({ item: dataItem, error: err });
return;
}
@@ -496,6 +506,8 @@ export const rateLimitedActions = async <T, R = void>(
retryCount += 1;
await performAction(dataItem, index);
} else if (err) {
failures.push({ item: dataItem, error: err });
}
};
@@ -509,6 +521,7 @@ export const rateLimitedActions = async <T, R = void>(
await sleepForMs(actionInterval);
}
}
return { failures };
};
export const knockSupported = (version: string): boolean => {