atagen

malice

public
Star0
Watch0

Commit e4c64c36f105

tarball

atagen <boss@atagen.co>2026-10-24 07:36 UTC

11 files changed+529-17
web: add slash commands for messaging, moderation and your profile

Commands go through a single /api/command route that accepts the core request JSON for an allowlisted set of ops, so each new operation needs no hand-written handler. Targets resolve by matrix id or the longest matching display name in the room, refusing ambiguous names rather than guessing. Avatar uploads take raw bytes on their own route and the server sniffs the image type instead of trusting the client mimetype.
Modified file: crates/malice-core/src/parse.rs+17 additions−1 deletions
@@ -1,5 +1,5 @@
11 use macltok::MessageMentions;
2-use macltok::ruma::{OwnedEventId, OwnedRoomId, OwnedRoomOrAliasId, OwnedUserId};
2+use macltok::ruma::{OwnedEventId, OwnedMxcUri, OwnedRoomId, OwnedRoomOrAliasId, OwnedUserId};
33
44 use crate::error::CoreError;
55
@@ -27,6 +27,22 @@ pub fn parse_room_or_alias(raw: &str) -> Result<OwnedRoomOrAliasId, CoreError> {
2727 OwnedRoomOrAliasId::try_from(trimmed).map_err(|_| CoreError::bad_request("invalid room"))
2828 }
2929
30+pub fn parse_user_id(raw: &str) -> Result<OwnedUserId, CoreError> {
31+ let trimmed = raw.trim();
32+ if trimmed.is_empty() {
33+ return Err(CoreError::bad_request("user_id is required"));
34+ }
35+ OwnedUserId::try_from(trimmed).map_err(|_| CoreError::bad_request("invalid user_id"))
36+}
37+
38+pub fn parse_mxc(raw: &str) -> Result<OwnedMxcUri, CoreError> {
39+ let mxc = OwnedMxcUri::from(raw.trim());
40+ if !mxc.is_valid() {
41+ return Err(CoreError::bad_request("invalid mxc URI"));
42+ }
43+ Ok(mxc)
44+}
45+
3046 pub fn parse_mentions(raw: &[String]) -> Result<MessageMentions, CoreError> {
3147 if raw.len() > 100 {
3248 return Err(CoreError::bad_request("too many mentions"));
Modified file: crates/malice-core/src/request.rs+176 additions−1 deletions
@@ -10,7 +10,10 @@ use serde_json::json;
1010 use crate::{
1111 error::CoreError,
1212 media::{AVATAR_SIZE, THUMBNAIL_SIZE, attachment_content_type, image_content_type},
13- parse::{parse_event_id, parse_mentions, parse_room_id, parse_room_or_alias},
13+ parse::{
14+ parse_event_id, parse_mentions, parse_mxc, parse_room_id, parse_room_or_alias,
15+ parse_user_id,
16+ },
1417 };
1518
1619 #[derive(Debug, Deserialize)]
@@ -40,6 +43,8 @@ pub enum Request {
4043 reply_to: Option<String>,
4144 #[serde(default)]
4245 mentions: Vec<String>,
46+ #[serde(default)]
47+ emote: bool,
4348 },
4449 Edit {
4550 #[serde(default)]
@@ -141,6 +146,54 @@ pub enum Request {
141146 #[serde(default)]
142147 url: String,
143148 },
149+ Kick {
150+ room_id: String,
151+ user_id: String,
152+ #[serde(default)]
153+ reason: Option<String>,
154+ },
155+ Ban {
156+ room_id: String,
157+ user_id: String,
158+ #[serde(default)]
159+ reason: Option<String>,
160+ },
161+ Unban {
162+ room_id: String,
163+ user_id: String,
164+ #[serde(default)]
165+ reason: Option<String>,
166+ },
167+ SetPowerLevel {
168+ room_id: String,
169+ user_id: String,
170+ level: i64,
171+ },
172+ SetDisplayName {
173+ #[serde(default)]
174+ name: Option<String>,
175+ },
176+ SetAvatar {
177+ #[serde(default)]
178+ mxc: Option<String>,
179+ },
180+ UploadAvatar {
181+ #[serde(skip)]
182+ bytes: Vec<u8>,
183+ },
184+ Invite {
185+ room_id: String,
186+ user_id: String,
187+ },
188+ Direct {
189+ user_id: String,
190+ },
191+ Redact {
192+ room_id: String,
193+ event_id: String,
194+ #[serde(default)]
195+ reason: Option<String>,
196+ },
144197 }
145198
146199 pub enum Reply {
@@ -187,6 +240,7 @@ pub async fn dispatch(
187240 formatted_body,
188241 reply_to,
189242 mentions,
243+ emote,
190244 } => {
191245 let room_id = parse_room_id(&room_id)?;
192246 if body.trim().is_empty() {
@@ -201,6 +255,10 @@ pub async fn dispatch(
201255 };
202256 let mentions = parse_mentions(&mentions)?;
203257 let content = match reply_to {
258+ _ if emote => MessageContent::Emote {
259+ content: text,
260+ mentions,
261+ },
204262 Some(event_id) => MessageContent::reply(parse_event_id(&event_id)?, text, mentions),
205263 None => MessageContent::Text {
206264 content: text,
@@ -468,7 +526,124 @@ pub async fn dispatch(
468526 .map_err(|_| CoreError::bad_request("preview is not valid json"))?;
469527 Ok(Reply::Json(value))
470528 }
529+ Request::Kick {
530+ room_id,
531+ user_id,
532+ reason,
533+ } => {
534+ handle
535+ .kick_user(
536+ parse_room_id(&room_id)?,
537+ parse_user_id(&user_id)?,
538+ reason_text(reason)?,
539+ )
540+ .await?;
541+ Ok(Reply::Done)
542+ }
543+ Request::Ban {
544+ room_id,
545+ user_id,
546+ reason,
547+ } => {
548+ handle
549+ .ban_user(
550+ parse_room_id(&room_id)?,
551+ parse_user_id(&user_id)?,
552+ reason_text(reason)?,
553+ )
554+ .await?;
555+ Ok(Reply::Done)
556+ }
557+ Request::Unban {
558+ room_id,
559+ user_id,
560+ reason,
561+ } => {
562+ handle
563+ .unban_user(
564+ parse_room_id(&room_id)?,
565+ parse_user_id(&user_id)?,
566+ reason_text(reason)?,
567+ )
568+ .await?;
569+ Ok(Reply::Done)
570+ }
571+ Request::SetPowerLevel {
572+ room_id,
573+ user_id,
574+ level,
575+ } => {
576+ if !(-MAX_POWER_LEVEL..=MAX_POWER_LEVEL).contains(&level) {
577+ return Err(CoreError::bad_request("power level is out of range"));
578+ }
579+ handle
580+ .set_user_power_level(parse_room_id(&room_id)?, parse_user_id(&user_id)?, level)
581+ .await?;
582+ Ok(Reply::Done)
583+ }
584+ Request::SetDisplayName { name } => {
585+ let name = name.map(|n| n.trim().to_owned()).filter(|n| !n.is_empty());
586+ if name.as_ref().is_some_and(|n| n.len() > 256) {
587+ return Err(CoreError::bad_request("display name is too long"));
588+ }
589+ handle.set_display_name(name).await?;
590+ Ok(Reply::Done)
591+ }
592+ Request::SetAvatar { mxc } => {
593+ let mxc = mxc.as_deref().map(parse_mxc).transpose()?;
594+ handle.set_avatar(mxc).await?;
595+ Ok(Reply::Done)
596+ }
597+ Request::UploadAvatar { bytes } => {
598+ if bytes.is_empty() || bytes.len() > 10 * 1024 * 1024 {
599+ return Err(CoreError::bad_request(
600+ "avatar must be between 1 byte and 10 MiB",
601+ ));
602+ }
603+ let Some(content_type) = image_content_type(&bytes) else {
604+ return Err(CoreError::unsupported("avatar is not a supported image"));
605+ };
606+ handle.upload_avatar(content_type, bytes).await?;
607+ Ok(Reply::Done)
608+ }
609+ Request::Invite { room_id, user_id } => {
610+ handle
611+ .invite_user(parse_room_id(&room_id)?, parse_user_id(&user_id)?)
612+ .await?;
613+ Ok(Reply::Done)
614+ }
615+ Request::Direct { user_id } => {
616+ let room_id = handle.create_direct(parse_user_id(&user_id)?).await?;
617+ Ok(Reply::Json(json!({ "room_id": room_id })))
618+ }
619+ Request::Redact {
620+ room_id,
621+ event_id,
622+ reason,
623+ } => {
624+ handle
625+ .redact(
626+ TimelineId::Room(parse_room_id(&room_id)?),
627+ parse_event_id(&event_id)?,
628+ reason_text(reason)?,
629+ )
630+ .await?;
631+ Ok(Reply::Done)
632+ }
633+ }
634+}
635+
636+// The spec's power level range is a canonical-JSON integer, which tops out at 2^53 - 1.
637+const MAX_POWER_LEVEL: i64 = (1 << 53) - 1;
638+
639+fn reason_text(reason: Option<String>) -> Result<Option<String>, CoreError> {
640+ let reason = reason
641+ .map(|r| r.trim().to_owned())
642+ .filter(|r| !r.is_empty());
643+ if reason.as_ref().is_some_and(|r| r.len() > 1024) {
644+ return Err(CoreError::bad_request("reason is too long"));
471645 }
646+ Ok(reason)
472647 }
473648
474649 fn parse_media_id(media_id: String) -> Result<MediaId, CoreError> {
Modified file: crates/malice-server/src/routes.rs+58 additions
@@ -45,6 +45,8 @@ struct SendBody {
4545 reply_to: Option<String>,
4646 #[serde(default)]
4747 mentions: Vec<String>,
48+ #[serde(default)]
49+ emote: bool,
4850 }
4951
5052 #[derive(Debug, Deserialize)]
@@ -415,6 +417,7 @@ async fn rooms_send(
415417 formatted_body: body.0.formatted_body,
416418 reply_to: body.0.reply_to,
417419 mentions: body.0.mentions,
420+ emote: body.0.emote,
418421 },
419422 )
420423 .await,
@@ -515,6 +518,56 @@ async fn rooms_upload(
515518 )
516519 }
517520
521+/// Ops added after the per-route handlers, taken as the core request JSON. Anything
522+/// carrying bytes or reading state stays on its own route.
523+async fn post_command(
524+ State(state): State<AppState>,
525+ headers: HeaderMap,
526+ body: axum::Json<Request>,
527+) -> Response {
528+ let (user, _) = match auth(&state, &headers).await {
529+ Ok(pair) => pair,
530+ Err(error) => return error.into_response(),
531+ };
532+ let request = body.0;
533+ if !matches!(
534+ request,
535+ Request::Kick { .. }
536+ | Request::Ban { .. }
537+ | Request::Unban { .. }
538+ | Request::SetPowerLevel { .. }
539+ | Request::SetDisplayName { .. }
540+ | Request::SetAvatar { .. }
541+ | Request::Invite { .. }
542+ | Request::Direct { .. }
543+ | Request::Redact { .. }
544+ ) {
545+ return ApiError::bad_request("op is not available as a command").into_response();
546+ }
547+ respond(dispatch(&user.handle, &user.mirror, request).await)
548+}
549+
550+async fn profile_avatar(
551+ State(state): State<AppState>,
552+ headers: HeaderMap,
553+ bytes: axum::body::Bytes,
554+) -> Response {
555+ let (user, _) = match auth(&state, &headers).await {
556+ Ok(pair) => pair,
557+ Err(error) => return error.into_response(),
558+ };
559+ respond(
560+ dispatch(
561+ &user.handle,
562+ &user.mirror,
563+ Request::UploadAvatar {
564+ bytes: bytes.to_vec(),
565+ },
566+ )
567+ .await,
568+ )
569+}
570+
518571 async fn rooms_paginate(
519572 State(state): State<AppState>,
520573 headers: HeaderMap,
@@ -939,6 +992,11 @@ pub fn router(state: AppState, web_dir: Option<String>) -> Router {
939992 )
940993 .route("/url-preview", get(get_url_preview))
941994 .route("/profile", get(get_profile))
995+ .route(
996+ "/profile/avatar",
997+ post(profile_avatar).layer(DefaultBodyLimit::max(10 * 1024 * 1024)),
998+ )
999+ .route("/command", post(post_command))
9421000 .route("/media", get(get_media))
9431001 .route("/message-media", get(get_message_media))
9441002 .route("/spaces/children", get(get_space_children))
Modified file: crates/malice-worker/src/lib.rs+3 additions−1 deletions
@@ -170,7 +170,9 @@ impl Host {
170170 };
171171 let mut request: Request = serde_json::from_str(&request_json)
172172 .map_err(|error| error_value("bad_request", error.to_string()))?;
173- if let Request::Upload { bytes: slot, .. } = &mut request {
173+ if let Request::Upload { bytes: slot, .. } | Request::UploadAvatar { bytes: slot } =
174+ &mut request
175+ {
174176 *slot = bytes.map(|array| array.to_vec()).unwrap_or_default();
175177 }
176178 match dispatch(&handle, &mirror, request).await {
Modified file: web/src/App.svelte+123 additions−4 deletions
@@ -22,9 +22,14 @@
2222 openRoom,
2323 paginateRoom,
2424 reactToMessage,
25+ runCommand,
26+ sendMessage,
2527 uploadAttachment,
28+ uploadAvatar,
2629 editMessage
2730 } from './lib/api';
31+ import { isKnownCommand, parseCommand, splitTarget, unescapeCommand } from './lib/commands';
32+ import type { ParsedCommand } from './lib/commands';
2833 import {
2934 banners,
3035 blocked,
@@ -87,6 +92,7 @@
8792 let timelineEl: HTMLElement | null = null;
8893 let composerEl: HTMLTextAreaElement | null = null;
8994 let fileInput: HTMLInputElement | null = null;
95+ let avatarInput: HTMLInputElement | null = null;
9096 let uploadBusy = false;
9197 let uploadName: string | null = null;
9298 interface StagedAttachment {
@@ -590,24 +596,129 @@
590596 return;
591597 }
592598 if (attachments.length > 0 && uploadBusy) return;
599+ const command = attachments.length === 0 ? parseCommand(body) : null;
600+ if (command && !isKnownCommand(command.name)) {
601+ pushBanner(`Unknown command /${command.name}`);
602+ return;
603+ }
593604 composer = '';
594605 if (roomId in drafts) delete drafts[roomId];
595606 stickToBottom = true;
596607 const reply = replyingTo;
597608 replyingTo = null;
598- const mentions = resolveMentions(body, pickedMentions[roomId] ?? []);
609+ const text = unescapeCommand(body);
610+ const mentions = resolveMentions(text, pickedMentions[roomId] ?? []);
599611 pickedMentions = { ...pickedMentions, [roomId]: [] };
600612 mentionQuery = null;
601- if (attachments.length === 0) {
602- queueSend(roomId, body, reply, mentions, $markdownEnabled);
613+ if (command) {
614+ void execute(roomId, command, reply, mentions);
615+ } else if (attachments.length === 0) {
616+ queueSend(roomId, text, reply, mentions, $markdownEnabled);
603617 } else {
604618 const { [roomId]: _sent, ...rest } = staged;
605619 staged = rest;
606- void sendAttachments(roomId, body, reply, mentions, attachments);
620+ void sendAttachments(roomId, text, reply, mentions, attachments);
607621 }
608622 composerEl?.focus();
609623 }
610624
625+ function lastOwnEventId(): string | null {
626+ for (let i = items.length - 1; i >= 0; i -= 1) {
627+ const entry = items[i];
628+ if (entry.kind === 'event' && entry.event.own && entry.event.event_id && entry.event.content.kind !== 'Redacted') {
629+ return entry.event.event_id;
630+ }
631+ }
632+ return null;
633+ }
634+
635+ async function execute(roomId: string, command: ParsedCommand, reply: ReplyReference | null, mentions: Mention[]): Promise<void> {
636+ const { name, args } = command;
637+ const target = () => {
638+ const found = splitTarget(args, Object.values(people));
639+ if ('error' in found) throw new Error(found.error);
640+ return found;
641+ };
642+ const required = (what: string) => {
643+ if (!args) throw new Error(`/${name} needs ${what}`);
644+ return args;
645+ };
646+ try {
647+ switch (name) {
648+ case 'me':
649+ await sendMessage(roomId, required('something to say'), null, mentions, $markdownEnabled, true);
650+ break;
651+ case 'msg': {
652+ const { user_id, rest } = target();
653+ const res = (await runCommand({ op: 'direct', user_id })) as { room_id: string };
654+ await refreshRooms();
655+ await selectRoom(res.room_id);
656+ if (rest) queueSend(res.room_id, rest, null, [], $markdownEnabled);
657+ break;
658+ }
659+ case 'join':
660+ await join(required('a room id or alias'));
661+ break;
662+ case 'leave':
663+ await leave();
664+ break;
665+ case 'invite':
666+ await runCommand({ op: 'invite', room_id: roomId, user_id: target().user_id });
667+ break;
668+ case 'disinvite':
669+ case 'kick':
670+ case 'ban':
671+ case 'unban': {
672+ const { user_id, rest } = target();
673+ const op = name === 'disinvite' ? 'kick' : name;
674+ await runCommand({ op, room_id: roomId, user_id, reason: rest || null });
675+ break;
676+ }
677+ case 'powerlevel': {
678+ const { user_id, rest } = target();
679+ const level = Number(rest);
680+ if (!rest || !Number.isInteger(level)) throw new Error('/powerlevel needs a whole number after the name');
681+ await runCommand({ op: 'set_power_level', room_id: roomId, user_id, level });
682+ break;
683+ }
684+ case 'delete': {
685+ const eventId = reply?.event_id ?? lastOwnEventId();
686+ if (!eventId) throw new Error('Nothing of yours to delete here');
687+ await runCommand({ op: 'redact', room_id: roomId, event_id: eventId, reason: args || null });
688+ break;
689+ }
690+ case 'globalnick':
691+ await runCommand({ op: 'set_display_name', name: required('a name') });
692+ await loadProfile();
693+ break;
694+ case 'globalavatar':
695+ if (!args) {
696+ avatarInput?.click();
697+ } else if (args.startsWith('mxc://')) {
698+ await runCommand({ op: 'set_avatar', mxc: args });
699+ await loadProfile();
700+ } else {
701+ throw new Error('/globalavatar takes an mxc:// link, or nothing to pick a file');
702+ }
703+ break;
704+ }
705+ } catch (e) {
706+ pushBanner(e instanceof Error ? e.message : `/${name} failed`);
707+ }
708+ }
709+
710+ async function onAvatarChosen(): Promise<void> {
711+ const file = avatarInput?.files?.[0];
712+ if (avatarInput) avatarInput.value = '';
713+ if (!file) return;
714+ try {
715+ await uploadAvatar(file);
716+ await loadProfile();
717+ } catch (e) {
718+ pushBanner(e instanceof Error ? e.message : 'Avatar upload failed');
719+ }
720+ }
721+
611722 async function sendAttachments(
612723 roomId: string,
613724 body: string,
@@ -1634,6 +1745,14 @@
16341745 aria-label="Attach a file"
16351746 on:change={() => void onFileChosen()}
16361747 />
1748+ <input
1749+ type="file"
1750+ accept="image/*"
1751+ bind:this={avatarInput}
1752+ class="visually-hidden"
1753+ aria-label="Choose a new avatar"
1754+ on:change={() => void onAvatarChosen()}
1755+ />
16371756 <button
16381757 type="button"
16391758 class="ghost-btn"
Modified file: web/src/lib/api.ts+6 additions−3 deletions
@@ -4,7 +4,7 @@ import type { MemberSummary, RoomSummary } from './types';
44 import { formatBody } from './markdown';
55 import { mentionedUserIds, type Mention } from './mentions';
66 import { HttpTransport } from './transport-http';
7-import type { SessionInfo, Transport, VerifyAction } from './transport';
7+import type { CommandRequest, SessionInfo, Transport, VerifyAction } from './transport';
88 import { createDeviceTransport } from 'malice:device-transport';
99
1010 export { ApiError } from './transport';
@@ -40,8 +40,11 @@ export const sendMessage = (
4040 body: string,
4141 reply_to: string | null = null,
4242 mentions: Mention[] = [],
43- markdown = false
44-) => transport.sendMessage(room_id, body, formatBody(body, mentions, markdown), reply_to, mentionedUserIds(mentions));
43+ markdown = false,
44+ emote = false
45+) => transport.sendMessage(room_id, body, formatBody(body, mentions, markdown), reply_to, mentionedUserIds(mentions), emote);
46+export const runCommand = (request: CommandRequest) => transport.command(request);
47+export const uploadAvatar = (file: File) => transport.uploadAvatar(file);
4548 export const paginateRoom = (room_id: string) => transport.paginateRoom(room_id);
4649 export const setTyping = (room_id: string, typing: boolean) => transport.setTyping(room_id, typing);
4750 export const getAccountData = (eventType: string) => transport.getAccountData(eventType);
Added file: web/src/lib/commands.ts+67 additions
@@ -0,0 +1,67 @@
1+import type { Person } from './types';
2+
3+export interface ParsedCommand {
4+ name: string;
5+ args: string;
6+}
7+
8+export const COMMANDS = [
9+ 'me',
10+ 'msg',
11+ 'join',
12+ 'leave',
13+ 'invite',
14+ 'disinvite',
15+ 'kick',
16+ 'ban',
17+ 'unban',
18+ 'powerlevel',
19+ 'delete',
20+ 'globalnick',
21+ 'globalavatar'
22+] as const;
23+export type CommandName = (typeof COMMANDS)[number];
24+
25+/** A leading `/word`, or null for plain text. `//text` sends `/text` as a message. */
26+export function parseCommand(text: string): ParsedCommand | null {
27+ const match = /^\/([a-z]+)(?:\s+([\s\S]*))?$/i.exec(text.trim());
28+ if (!match) return null;
29+ return { name: match[1].toLowerCase(), args: (match[2] ?? '').trim() };
30+}
31+
32+export function isKnownCommand(name: string): name is CommandName {
33+ return (COMMANDS as readonly string[]).includes(name);
34+}
35+
36+export function unescapeCommand(text: string): string {
37+ return text.startsWith('//') ? text.slice(1) : text;
38+}
39+
40+export type Target = { user_id: string; rest: string } | { error: string };
41+
42+/** The user an argument string starts with, by matrix id or by a known display name. */
43+export function splitTarget(args: string, people: Person[]): Target {
44+ if (!args) return { error: 'Name someone' };
45+ if (args.startsWith('@')) {
46+ const [id, ...rest] = args.split(/\s+/);
47+ if (/^@[^:\s]+:\S+$/.test(id)) return { user_id: id, rest: rest.join(' ') };
48+ }
49+ const lower = args.toLowerCase();
50+ let best: Person[] = [];
51+ let bestLength = 0;
52+ for (const person of people) {
53+ const name = person.name.toLowerCase();
54+ if (!name || name.length < bestLength) continue;
55+ if (lower !== name && !lower.startsWith(`${name} `)) continue;
56+ if (name.length > bestLength) {
57+ best = [person];
58+ bestLength = name.length;
59+ } else {
60+ best.push(person);
61+ }
62+ }
63+ const ids = [...new Set(best.map((p) => p.user_id))];
64+ if (ids.length === 0) return { error: `No one here is called ${args.split(/\s+/)[0]}` };
65+ if (ids.length > 1) return { error: `Several people are called ${best[0].name}, use their @id` };
66+ return { user_id: ids[0], rest: args.slice(bestLength).trim() };
67+}
Modified file: web/src/lib/transport-http.ts+24 additions−3 deletions
@@ -1,5 +1,5 @@
11 import type { MemberSummary, RoomSummary } from './types';
2-import { ApiError, type SessionInfo, type Transport, type UploadExtras, type VerifyAction } from './transport';
2+import { ApiError, type CommandRequest, type SessionInfo, type Transport, type UploadExtras, type VerifyAction } from './transport';
33
44 async function parseFailure(res: Response, fallback: string): Promise<never> {
55 let message = fallback;
@@ -95,9 +95,10 @@ export class HttpTransport implements Transport {
9595 body: string,
9696 formatted_body: string | null,
9797 reply_to: string | null,
98- mentions: string[]
98+ mentions: string[],
99+ emote = false
99100 ): Promise<void> {
100- return this.roomCommand(ROOM_COMMAND_PATHS.send, { room_id, body, formatted_body, reply_to, mentions });
101+ return this.roomCommand(ROOM_COMMAND_PATHS.send, { room_id, body, formatted_body, reply_to, mentions, emote });
101102 }
102103 paginateRoom(room_id: string): Promise<void> {
103104 return this.roomCommand(ROOM_COMMAND_PATHS.paginate, { room_id });
@@ -217,6 +218,26 @@ export class HttpTransport implements Transport {
217218 return json(res, 'Verification action failed');
218219 }
219220
221+ async command(request: CommandRequest): Promise<unknown> {
222+ const res = await fetch('/api/command', {
223+ method: 'POST',
224+ credentials: 'same-origin',
225+ headers: { 'content-type': 'application/json' },
226+ body: JSON.stringify(request)
227+ });
228+ return json(res, 'Command failed');
229+ }
230+
231+ async uploadAvatar(file: File): Promise<void> {
232+ const res = await fetch('/api/profile/avatar', {
233+ method: 'POST',
234+ credentials: 'same-origin',
235+ headers: { 'content-type': file.type || 'application/octet-stream' },
236+ body: file
237+ });
238+ return json(res, 'Avatar upload failed');
239+ }
240+
220241 async uploadAttachment(room_id: string, file: File, extras: UploadExtras): Promise<void> {
221242 const form = new FormData();
222243 form.append('room_id', room_id);
Modified file: web/src/lib/transport-worker.ts+12 additions−3 deletions
@@ -1,5 +1,5 @@
11 import type { MemberSummary, RoomSummary } from './types';
2-import { ApiError, type SessionInfo, type SoftLogout, type Transport, type UploadExtras, type VerifyAction } from './transport';
2+import { ApiError, type CommandRequest, type SessionInfo, type SoftLogout, type Transport, type UploadExtras, type VerifyAction } from './transport';
33 import { statusForKind } from './wire-status';
44
55 function toApiError(error: { kind: string; message: string }): ApiError {
@@ -221,9 +221,10 @@ export class WorkerTransport implements Transport {
221221 body: string,
222222 formatted_body: string | null,
223223 reply_to: string | null,
224- mentions: string[]
224+ mentions: string[],
225+ emote = false
225226 ): Promise<void> {
226- return this.client.call('send', { room_id, body, formatted_body, reply_to, mentions });
227+ return this.client.call('send', { room_id, body, formatted_body, reply_to, mentions, emote });
227228 }
228229 paginateRoom(room_id: string): Promise<void> {
229230 return this.client.call('paginate', { room_id });
@@ -280,6 +281,14 @@ export class WorkerTransport implements Transport {
280281 return this.client.call('verify', { action });
281282 }
282283
284+ command({ op, ...fields }: CommandRequest): Promise<unknown> {
285+ return this.client.call(op, fields);
286+ }
287+
288+ async uploadAvatar(file: File): Promise<void> {
289+ await this.client.call('upload_avatar', {}, await file.arrayBuffer());
290+ }
291+
283292 async uploadAttachment(room_id: string, file: File, extras: UploadExtras): Promise<void> {
284293 const bytes = await file.arrayBuffer();
285294 await this.client.call(
Modified file: web/src/lib/transport.ts+14 additions−1 deletions
@@ -21,6 +21,15 @@ export interface SessionInfo {
2121 blocked?: boolean;
2222 }
2323
24+export type CommandRequest =
25+ | { op: 'kick' | 'ban' | 'unban'; room_id: string; user_id: string; reason: string | null }
26+ | { op: 'set_power_level'; room_id: string; user_id: string; level: number }
27+ | { op: 'set_display_name'; name: string | null }
28+ | { op: 'set_avatar'; mxc: string | null }
29+ | { op: 'invite'; room_id: string; user_id: string }
30+ | { op: 'direct'; user_id: string }
31+ | { op: 'redact'; room_id: string; event_id: string; reason: string | null };
32+
2433 export interface UploadExtras {
2534 caption: string | null;
2635 formatted_caption: string | null;
@@ -47,7 +56,8 @@ export interface Transport {
4756 body: string,
4857 formatted_body: string | null,
4958 reply_to: string | null,
50- mentions: string[]
59+ mentions: string[],
60+ emote?: boolean
5161 ): Promise<void>;
5262 paginateRoom(room_id: string): Promise<void>;
5363 setTyping(room_id: string, typing: boolean): Promise<void>;
@@ -68,6 +78,9 @@ export interface Transport {
6878 startVerification(): Promise<void>;
6979 verifyEncryption(action: VerifyAction): Promise<void>;
7080 uploadAttachment(room_id: string, file: File, extras: UploadExtras): Promise<void>;
81+ /** A core request with no bytes, as `{ op, ...fields }`. */
82+ command(request: CommandRequest): Promise<unknown>;
83+ uploadAvatar(file: File): Promise<void>;
7184 /**
7285 * Wire events as raw JSON text, snapshot first. Returns an unsubscribe
7386 * function. onDisconnect fires whenever the underlying connection drops;
Added file: web/test/commands.test.ts+29 additions
@@ -0,0 +1,29 @@
1+import assert from 'node:assert/strict';
2+import { test } from 'node:test';
3+import { isKnownCommand, parseCommand, splitTarget, unescapeCommand } from '../src/lib/commands.ts';
4+
5+const people = [
6+ { user_id: '@alice:example.org', name: 'Alice Liddell', avatar: null },
7+ { user_id: '@al:example.org', name: 'Alice', avatar: null },
8+ { user_id: '@bob:example.org', name: 'Bob', avatar: null },
9+ { user_id: '@bob2:example.org', name: 'Bob', avatar: null }
10+];
11+
12+test('parses a leading command and leaves text alone', () => {
13+ assert.deepEqual(parseCommand('/kick Bob being rude '), { name: 'kick', args: 'Bob being rude' });
14+ assert.deepEqual(parseCommand('/leave'), { name: 'leave', args: '' });
15+ assert.equal(parseCommand('hello /kick'), null);
16+ assert.equal(parseCommand('//kick bob'), null);
17+ assert.equal(unescapeCommand('//kick bob'), '/kick bob');
18+ assert.equal(isKnownCommand('kick'), true);
19+ assert.equal(isKnownCommand('shrug'), false);
20+});
21+
22+test('finds targets by id or the longest display name', () => {
23+ assert.deepEqual(splitTarget('@carol:example.org 50', people), { user_id: '@carol:example.org', rest: '50' });
24+ assert.deepEqual(splitTarget('Alice Liddell spam', people), { user_id: '@alice:example.org', rest: 'spam' });
25+ assert.deepEqual(splitTarget('alice', people), { user_id: '@al:example.org', rest: '' });
26+ assert.ok('error' in splitTarget('Bob', people));
27+ assert.ok('error' in splitTarget('Zed', people));
28+ assert.ok('error' in splitTarget('', people));
29+});