CI / Build & Quality Checks (pull_request) Successful in 1m43s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 7s
CI / Playwright smoke (e2e) (pull_request) Successful in 13m4s
Production Synapse caps delayed events at 24h, but the dialog let any later time be picked and then showed the raw server error. The date input now has min/max (today .. now+24h), a too-late time shows "You can schedule up to 24 hours ahead." as soon as it is picked (and blocks Schedule), and ORG.MATRIX.MSC4140_DELAY_TOO_LARGE maps to the same text. Fixes #275 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
154 lines
5.8 KiB
TypeScript
154 lines
5.8 KiB
TypeScript
import { IContent, MatrixClient, Method } from 'matrix-js-sdk';
|
|
import type { ScheduledMessage } from '../state/scheduledMessages';
|
|
|
|
/**
|
|
* [Gitea #275] Production Synapse caps delayed events at
|
|
* `max_event_delay_duration: 24h`; a later time is rejected with
|
|
* ORG.MATRIX.MSC4140_DELAY_TOO_LARGE. The dialog enforces the same limit and
|
|
* the server's error maps to the same text, in case the limit changes.
|
|
*/
|
|
export const MAX_SCHEDULE_AHEAD_MS = 24 * 60 * 60 * 1000;
|
|
export const SCHEDULE_TOO_FAR_TEXT = 'You can schedule up to 24 hours ahead.';
|
|
|
|
/** A user-facing sentence for a failed schedule. */
|
|
export const describeScheduleError = (err: unknown): string => {
|
|
const errcode = (err as { errcode?: unknown } | null)?.errcode;
|
|
if (errcode === 'ORG.MATRIX.MSC4140_DELAY_TOO_LARGE') return SCHEDULE_TOO_FAR_TEXT;
|
|
return err instanceof Error ? err.message : 'Failed to schedule message.';
|
|
};
|
|
|
|
/**
|
|
* Schedule a message via MSC4140 (delayed messages).
|
|
* Synapse implementation: pass org.matrix.msc4140.delay on the standard
|
|
* /v3/rooms send endpoint. The server stores the event and returns delay_id
|
|
* instead of event_id.
|
|
*/
|
|
export async function scheduleMessage(
|
|
mx: MatrixClient,
|
|
roomId: string,
|
|
content: IContent,
|
|
sendAtMs: number,
|
|
): Promise<string> {
|
|
// MSC4140 delayed events are PUT straight to /send/m.room.message, bypassing
|
|
// the SDK's encryptEventIfNeeded pipeline — the body would land on the server
|
|
// (and later in the timeline) in the clear. Refuse rather than leak; the
|
|
// composer also hides the Schedule button in encrypted rooms.
|
|
if (mx.getRoom?.(roomId)?.hasEncryptionStateEvent()) {
|
|
throw new Error('Scheduled messages are not supported in encrypted rooms.');
|
|
}
|
|
// A past/near target floors at 1000ms (send ~immediately) — an intentional,
|
|
// tested contract; the ScheduleMessageModal already guards ≥60s in the future.
|
|
const delayMs = Math.max(1000, Math.round(sendAtMs - Date.now()));
|
|
const txnId = `sched_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
|
const path = `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`;
|
|
// Use standard v3 prefix with the MSC4140 delay query param.
|
|
const res = (await mx.http.authedRequest(
|
|
Method.Put,
|
|
path,
|
|
{
|
|
'org.matrix.msc4140.delay': delayMs,
|
|
},
|
|
content,
|
|
)) as { delay_id: string };
|
|
return res.delay_id;
|
|
}
|
|
|
|
/**
|
|
* Cancel a scheduled message via MSC4140.
|
|
*/
|
|
export async function cancelScheduledMessage(mx: MatrixClient, delayId: string): Promise<void> {
|
|
const path = `/delayed_events/${encodeURIComponent(delayId)}`;
|
|
await mx.http.authedRequest(
|
|
Method.Post,
|
|
path,
|
|
undefined,
|
|
{ action: 'cancel' },
|
|
{ prefix: '/_matrix/client/unstable/org.matrix.msc4140' },
|
|
);
|
|
}
|
|
|
|
export async function restartScheduledMessage(mx: MatrixClient, delayId: string): Promise<void> {
|
|
const path = `/delayed_events/${encodeURIComponent(delayId)}`;
|
|
await mx.http.authedRequest(
|
|
Method.Post,
|
|
path,
|
|
undefined,
|
|
{ action: 'restart' },
|
|
{ prefix: '/_matrix/client/unstable/org.matrix.msc4140' },
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Send a scheduled message immediately via MSC4140 (`action: 'send'`) — the
|
|
* server dispatches the stored delayed event now, as a normal timeline event,
|
|
* instead of waiting for its timeout.
|
|
*/
|
|
export async function sendScheduledMessageNow(mx: MatrixClient, delayId: string): Promise<void> {
|
|
const path = `/delayed_events/${encodeURIComponent(delayId)}`;
|
|
await mx.http.authedRequest(
|
|
Method.Post,
|
|
path,
|
|
undefined,
|
|
{ action: 'send' },
|
|
{ prefix: '/_matrix/client/unstable/org.matrix.msc4140' },
|
|
);
|
|
}
|
|
|
|
type DelayedEventItem = {
|
|
delay_id?: unknown;
|
|
room_id?: unknown;
|
|
type?: unknown;
|
|
state_key?: unknown;
|
|
content?: unknown;
|
|
delay?: unknown;
|
|
delay_ms?: unknown;
|
|
running_since?: unknown;
|
|
delayed_since_ts?: unknown;
|
|
};
|
|
|
|
/**
|
|
* [Gitea #322] Pure: the user's pending scheduled messages from an MSC4140
|
|
* `GET /delayed_events` response. Synapse answers `{ delayed_events: [...] }`;
|
|
* the current MSC shape is `{ scheduled: [...] }`. Call-membership delayed
|
|
* events share the endpoint, so only non-state `m.room.message`s are kept.
|
|
*/
|
|
export function parseDelayedEvents(res: unknown): ScheduledMessage[] {
|
|
if (!res || typeof res !== 'object') return [];
|
|
const r = res as { delayed_events?: unknown; scheduled?: unknown };
|
|
const list = Array.isArray(r.delayed_events) ? r.delayed_events : r.scheduled;
|
|
if (!Array.isArray(list)) return [];
|
|
const out: ScheduledMessage[] = [];
|
|
list.forEach((raw: DelayedEventItem) => {
|
|
if (!raw || typeof raw !== 'object') return;
|
|
if (raw.type !== 'm.room.message' || raw.state_key !== undefined) return;
|
|
if (typeof raw.delay_id !== 'string' || typeof raw.room_id !== 'string') return;
|
|
if (!raw.content || typeof raw.content !== 'object') return;
|
|
const delay = Number(raw.delay ?? raw.delay_ms);
|
|
const since = Number(raw.running_since ?? raw.delayed_since_ts);
|
|
if (!Number.isFinite(delay) || !Number.isFinite(since)) return;
|
|
out.push({
|
|
delayId: raw.delay_id,
|
|
roomId: raw.room_id,
|
|
content: raw.content as IContent,
|
|
sendAt: since + delay,
|
|
});
|
|
});
|
|
return out;
|
|
}
|
|
|
|
/** The user's pending scheduled messages, as the server has them. */
|
|
export async function fetchScheduledMessages(mx: MatrixClient): Promise<ScheduledMessage[]> {
|
|
const res = await mx.http.authedRequest(Method.Get, '/delayed_events', undefined, undefined, {
|
|
prefix: '/_matrix/client/unstable/org.matrix.msc4140',
|
|
});
|
|
return parseDelayedEvents(res);
|
|
}
|
|
|
|
/** Group by room, the shape of `scheduledMessagesAtom`. */
|
|
export function groupScheduledByRoom(msgs: ScheduledMessage[]): Map<string, ScheduledMessage[]> {
|
|
const map = new Map<string, ScheduledMessage[]>();
|
|
msgs.forEach((m) => map.set(m.roomId, [...(map.get(m.roomId) ?? []), m]));
|
|
map.forEach((list) => list.sort((a, b) => a.sendAt - b.sendAt));
|
|
return map;
|
|
}
|