Commit e4c64c36f105
tarball11 files changed+529-17
@@ -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"));@@ -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
+ handle535
+ .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
+ handle549
+ .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
+ handle563
+ .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
+ handle580
+ .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
+ handle611
+ .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
+ handle625
+ .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 = reason641
+ .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> {@@ -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. Anything522
+/// 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))@@ -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 request175
+ {174176
*slot = bytes.map(|array| array.to_vec()).unwrap_or_default();175177
}176178
match dispatch(&handle, &mirror, request).await {@@ -22,9 +22,14 @@
2222
openRoom,2323
paginateRoom,2424
reactToMessage,25
+ runCommand,26
+ sendMessage,2527
uploadAttachment,28
+ uploadAvatar,2629
editMessage2730
} 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
+ <input1749
+ 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
<button16381757
type="button"16391758
class="ghost-btn"@@ -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 = false44
-) => transport.sendMessage(room_id, body, formatBody(body, mentions, markdown), reply_to, mentionedUserIds(mentions));43
+ markdown = false,44
+ emote = false45
+) => 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);@@ -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
+}@@ -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 = false99100
): 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: file237
+ });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);@@ -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 = false225226
): 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(@@ -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?: boolean5161
): 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 unsubscribe7386
* function. onDisconnect fires whenever the underlying connection drops;@@ -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
+});