@homie-rocks/studio 0.28.0 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -1
- package/README.md +30 -1
- package/bin/homie-studio.mjs +24 -0
- package/chat/CHAT.md +5 -1
- package/chat/LOUNGE.md +114 -0
- package/chat/OWNERS.md +6 -2
- package/lib/build.mjs +5 -0
- package/lib/lounge-cli.mjs +70 -0
- package/lib/mcp-tools.mjs +6 -0
- package/lib/scaffold.mjs +13 -2
- package/lib/template-history.json +40 -0
- package/mcp/ui/setup.js +2 -0
- package/netplay/NETPLAY.md +11 -1
- package/package.json +1 -1
- package/site/SITE.md +13 -0
- package/worker/chat-store.mjs +2 -1
- package/worker/chat.mjs +13 -1
- package/worker/discover.mjs +5 -0
- package/worker/index.mjs +38 -3
- package/worker/lounge-page.mjs +623 -0
- package/worker/lounge-store.mjs +281 -0
- package/worker/lounge.mjs +547 -0
- package/worker/office-page.mjs +99 -0
- package/worker/office.mjs +19 -1
- package/worker/players.mjs +5 -0
- package/worker/room.mjs +116 -12
- package/worker/site.mjs +6 -1
- package/worker/version.mjs +1 -1
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE LOUNGE (@homie-rocks/studio 0.29.0, chat/LOUNGE.md): a studio's own community room at /lounge/, when studio.json
|
|
3
|
+
* turns it on. Play nights, demos, "show what you made", and talking with the people who play the studio's games. It
|
|
4
|
+
* is room chat (NETPLAY.md section 19) in a room of its own: the same relay, the same floor and review on the studio's
|
|
5
|
+
* own Workers AI, the same owner's tools, plus what a lasting room needs:
|
|
6
|
+
*
|
|
7
|
+
* history the owner may keep what was said for a number of days (off until they do; never on a kids lounge),
|
|
8
|
+
* in the studio's own D1 (worker/lounge-store.mjs); anyone takes their own line down, the owner and the
|
|
9
|
+
* moderators take anyone's, deleting an account takes every line of it
|
|
10
|
+
* moderators player accounts the owner names: Remove, Mute, Kick and slow mode from the Lounge page itself
|
|
11
|
+
* play nights set by the owner in the office (or `homie-studio lounge night`), shown with the visitor's own time
|
|
12
|
+
* featured live rooms: this studio's, and with a directory the whole directory's, each with Watch and Join
|
|
13
|
+
* show "show what you made": a link to a Homie studio's game becomes a card (read from that studio's own
|
|
14
|
+
* manifest, its words through the floor and the review like any line); links are never typed
|
|
15
|
+
*
|
|
16
|
+
* Who says what is the Worker's word: a page of this site carries its signed-in account (a passkey) and the owner's mark;
|
|
17
|
+
* a page of another site (homie.rocks shows the Lounge live) is a watcher with a handle that can react and send quick
|
|
18
|
+
* lines when the rules let it, never type, and never speaks as anyone signed in here. Kids: a lounge with `kids` keeps
|
|
19
|
+
* chat to emoji and quick lines and keeps nothing.
|
|
20
|
+
*/
|
|
21
|
+
import { CHAT_LIMITS, REACTIONS, checkChatRules, cleanText, composeChat, publicChat } from './chat.mjs';
|
|
22
|
+
import { REPORT_REASONS, chatRowsOf, clearChatRules, fileReport, reportsOf, writeChatRules } from './chat-store.mjs';
|
|
23
|
+
import {
|
|
24
|
+
LOUNGE_CHAT, LOUNGE_GAME, LOUNGE_LIMITS, LOUNGE_ROOM, addMod, addNight, checkNight, historyOf, isMod, keptLine, modsOf, nightsOf,
|
|
25
|
+
removeMod, removeNight, trimHistory,
|
|
26
|
+
} from './lounge-store.mjs';
|
|
27
|
+
import { encodeFacts } from './agents.mjs';
|
|
28
|
+
import { PUBLIC_SERVER, policyOf } from './servers.mjs';
|
|
29
|
+
import { players } from './players.mjs';
|
|
30
|
+
import { signControl } from './office.mjs';
|
|
31
|
+
import { LOUNGE_JS, loungePage } from './lounge-page.mjs';
|
|
32
|
+
import { STUDIO_VERSION_TAG } from './version.mjs';
|
|
33
|
+
|
|
34
|
+
const json = (body, status = 200, extra = {}) => new Response(`${JSON.stringify(body)}\n`, {
|
|
35
|
+
status, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', ...extra },
|
|
36
|
+
});
|
|
37
|
+
const BROWSER_KEY = /^[A-Za-z0-9_-]{16,43}$/;
|
|
38
|
+
const LINE_ID = /^[A-Za-z0-9_-]{1,16}$/;
|
|
39
|
+
const GAME_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
40
|
+
const NOBODY = Object.freeze({ player: null, acct: false, owner: false, mod: false, name: null });
|
|
41
|
+
const oneLine = (v, max) => [...String(v ?? '').normalize('NFKC').replace(/[\p{Cc}\p{Cf}\p{Co}\p{Cs}\u2028\u2029]+/gu, ' ').replace(/\s+/g, ' ').trim()].slice(0, max).join('').trim();
|
|
42
|
+
|
|
43
|
+
export const isLoungePath = (p) => p === '/lounge' || p.startsWith('/lounge/') || p === '/_homie/lounge.js';
|
|
44
|
+
|
|
45
|
+
/** The studio's Lounge as the build kept it (studio.json "lounge"), or null when it has none. */
|
|
46
|
+
export const loungeOf = (cat) => (cat?.studio?.lounge && typeof cat.studio.lounge === 'object' ? cat.studio.lounge : null);
|
|
47
|
+
|
|
48
|
+
/** The Lounge's chat rules: its defaults, studio.json's `lounge.chat`, then the owner's (the office), kids capped. */
|
|
49
|
+
export async function loungeRules(env, cat, { fresh = false } = {}) {
|
|
50
|
+
const L = loungeOf(cat);
|
|
51
|
+
const rows = await chatRowsOf(env, { fresh });
|
|
52
|
+
return composeChat({ game: { ...LOUNGE_CHAT, ...(L?.chat ?? {}) }, office: rows.get(`${LOUNGE_GAME}/`) ?? null, kids: Boolean(L?.kids), speech: 'game' });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Where the Lounge's rules came from (the office's "from"). */
|
|
56
|
+
async function rulesFrom(env) {
|
|
57
|
+
const rows = await chatRowsOf(env, { fresh: true });
|
|
58
|
+
return rows.get(`${LOUNGE_GAME}/`) ? 'office' : 'default';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The Lounge's room policy: humans only (an AI never types in chat), kids when the Lounge is, the chat rules beside. */
|
|
62
|
+
function loungePolicy(L, chat) {
|
|
63
|
+
const kids = Boolean(L?.kids);
|
|
64
|
+
const srv = { ...PUBLIC_SERVER, id: 'lounge', name: L?.name ?? 'The Lounge', policy: 'humans-only', aiSeats: 0, guides: 0, bots: 'off', kids, speech: kids ? 'lines' : 'game' };
|
|
65
|
+
return policyOf(srv, { seats: 2, named: true, chat });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const tableOf = (env) => env.TABLE.get(env.TABLE.idFromName(`${LOUNGE_GAME}/${LOUNGE_ROOM}`));
|
|
69
|
+
const tableUrl = (path, extra = '') => `https://table/${path}?game=${LOUNGE_GAME}&room=${LOUNGE_ROOM}&max=2${extra}`;
|
|
70
|
+
|
|
71
|
+
/** One signed control for the Lounge's room (NETPLAY.md section 15): what the room says back. */
|
|
72
|
+
export async function loungeControl(env, op, args = {}) {
|
|
73
|
+
const ctl = await signControl(env, { op, game: LOUNGE_GAME, room: LOUNGE_ROOM, args });
|
|
74
|
+
if (!ctl) return { ok: false, error: 'no-key', message: 'This studio has no database for its office yet: `npm run deploy` applies its migrations.' };
|
|
75
|
+
try {
|
|
76
|
+
const res = await tableOf(env).fetch(new Request(tableUrl('__office'), { method: 'POST', body: JSON.stringify(ctl) }));
|
|
77
|
+
return await res.json();
|
|
78
|
+
} catch (error) { return { ok: false, error: 'room', message: String(error?.message ?? error).slice(0, 120) }; }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The Lounge's room as its owner sees it: who is here, the last lines with who sent them, mutes and holds. */
|
|
82
|
+
async function loungeFacts(env) {
|
|
83
|
+
try { const res = await tableOf(env).fetch(tableUrl('__facts')); return res.ok ? await res.json() : null; } catch { return null; }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Every live room of the Lounge after a rules change hears its new rules now (a signed `policy` control). */
|
|
87
|
+
async function pushRules(env, cat) {
|
|
88
|
+
const L = loungeOf(cat);
|
|
89
|
+
const rules = await loungeRules(env, cat, { fresh: true });
|
|
90
|
+
await loungeControl(env, 'policy', { pol: loungePolicy(L, rules) });
|
|
91
|
+
return rules;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Who this request is to the Lounge: a signed-in account (a passkey, never a guest), the studio's owner, a moderator
|
|
96
|
+
* the owner named, and the name they chose. Only a page of this site: another site's page is nobody.
|
|
97
|
+
*/
|
|
98
|
+
async function whoOf(request, env) {
|
|
99
|
+
let p = null;
|
|
100
|
+
try { p = await players.of(request, env); } catch { p = null; }
|
|
101
|
+
if (!p || p.guest) return NOBODY;
|
|
102
|
+
const owner = Boolean(p.owner);
|
|
103
|
+
return { player: p.id, acct: true, owner, mod: !owner && await isMod(env, p.id), name: oneLine(p.name, CHAT_LIMITS.name) || null };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function sameOrigin(request, url) {
|
|
107
|
+
const o = request.headers.get('origin');
|
|
108
|
+
return Boolean(o) && o === url.origin;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function readJson(request, max = 2048) {
|
|
112
|
+
const text = await request.text();
|
|
113
|
+
if (text.length > max) return { error: 'too-large' };
|
|
114
|
+
try { const b = JSON.parse(text || '{}'); return b && typeof b === 'object' && !Array.isArray(b) ? { body: b } : { error: 'json' }; } catch { return { error: 'json' }; }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/* ------------------------------------------------------------------ what the page and homie.rocks read */
|
|
118
|
+
|
|
119
|
+
/** A play night as everyone sees it: its game named and linked when it is one of this studio's (or a link to one). */
|
|
120
|
+
function nightView(n, cat, origin) {
|
|
121
|
+
let game = null;
|
|
122
|
+
if (n.game && GAME_ID.test(n.game)) {
|
|
123
|
+
const g = (cat.games ?? []).find((x) => x.id === n.game);
|
|
124
|
+
if (g) game = { name: g.name, page: `${origin}/${g.id}/`, play: `${origin}/${g.id}/play` };
|
|
125
|
+
} else if (n.game && /^https:\/\//.test(n.game)) {
|
|
126
|
+
game = { name: null, page: n.game, play: n.game };
|
|
127
|
+
}
|
|
128
|
+
return { id: n.id, title: n.title, at: new Date(n.at).toISOString(), minutes: n.minutes, note: n.note ?? null, on: Boolean(n.on), game };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const hubCache = new Map();
|
|
132
|
+
/** The directory's live rooms (its /api/hub), read at most every 45 s by this Worker instance, 3 s at most. */
|
|
133
|
+
async function directoryRooms(directory, fetchFn = fetch) {
|
|
134
|
+
const hit = hubCache.get(directory);
|
|
135
|
+
if (hit && Date.now() - hit.at < 45_000) return hit.rooms;
|
|
136
|
+
let rooms = [];
|
|
137
|
+
try {
|
|
138
|
+
const ctl = new AbortController();
|
|
139
|
+
const t = setTimeout(() => ctl.abort(), 3000);
|
|
140
|
+
const res = await fetchFn(`${directory}/api/hub`, { signal: ctl.signal, headers: { accept: 'application/json' } });
|
|
141
|
+
clearTimeout(t);
|
|
142
|
+
const body = res.ok ? await res.json() : null;
|
|
143
|
+
const https = (u) => (typeof u === 'string' && /^https:\/\/[^\s]{4,400}$/.test(u) ? u : null);
|
|
144
|
+
rooms = (Array.isArray(body?.rooms) ? body.rooms : []).slice(0, 50).map((r) => ({
|
|
145
|
+
title: oneLine(r.title, 80) || 'A room', studio: oneLine(r.studio, 60) || null, site: https(r.site), players: Math.max(0, Math.floor(Number(r.players) || 0)),
|
|
146
|
+
max: null, watch: https(r.watch), join: https(r.href), picture: https(r.picture),
|
|
147
|
+
})).filter((r) => r.join && r.players > 0);
|
|
148
|
+
} catch { rooms = []; }
|
|
149
|
+
hubCache.set(directory, { at: Date.now(), rooms });
|
|
150
|
+
return rooms;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The live rooms the Lounge shows (Watch and Join): this studio's own, and with `featured: "directory"` (the default)
|
|
155
|
+
* the directory's busiest too. Never more than eight.
|
|
156
|
+
*/
|
|
157
|
+
export async function featuredRooms(env, cat, origin, { roomsOf = null, fetchFn = fetch } = {}) {
|
|
158
|
+
const L = loungeOf(cat);
|
|
159
|
+
if (!L || L.featured === 'none') return [];
|
|
160
|
+
const abs = (u) => (u && u.startsWith('/') ? `${origin}${u}` : u);
|
|
161
|
+
let own = [];
|
|
162
|
+
try {
|
|
163
|
+
own = roomsOf ? (await roomsOf(env, cat.games ?? [])).rooms.map((r) => ({
|
|
164
|
+
title: `${r.name} · ${r.label}`, studio: cat.studio?.name ?? null, site: origin, players: r.players, max: r.max,
|
|
165
|
+
watch: r.watch ? abs(r.watch) : null, join: abs(r.play), picture: r.cover ? abs(r.cover) : null, here: true,
|
|
166
|
+
})) : [];
|
|
167
|
+
} catch { own = []; }
|
|
168
|
+
if (L.featured === 'studio' || !cat.studio?.directory || env.HOMIE_PREVIEW === '1') return own.slice(0, 8);
|
|
169
|
+
const host = new URL(origin).host;
|
|
170
|
+
const others = (await directoryRooms(cat.studio.directory, fetchFn)).filter((r) => { try { return !r.site || new URL(r.site).host !== host; } catch { return true; } });
|
|
171
|
+
return [...own, ...others].sort((a, b) => b.players - a.players).slice(0, 8);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** What /lounge/api/now says (the page's sidebar and homie.rocks's Lounge page): the rules, the nights, the rooms. */
|
|
175
|
+
export async function loungeNow(env, cat, origin, opts = {}) {
|
|
176
|
+
const L = loungeOf(cat);
|
|
177
|
+
const [rules, nights, rooms, facts] = await Promise.all([loungeRules(env, cat), nightsOf(env), featuredRooms(env, cat, origin, opts), loungeFacts(env)]);
|
|
178
|
+
const pub = publicChat(rules);
|
|
179
|
+
const ws = origin.replace(/^http/, 'ws');
|
|
180
|
+
return {
|
|
181
|
+
ok: true,
|
|
182
|
+
lounge: {
|
|
183
|
+
name: L.name, blurb: L.blurb, studio: cat.studio?.name ?? null, page: `${origin}/lounge/`, socket: `${ws}/lounge/__watch`,
|
|
184
|
+
here: Math.max(0, Number(facts?.counts?.shells ?? 0) || 0),
|
|
185
|
+
rules: { mode: pub.mode, who: pub.who, react: pub.react, hub: pub.hub, slow: pub.slow, history: pub.history ?? 0, kids: Boolean(L.kids) },
|
|
186
|
+
},
|
|
187
|
+
nights: nights.map((n) => nightView(n, cat, origin)),
|
|
188
|
+
rooms,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/* ------------------------------------------------------------------ show what you made */
|
|
193
|
+
|
|
194
|
+
const showsMade = new Map();
|
|
195
|
+
/** Cards one account may post: three an hour (this Worker's memory). A card that was not posted does not count. */
|
|
196
|
+
function showAllowed(player, now = Date.now()) {
|
|
197
|
+
const list = (showsMade.get(player) ?? []).filter((at) => now - at < 3600_000);
|
|
198
|
+
showsMade.set(player, list);
|
|
199
|
+
if (showsMade.size > 5000) for (const [k, v] of showsMade) if (!v.some((at) => now - at < 3600_000)) showsMade.delete(k);
|
|
200
|
+
return list.length < LOUNGE_LIMITS.show;
|
|
201
|
+
}
|
|
202
|
+
const showPosted = (player, now = Date.now()) => { showsMade.set(player, [...(showsMade.get(player) ?? []), now]); };
|
|
203
|
+
const showsTried = new Map();
|
|
204
|
+
/** Links one account may ask about (each reads another site's manifest): ten in ten minutes, posted or not. */
|
|
205
|
+
function showTry(player, now = Date.now()) {
|
|
206
|
+
const list = (showsTried.get(player) ?? []).filter((at) => now - at < 10 * 60_000);
|
|
207
|
+
if (list.length >= 10) { showsTried.set(player, list); return false; }
|
|
208
|
+
list.push(now);
|
|
209
|
+
showsTried.set(player, list);
|
|
210
|
+
if (showsTried.size > 5000) for (const [k, v] of showsTried) if (!v.some((at) => now - at < 10 * 60_000)) showsTried.delete(k);
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* A link to a game becomes a card, read from where it lives: this studio's own game from its catalogue; another studio's
|
|
216
|
+
* from that site's Homie manifest (/.well-known/homie-studio.json), so only a Homie studio's game can be a card and
|
|
217
|
+
* its title, pitch and picture are the studio's own. `{ ok, card }` or `{ ok: false, message }`.
|
|
218
|
+
*/
|
|
219
|
+
export async function cardFor(link, { cat, origin, fetchFn = fetch, local = false } = {}) {
|
|
220
|
+
const bad = (message) => ({ ok: false, error: 'card', message });
|
|
221
|
+
let u;
|
|
222
|
+
try { u = new URL(String(link ?? '').trim()); } catch { return bad('Paste the link to your game (https://…).'); }
|
|
223
|
+
const localHost = /^(localhost|127\.0\.0\.1)$/.test(u.hostname);
|
|
224
|
+
if (u.protocol !== 'https:' && !(local && localHost && u.protocol === 'http:')) return bad('That needs to be an https link to a game.');
|
|
225
|
+
if (u.username || u.password || String(link).length > 300) return bad('That link is not one a card can show.');
|
|
226
|
+
const id = u.pathname.split('/').filter(Boolean)[0] ?? '';
|
|
227
|
+
if (!GAME_ID.test(id)) return bad('Link to the game itself: its page (https://your-studio/<game>/) or its Play.');
|
|
228
|
+
if (u.origin === origin) {
|
|
229
|
+
const g = (cat.games ?? []).find((x) => x.id === id);
|
|
230
|
+
if (!g) return bad('This studio has no game at that link.');
|
|
231
|
+
const cover = g.landing?.hero?.wideImage ?? g.landing?.hero?.tallImage ?? g.landing?.cover ?? (g.cover ? `/games/${g.id}/${g.cover}` : null);
|
|
232
|
+
return { ok: true, card: { url: `${origin}/${g.id}/`, title: oneLine(g.name, 80), studio: oneLine(cat.studio?.name, 60), pitch: oneLine(g.blurb, 160), image: cover ? (cover.startsWith('/') ? `${origin}${cover}` : cover) : null, game: g.id } };
|
|
233
|
+
}
|
|
234
|
+
if (localHost && !local) return bad('That link is on somebody\'s own computer: share the game\'s live link.');
|
|
235
|
+
let manifest = null;
|
|
236
|
+
try {
|
|
237
|
+
const ctl = new AbortController();
|
|
238
|
+
const t = setTimeout(() => ctl.abort(), 4000);
|
|
239
|
+
// Workers know `follow` and `manual` (no `error`): a redirect is not the studio's own manifest, so it is refused.
|
|
240
|
+
const res = await fetchFn(`${u.origin}/.well-known/homie-studio.json`, { signal: ctl.signal, headers: { accept: 'application/json' }, redirect: 'manual' });
|
|
241
|
+
clearTimeout(t);
|
|
242
|
+
const text = res.status === 200 ? await res.text() : '';
|
|
243
|
+
manifest = text && text.length < 512 * 1024 ? JSON.parse(text) : null;
|
|
244
|
+
} catch { manifest = null; }
|
|
245
|
+
if (!manifest || manifest.kind !== 'homie-studio' || !Array.isArray(manifest.games)) return bad('That site is not a Homie studio (it has no studio manifest), so it can\'t be a card.');
|
|
246
|
+
const g = manifest.games.find((x) => x && x.id === id);
|
|
247
|
+
if (!g) return bad(`${oneLine(manifest.name, 60) || 'That studio'} has no public game at that link.`);
|
|
248
|
+
const https = (v) => (typeof v === 'string' && /^https:\/\/[^\s]{4,400}$/.test(v) ? v : null);
|
|
249
|
+
const page = https(g.page) && new URL(g.page).origin === u.origin ? g.page : `${u.origin}/${id}/`;
|
|
250
|
+
return { ok: true, card: { url: page, title: oneLine(g.name, 80) || id, studio: oneLine(manifest.name, 60), pitch: oneLine(g.blurb, 160), image: https(g.cover), game: id } };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/* ------------------------------------------------------------------ the routes */
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* /lounge/ and everything under it, and /_homie/lounge.js. Null when the studio has no Lounge (a game, a page of the
|
|
257
|
+
* studio's own or a 404 answers instead).
|
|
258
|
+
*/
|
|
259
|
+
export async function loungeRoutes(request, env, ctx, url, { catalogueOf, roomsOf = null }) {
|
|
260
|
+
const path = url.pathname;
|
|
261
|
+
const read = request.method === 'GET' || request.method === 'HEAD';
|
|
262
|
+
const cat = await catalogueOf();
|
|
263
|
+
const L = loungeOf(cat);
|
|
264
|
+
if (!L) return null;
|
|
265
|
+
if (path === '/_homie/lounge.js') return new Response(LOUNGE_JS, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': url.searchParams.get('v') === STUDIO_VERSION_TAG ? 'public, max-age=31536000, immutable' : 'public, max-age=300', 'x-content-type-options': 'nosniff' } });
|
|
266
|
+
if (path === '/lounge' && read) return Response.redirect(`${url.origin}/lounge/${url.search}`, 301);
|
|
267
|
+
if (!env.TABLE || !env.DB) return path.startsWith('/lounge/api/') ? json({ ok: false, error: 'no-rooms', message: 'This studio has no rooms or database bound yet: `npm run deploy` sets them up.' }, 503) : null;
|
|
268
|
+
const local = /^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(url.origin);
|
|
269
|
+
|
|
270
|
+
if (path === '/lounge/' && read) {
|
|
271
|
+
const [who, rules, nights] = await Promise.all([whoOf(request, env), loungeRules(env, cat), nightsOf(env)]);
|
|
272
|
+
return loungePage(cat, { origin: url.origin, who, rules: publicChat(rules), nights: nights.map((n) => nightView(n, cat, url.origin)) });
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (path === '/lounge/__watch') {
|
|
276
|
+
if (request.headers.get('upgrade') !== 'websocket') return new Response('websocket only', { status: 426 });
|
|
277
|
+
const origin = request.headers.get('origin');
|
|
278
|
+
const hub = Boolean(origin) && origin !== 'null' && origin !== url.origin;
|
|
279
|
+
const rules = await loungeRules(env, cat);
|
|
280
|
+
if (hub && !rules.hub) return new Response('this lounge is shown only on its own site', { status: 403 });
|
|
281
|
+
// A page of another site never speaks as anyone signed in here (its socket carries the visitor's cookies too).
|
|
282
|
+
const who = hub ? NOBODY : await whoOf(request, env);
|
|
283
|
+
const b = BROWSER_KEY.test(url.searchParams.get('b') ?? '') ? url.searchParams.get('b') : '';
|
|
284
|
+
const via = who.player ? (who.owner ? `o~p-${who.player}` : `p-${who.player}`) : null;
|
|
285
|
+
const lean = { ...rules, emoji: rules.emoji.filter((e) => !REACTIONS.some((r) => r.k === e.k)) };
|
|
286
|
+
const target = tableUrl('__watch', `${b ? `&b=${b}` : ''}${via ? `&via=${encodeURIComponent(via)}` : ''}&pol=${encodeFacts(loungePolicy(L, rules))}&chat=${encodeFacts(lean)}${who.acct ? '&acct=1&mem=1' : ''}${hub ? '&hub=1' : ''}${who.name ? `&nm=${encodeURIComponent(who.name)}` : ''}${who.mod ? '&mod=1' : ''}`);
|
|
287
|
+
return tableOf(env).fetch(new Request(target, request));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (path === '/lounge/api/now' && read) {
|
|
291
|
+
return json(await loungeNow(env, cat, url.origin, { roomsOf }), 200, { 'access-control-allow-origin': '*', 'cache-control': 'public, max-age=30' });
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (path === '/lounge/api/history' && read) {
|
|
295
|
+
const rules = await loungeRules(env, cat);
|
|
296
|
+
if (!(rules.history > 0)) return json({ ok: true, history: 0, lines: [] });
|
|
297
|
+
const before = Number(url.searchParams.get('before'));
|
|
298
|
+
const [lines, who] = await Promise.all([historyOf(env, LOUNGE_GAME, LOUNGE_ROOM, { days: rules.history, before: Number.isFinite(before) && before > 0 ? before : null }), whoOf(request, env)]);
|
|
299
|
+
// A signed-in person's own kept lines say so (they may take them down); nobody else's account is ever named.
|
|
300
|
+
return json({ ok: true, history: rules.history, lines: lines.map((r) => ({ ...wire(r), ...(who.player && r.from?.player === who.player ? { mine: true } : {}) })), more: lines.length >= LOUNGE_LIMITS.page });
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (path === '/lounge/api/show') {
|
|
304
|
+
if (request.method !== 'POST' || !sameOrigin(request, url)) return json({ ok: false, error: 'origin', message: 'A card comes from the Lounge page itself.' }, 403);
|
|
305
|
+
const who = await whoOf(request, env);
|
|
306
|
+
if (!who.acct) return json({ ok: false, error: 'sign-in', message: 'Sign in with a passkey to show what you made.' }, 401);
|
|
307
|
+
const r = await readJson(request);
|
|
308
|
+
if (r.error) return json({ ok: false, error: r.error }, 400);
|
|
309
|
+
if (!who.owner && !showAllowed(who.player)) return json({ ok: false, error: 'rate', message: 'Three cards an hour: give the Lounge a moment with this one.' }, 429);
|
|
310
|
+
if (!who.owner && !showTry(who.player)) return json({ ok: false, error: 'rate', message: 'That is a lot of links. Try again in a few minutes.' }, 429);
|
|
311
|
+
const made = await cardFor(r.body.url, { cat, origin: url.origin, local });
|
|
312
|
+
if (!made.ok) return json(made, 400);
|
|
313
|
+
const note = cleanText(r.body.note, 200);
|
|
314
|
+
const b = BROWSER_KEY.test(String(r.body.b ?? '')) ? r.body.b : null;
|
|
315
|
+
const res = await loungeControl(env, 'card', { card: made.card, note, name: who.name, player: who.player, acct: true, owner: who.owner, mod: who.mod, browser: b });
|
|
316
|
+
if (!res.ok) return json({ ok: false, error: res.error ?? 'held', why: res.why ?? null, message: res.message ?? 'That card was not posted.' }, res.error === 'muted' || res.error === 'kicked' ? 403 : 400);
|
|
317
|
+
if (!who.owner) showPosted(who.player);
|
|
318
|
+
return json({ ok: true, id: res.id, card: made.card, message: 'It\'s in the Lounge.' });
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (path === '/lounge/api/report') {
|
|
322
|
+
if (request.method !== 'POST' || !sameOrigin(request, url)) return json({ ok: false, error: 'origin', message: 'A report comes from the Lounge page itself.' }, 403);
|
|
323
|
+
const r = await readJson(request);
|
|
324
|
+
if (r.error) return json({ ok: false, error: r.error }, 400);
|
|
325
|
+
const id = String(r.body.id ?? '');
|
|
326
|
+
if (!LINE_ID.test(id)) return json({ ok: false, error: 'bad-request', message: 'id names the message' }, 400);
|
|
327
|
+
const reason = REPORT_REASONS.includes(r.body.reason) ? r.body.reason : 'other';
|
|
328
|
+
const key = `${BROWSER_KEY.test(String(r.body.b ?? '')) ? r.body.b : ''}|${request.headers.get('cf-connecting-ip') ?? ''}`;
|
|
329
|
+
if (!reportAllowed(key)) return json({ ok: false, error: 'rate', message: 'That is a lot of reports: the studio has them. Try again in a few minutes.' }, 429);
|
|
330
|
+
const line = await lineOf(env, id);
|
|
331
|
+
if (!line) return json({ ok: false, error: 'gone', message: 'That message is no longer in the Lounge.' }, 404);
|
|
332
|
+
if (line.by === 'studio') return json({ ok: false, error: 'studio', message: 'That is the studio\'s own line.' }, 400);
|
|
333
|
+
try { await fileReport(env, { game: LOUNGE_GAME, room: LOUNGE_ROOM, line, reason }); } catch (error) {
|
|
334
|
+
return json({ ok: false, error: 'not-migrated', message: `Reports need migration 0007_studio_chat.sql (npm run deploy). (${String(error?.message ?? error).slice(0, 120)})` }, 503);
|
|
335
|
+
}
|
|
336
|
+
return json({ ok: true, message: 'Thanks for telling the studio. They will look at it.' });
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (path === '/lounge/api/delete') {
|
|
340
|
+
// A signed-in person takes their own message down, kept ones too (a guest does it on the socket, in the window).
|
|
341
|
+
if (request.method !== 'POST' || !sameOrigin(request, url)) return json({ ok: false, error: 'origin', message: 'This comes from the Lounge page itself.' }, 403);
|
|
342
|
+
const who = await whoOf(request, env);
|
|
343
|
+
if (!who.acct) return json({ ok: false, error: 'sign-in', message: 'Sign in to take your own messages down.' }, 401);
|
|
344
|
+
const r = await readJson(request);
|
|
345
|
+
if (r.error) return json({ ok: false, error: r.error }, 400);
|
|
346
|
+
const id = String(r.body.id ?? '');
|
|
347
|
+
if (!LINE_ID.test(id)) return json({ ok: false, error: 'bad-request', message: 'id names the message' }, 400);
|
|
348
|
+
const line = await lineOf(env, id);
|
|
349
|
+
if (!line) return json({ ok: false, error: 'gone', message: 'That message is gone already.' }, 404);
|
|
350
|
+
if (line.player !== who.player || line.by === 'studio') return json({ ok: false, error: 'not-yours', message: 'Only its sender (or the studio) can take a message down.' }, 403);
|
|
351
|
+
const res = await loungeControl(env, 'unsay', { ids: [id] });
|
|
352
|
+
return json({ ok: Boolean(res.ok), message: 'Taken down.' });
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (path === '/lounge/api/mod') {
|
|
356
|
+
if (request.method !== 'POST' || !sameOrigin(request, url)) return json({ ok: false, error: 'origin', message: 'This comes from the Lounge page itself.' }, 403);
|
|
357
|
+
const who = await whoOf(request, env);
|
|
358
|
+
if (!who.owner && !who.mod) return json({ ok: false, error: 'not-a-mod', message: 'Only the studio\'s owner and its moderators keep the Lounge.' }, 403);
|
|
359
|
+
const r = await readJson(request);
|
|
360
|
+
if (r.error) return json({ ok: false, error: r.error }, 400);
|
|
361
|
+
return json(await modAction(env, cat, who, r.body));
|
|
362
|
+
}
|
|
363
|
+
return path.startsWith('/lounge/') ? json({ ok: false, error: 'not-found' }, 404) : null;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** A kept or live line as every page gets it (the relay's own `line` shape). */
|
|
367
|
+
function wire(r) {
|
|
368
|
+
const base = { id: r.id, at: r.at, name: r.name, seat: null, colour: null, by: r.by, ...(r.acct ? { acct: true } : {}), ...(r.owner ? { owner: true } : {}), ...(r.mod ? { mod: true } : {}), kept: true };
|
|
369
|
+
return { t: 'line', ...base, text: r.text ?? '', ...(r.say ? { say: r.say } : {}), ...(r.kind === 'card' && r.card ? { card: r.card } : {}) };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** One line the room holds now, else the one the Lounge kept (an older line has only its sender's account). */
|
|
373
|
+
async function lineOf(env, id) {
|
|
374
|
+
try {
|
|
375
|
+
const res = await tableOf(env).fetch(tableUrl('__chat', `&op=line&id=${encodeURIComponent(id)}`));
|
|
376
|
+
const line = (await res.json()).line ?? null;
|
|
377
|
+
if (line) return line;
|
|
378
|
+
} catch { /* the kept copy */ }
|
|
379
|
+
const kept = await keptLine(env, LOUNGE_GAME, LOUNGE_ROOM, id);
|
|
380
|
+
return kept ? { id: kept.id, kind: kept.kind, text: kept.text ?? null, glyph: null, name: kept.name, seat: null, at: kept.at, by: kept.by, player: kept.from?.player ?? null, owner: kept.owner, mod: Boolean(kept.mod) } : null;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const reportsMade = new Map();
|
|
384
|
+
function reportAllowed(key, now = Date.now()) {
|
|
385
|
+
const list = (reportsMade.get(key) ?? []).filter((at) => now - at < 10 * 60_000);
|
|
386
|
+
if (list.length >= 8) { reportsMade.set(key, list); return false; }
|
|
387
|
+
list.push(now);
|
|
388
|
+
reportsMade.set(key, list);
|
|
389
|
+
if (reportsMade.size > 5000) for (const [k, v] of reportsMade) if (!v.some((at) => now - at < 10 * 60_000)) reportsMade.delete(k);
|
|
390
|
+
return true;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* The Lounge's keepers, from the page: the owner and the moderators they named. Remove a line, mute or kick its sender
|
|
395
|
+
* (their lines come down with them), or set slow mode. A moderator holds someone for at most an hour and never acts on
|
|
396
|
+
* the owner's lines or another moderator's; the owner may hold for up to a day.
|
|
397
|
+
*/
|
|
398
|
+
async function modAction(env, cat, who, body) {
|
|
399
|
+
const op = String(body.op ?? '');
|
|
400
|
+
if (op === 'slow') {
|
|
401
|
+
const n = Math.floor(Number(body.seconds));
|
|
402
|
+
if (!(n >= 0 && n <= CHAT_LIMITS.slowMax)) return { ok: false, error: 'bad-request', message: `seconds is 0 to ${CHAT_LIMITS.slowMax}` };
|
|
403
|
+
try { await writeChatRules(env, LOUNGE_GAME, '', { slow: n }); } catch (error) { return { ok: false, error: 'not-migrated', message: String(error?.message ?? error).slice(0, 120) }; }
|
|
404
|
+
const rules = await pushRules(env, cat);
|
|
405
|
+
return { ok: true, op, slow: rules.slow, message: rules.slow ? `Slow mode: one message every ${rules.slow} s.` : 'Slow mode is off.' };
|
|
406
|
+
}
|
|
407
|
+
if (!['remove', 'mute', 'kick'].includes(op)) return { ok: false, error: 'bad-request', message: 'op is remove, mute, kick or slow' };
|
|
408
|
+
const id = String(body.line ?? '');
|
|
409
|
+
if (!LINE_ID.test(id)) return { ok: false, error: 'bad-request', message: 'line is the message\'s id' };
|
|
410
|
+
const line = await lineOf(env, id);
|
|
411
|
+
if (!line) return { ok: false, error: 'gone', message: 'That message is gone already.' };
|
|
412
|
+
if (line.by === 'studio' && op !== 'remove') return { ok: false, error: 'studio', message: 'That is the studio\'s own line.' };
|
|
413
|
+
if (!who.owner && (line.owner || line.mod) && line.player !== who.player) return { ok: false, error: 'keeper', message: 'Moderators don\'t act on the owner\'s or another moderator\'s lines.' };
|
|
414
|
+
if (op === 'remove') {
|
|
415
|
+
const r = await loungeControl(env, 'unsay', { ids: [id] });
|
|
416
|
+
return { ok: Boolean(r.ok), op, removed: r.removed ?? 0, message: 'Taken down on every screen.' };
|
|
417
|
+
}
|
|
418
|
+
const minutes = Math.max(1, Math.min(who.owner ? 1440 : 60, Math.floor(Number(body.minutes) || 10)));
|
|
419
|
+
const kept = { id: line.id, name: line.name, seat: null, from: { client: null, token: null, browser: null, player: line.player ?? null } };
|
|
420
|
+
const r = await loungeControl(env, op, { line: id, minutes, purge: true, kept, ...(op === 'kick' ? { message: 'The Lounge\'s keepers asked you to take a break. You can come back later.' } : {}) });
|
|
421
|
+
return { ...r, op, minutes, message: r.ok ? `${line.name} is ${op === 'kick' ? 'out of the Lounge' : 'muted'} for ${minutes} min; their messages are down.` : r.message };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/* ------------------------------------------------------------------ the office */
|
|
425
|
+
|
|
426
|
+
/** The Lounge in the office: its rules and where they came from, the play nights, the moderators, the last lines, reports. */
|
|
427
|
+
export async function loungeOffice(env, cat, origin) {
|
|
428
|
+
const L = loungeOf(cat);
|
|
429
|
+
if (!L) return null;
|
|
430
|
+
const [rules, from, nights, mods, facts, reports] = await Promise.all([loungeRules(env, cat, { fresh: true }), rulesFrom(env), nightsOf(env, { limit: LOUNGE_LIMITS.nights }), modsOf(env), loungeFacts(env), reportsOf(env, { game: LOUNGE_GAME })]);
|
|
431
|
+
const lines = (facts?.office?.chat?.lines ?? []).slice(-50).map((l) => ({ id: l.id, at: l.at, t: l.t, kind: l.kind, name: oneLine(l.name, 40), by: l.by, text: l.text ?? null, glyph: l.glyph ?? null, card: l.card ?? null, acct: Boolean(l.acct), owner: Boolean(l.owner), mod: Boolean(l.mod), player: l.player ?? null }));
|
|
432
|
+
return {
|
|
433
|
+
on: true, name: L.name, page: `${origin}/lounge/`, kids: Boolean(L.kids), featured: L.featured,
|
|
434
|
+
rules: publicChat(rules), office: (await chatRowsOf(env, { fresh: true })).get(`${LOUNGE_GAME}/`) ?? null, from,
|
|
435
|
+
nights: nights.map((n) => nightView(n, cat, origin)), mods, lines, here: Math.max(0, Number(facts?.counts?.shells ?? 0) || 0),
|
|
436
|
+
mutes: facts?.office?.mutes ?? [], bans: facts?.office?.bans ?? [], reports,
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** An office control for the Lounge, checked: `{ ok, action }` or `{ ok: false, message }`; null when `op` is not one. */
|
|
441
|
+
export function checkLoungeAction(cat, op, body) {
|
|
442
|
+
if (!op.startsWith('lounge-')) return null;
|
|
443
|
+
const bad = (message) => ({ ok: false, error: 'bad-request', message });
|
|
444
|
+
if (!loungeOf(cat)) return bad('This studio has no Lounge: add "lounge": true to studio.json and deploy.');
|
|
445
|
+
switch (op) {
|
|
446
|
+
case 'lounge-rules': {
|
|
447
|
+
if (body.reset === true) return { ok: true, action: { op, reset: true } };
|
|
448
|
+
const c = checkChatRules(body);
|
|
449
|
+
if (!c.ok) return c;
|
|
450
|
+
if (!Object.keys(c.fields).length) return bad('say what to change: mode, who, react, slow, max, links, swears, ai, hub, history (days), block, allow, lines, emoji; or reset: true');
|
|
451
|
+
return { ok: true, action: { op, fields: c.fields } };
|
|
452
|
+
}
|
|
453
|
+
case 'lounge-night': {
|
|
454
|
+
if (body.remove !== undefined) return /^pn_[a-f0-9]{12}$/.test(String(body.remove)) ? { ok: true, action: { op, remove: body.remove } } : bad('remove is the play night\'s id');
|
|
455
|
+
const n = checkNight(body);
|
|
456
|
+
if (!n.ok) return n;
|
|
457
|
+
if (n.night.game && GAME_ID.test(n.night.game) && !(cat.games ?? []).some((g) => g.id === n.night.game)) return bad(`game: this studio has no game "${n.night.game}"`);
|
|
458
|
+
return { ok: true, action: { op, night: n.night } };
|
|
459
|
+
}
|
|
460
|
+
case 'lounge-mod': {
|
|
461
|
+
if (!/^[A-Za-z0-9_-]{1,64}$/.test(String(body.player ?? ''))) return bad('player is the player\'s id (the office lists players)');
|
|
462
|
+
return { ok: true, action: { op, player: body.player, ...(body.remove === true ? { remove: true } : {}), name: oneLine(body.name, 40) || null } };
|
|
463
|
+
}
|
|
464
|
+
case 'lounge-remove': {
|
|
465
|
+
const ids = (Array.isArray(body.ids) ? body.ids : body.id !== undefined ? [body.id] : []).filter((x) => typeof x === 'string' && LINE_ID.test(x)).slice(0, 64);
|
|
466
|
+
if (!ids.length && body.all !== true) return bad('id is the message\'s id, or all: true');
|
|
467
|
+
return { ok: true, action: { op, ...(body.all === true ? { all: true } : { ids }) } };
|
|
468
|
+
}
|
|
469
|
+
case 'lounge-hold': {
|
|
470
|
+
if (!LINE_ID.test(String(body.line ?? ''))) return bad('line is the message\'s id');
|
|
471
|
+
const minutes = Math.max(1, Math.min(1440, Math.floor(Number(body.minutes) || 10)));
|
|
472
|
+
return { ok: true, action: { op, line: body.line, kick: body.kick === true, minutes, off: body.off === true } };
|
|
473
|
+
}
|
|
474
|
+
default: return bad('unknown control');
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** In words, for the office's toast and an AI's ask. */
|
|
479
|
+
export function describeLounge(cat, a) {
|
|
480
|
+
const name = loungeOf(cat)?.name ?? 'the Lounge';
|
|
481
|
+
switch (a.op) {
|
|
482
|
+
case 'lounge-rules': {
|
|
483
|
+
if (a.reset) return `Give ${name} its own chat rules back.`;
|
|
484
|
+
const f = a.fields;
|
|
485
|
+
const bits = [];
|
|
486
|
+
if (f.history !== undefined) bits.push(f.history ? `keep what people say for ${f.history} ${f.history === 1 ? 'day' : 'days'} (signed-in people can take their own lines down; deleting an account takes its lines)` : 'keep nothing past the last few minutes (what was kept is deleted now)');
|
|
487
|
+
if (f.mode) bits.push({ off: 'turn chat off', emoji: 'keep chat to emoji', lines: 'keep chat to emoji and quick lines', text: 'let people type' }[f.mode]);
|
|
488
|
+
if (f.who) bits.push(`typing for ${f.who === 'anyone' ? 'anyone, guests too' : f.who === 'signed-in' ? 'signed-in people (a passkey)' : 'members only'}`);
|
|
489
|
+
if (f.slow !== undefined) bits.push(f.slow ? `slow mode: one message every ${f.slow} s` : 'no slow mode');
|
|
490
|
+
if (f.hub !== undefined) bits.push(f.hub ? 'show it live on homie.rocks' : 'keep it to this site');
|
|
491
|
+
const rest = Object.keys(f).filter((k) => !['history', 'mode', 'who', 'slow', 'hub'].includes(k));
|
|
492
|
+
if (rest.length) bits.push(`change its ${rest.join(', ')}`);
|
|
493
|
+
return `${name}: ${bits.join('; ')}.`;
|
|
494
|
+
}
|
|
495
|
+
case 'lounge-night': return a.remove ? `Take a play night off ${name}.` : `Add a play night to ${name}: "${a.night.title}", ${new Date(a.night.at).toISOString().replace('T', ' ').slice(0, 16)} UTC for ${a.night.minutes} min.`;
|
|
496
|
+
case 'lounge-mod': return a.remove ? `Stop ${a.name ?? a.player} moderating ${name}.` : `Make ${a.name ?? a.player} a moderator of ${name}: they can take messages down, mute or kick someone for up to an hour, and set slow mode.`;
|
|
497
|
+
case 'lounge-remove': return a.all ? `Take every message in ${name} down.` : `Take ${a.ids.length === 1 ? 'a message' : `${a.ids.length} messages`} down in ${name}.`;
|
|
498
|
+
case 'lounge-hold': return a.off ? `Unmute someone in ${name}.` : `${a.kick ? 'Kick' : 'Mute'} the sender of a message in ${name} for ${a.minutes} min, and take their messages down.`;
|
|
499
|
+
default: return 'A Lounge control.';
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** Whether an office key (the owner's AI) only ASKS: keeping more, opening chat up, a new moderator, a mute or a kick. */
|
|
504
|
+
export async function loungeNeedsAsk(env, cat, a, chatOpensUp) {
|
|
505
|
+
if (a.op === 'lounge-rules') return a.reset ? true : chatOpensUp(await loungeRules(env, cat, { fresh: true }), a.fields);
|
|
506
|
+
if (a.op === 'lounge-mod') return a.remove !== true;
|
|
507
|
+
if (a.op === 'lounge-hold') return a.off !== true;
|
|
508
|
+
return false;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
export async function performLounge(env, cat, a, origin = '') {
|
|
512
|
+
switch (a.op) {
|
|
513
|
+
case 'lounge-rules': {
|
|
514
|
+
const before = await loungeRules(env, cat, { fresh: true });
|
|
515
|
+
try {
|
|
516
|
+
if (a.reset) await clearChatRules(env, LOUNGE_GAME, '');
|
|
517
|
+
else await writeChatRules(env, LOUNGE_GAME, '', a.fields);
|
|
518
|
+
} catch (error) { return { ok: false, error: 'not-migrated', message: `The Lounge needs migrations 0007 and 0009 (npm run deploy). (${String(error?.message ?? error).slice(0, 120)})` }; }
|
|
519
|
+
const rules = await pushRules(env, cat);
|
|
520
|
+
// Keeping fewer days (or none) forgets the rest now, not later.
|
|
521
|
+
let forgot = 0;
|
|
522
|
+
if ((rules.history ?? 0) < (before.history ?? 0)) forgot = await trimHistory(env, LOUNGE_GAME, rules.history ?? 0).catch(() => 0);
|
|
523
|
+
return { ok: true, op: a.op, rules: publicChat(rules), ...(forgot ? { forgot } : {}) };
|
|
524
|
+
}
|
|
525
|
+
case 'lounge-night': {
|
|
526
|
+
try {
|
|
527
|
+
if (a.remove) return { op: a.op, ...(await removeNight(env, a.remove)) };
|
|
528
|
+
return { op: a.op, ...(await addNight(env, a.night)), night: nightView({ id: null, ...a.night, on: false }, cat, origin) };
|
|
529
|
+
} catch (error) { return { ok: false, error: 'not-migrated', message: `Play nights need migration 0009_studio_lounge.sql (npm run deploy). (${String(error?.message ?? error).slice(0, 120)})` }; }
|
|
530
|
+
}
|
|
531
|
+
case 'lounge-mod': {
|
|
532
|
+
try {
|
|
533
|
+
const r = a.remove ? await removeMod(env, a.player) : await addMod(env, a.player);
|
|
534
|
+
return { op: a.op, ...r, note: r.ok ? (a.remove ? 'They are not a moderator any more (from their next visit).' : 'They are a moderator from their next visit to the Lounge.') : undefined };
|
|
535
|
+
} catch (error) { return { ok: false, error: 'not-migrated', message: `Moderators need migration 0009_studio_lounge.sql (npm run deploy). (${String(error?.message ?? error).slice(0, 120)})` }; }
|
|
536
|
+
}
|
|
537
|
+
case 'lounge-remove': return { ...(await loungeControl(env, 'unsay', a.all ? { all: true } : { ids: a.ids })), op: a.op };
|
|
538
|
+
case 'lounge-hold': {
|
|
539
|
+
if (a.off) return { ...(await loungeControl(env, 'mute', { line: a.line, off: true })), op: a.op };
|
|
540
|
+
const line = await lineOf(env, a.line);
|
|
541
|
+
if (!line) return { ok: false, error: 'gone', message: 'That message is gone already.' };
|
|
542
|
+
const kept = { id: line.id, name: line.name, seat: null, from: { client: null, token: null, browser: null, player: line.player ?? null } };
|
|
543
|
+
return { ...(await loungeControl(env, a.kick ? 'kick' : 'mute', { line: a.line, minutes: a.minutes, purge: true, kept, ...(a.kick ? { message: 'The studio asked you to take a break from the Lounge.' } : {}) })), op: a.op };
|
|
544
|
+
}
|
|
545
|
+
default: return { ok: false, error: 'unknown' };
|
|
546
|
+
}
|
|
547
|
+
}
|