Chat im Match: Match-Chat und Team-Chat in Tabs, Zuschauer ausgeschlossen, nach Matchende nur lesbar

This commit is contained in:
mregosar committed 2026-10-29 22:44:17 +01:00
1 parent e26c736f2e
commit c820ba9f12
8 files changed
+267

No files matched your search

+14
View File
@@ -118,6 +118,20 @@ CREATE TABLE IF NOT EXISTS knife_servers (
updated_at INTEGER NOT NULL
);
-- Chat im Match: channel = 'match' (beide Teams + Organisator) oder 'A' / 'B' (nur das eigene Team)
CREATE TABLE IF NOT EXISTS chat_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
match_id TEXT NOT NULL REFERENCES matches(id) ON DELETE CASCADE,
channel TEXT NOT NULL,
player_id TEXT,
author TEXT NOT NULL,
team TEXT,
organizer INTEGER NOT NULL DEFAULT 0,
text TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS chat_by_match ON chat_messages (match_id, channel, id);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
+1
View File
@@ -113,6 +113,7 @@ export function createApiRouter({ service, manager, config, pools, adapter, sett
const action = (path, handler) => router.post(`/matches/:id/${path}`, limit('action'), wrap((req) => handler(viewerFor(req), req.body ?? {})));
action('ready', (v, b) => service.setReady(v, Boolean(b.ready)));
action('chat', (v, b) => service.postChat(v, { channel: b.channel, text: b.text }));
action('leave', (v) => service.leave(v));
action('kick', (v, b) => service.kick(v, b.playerId));
action('captain', (v, b) => service.setCaptain(v, b.playerId));
+69
View File
@@ -41,9 +41,13 @@ function cleanSteamId(value) {
return raw;
}
const CHAT_MAX_LENGTH = 300;
const CHAT_HISTORY = 200;
export class MatchService extends EventEmitter {
constructor({ db, pools, config, notifier = { enabled: false, send: async () => {} } }) {
super();
this.chatRate = new Map();
this.db = db;
this.pools = pools;
this.config = config;
@@ -635,6 +639,70 @@ export class MatchService extends EventEmitter {
.run(matchId, match.current_map_number);
}
// ---------- Chat ----------
/**
* Chat-Kanäle, die dieser Zugang sieht: der Match-Chat für Spieler beider Teams und den Organisator,
* dazu der Team-Chat des eigenen Teams. Zuschauer und Gäste sehen keinen Chat.
*/
chatChannels(match, viewer) {
const channels = [];
if (viewer.isOrganizer || viewer.playerId) channels.push({ key: 'match', label: 'Match-Chat' });
if (viewer.playerId && (viewer.team === 'A' || viewer.team === 'B')) {
channels.push({ key: viewer.team, label: `Team ${this.teamName(match, viewer.team)}` });
}
return channels;
}
chatView(match, viewer) {
const channels = this.chatChannels(match, viewer);
if (!channels.length) return null;
const stmt = this.db.prepare(`
SELECT * FROM (SELECT * FROM chat_messages WHERE match_id = ? AND channel = ? ORDER BY id DESC LIMIT ${CHAT_HISTORY})
ORDER BY id
`);
const messages = {};
for (const c of channels) {
messages[c.key] = stmt.all(match.id, c.key).map((m) => ({
id: m.id,
author: m.author,
team: m.team,
organizer: !!m.organizer,
text: m.text,
at: m.created_at,
mine: viewer.playerId ? m.player_id === viewer.playerId : !!m.organizer && !m.player_id,
}));
}
return { channels, messages, canWrite: !CLOSED_STATUSES.includes(match.status), maxLength: CHAT_MAX_LENGTH };
}
/** Nachricht in einen Kanal schreiben. Nach Matchende ist der Chat nur noch lesbar. */
postChat(viewer, { channel, text } = {}) {
const match = this.getMatch(viewer.matchId);
if (!this.chatChannels(match, viewer).some((c) => c.key === channel)) {
throw new HttpError(403, 'In diesem Chat darfst du nicht schreiben', 'forbidden');
}
if (CLOSED_STATUSES.includes(match.status)) throw new HttpError(409, 'Das Match ist beendet, der Chat ist nur noch lesbar', 'match_closed');
const clean = String(text ?? '').replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim();
if (!clean) throw new HttpError(400, 'Die Nachricht ist leer', 'invalid_input');
if (clean.length > CHAT_MAX_LENGTH) throw new HttpError(400, `Höchstens ${CHAT_MAX_LENGTH} Zeichen`, 'invalid_input');
// Gegen Spam: höchstens 5 Nachrichten in 10 Sekunden pro Person
const who = `${match.id}:${viewer.playerId ?? 'org'}`;
const now = this.now();
const recent = (this.chatRate.get(who) ?? []).filter((t) => now - t < 10_000);
if (recent.length >= 5) throw new HttpError(429, 'Zu viele Nachrichten, kurz warten', 'rate_limited');
this.chatRate.set(who, [...recent, now]);
const player = viewer.playerId ? this.db.prepare('SELECT nickname, team FROM players WHERE id = ?').get(viewer.playerId) : null;
this.db.prepare(`
INSERT INTO chat_messages (match_id, channel, player_id, author, team, organizer, text, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(match.id, channel, viewer.playerId ?? null, player?.nickname ?? 'Organisator', player?.team ?? null,
viewer.isOrganizer ? 1 : 0, clean, now);
this.changed(match.id);
}
/** Abgeschlossenes Match ins Archiv legen bzw. zurückholen. */
setArchived(matchId, archived) {
const match = this.getMatch(matchId);
@@ -957,6 +1025,7 @@ export class MatchService extends EventEmitter {
seriesScore: seriesScore(seriesRows),
maxRounds: match.mode === 'wingman' ? this.config.match.maxRoundsWingman : this.config.match.maxRoundsCompetitive,
server: this.serverView(match, viewer),
chat: this.chatView(match, viewer),
events: publicView
? []
: this.db
+42
View File
@@ -648,3 +648,45 @@ test('Ohne Messerrunde bleibt firstTeam wirkungslos', async () => {
await close();
}
});
test('Chat: Match-Chat für beide Teams und Organisator, Team-Chat nur fürs eigene Team, Zuschauer sehen nichts', async () => {
const { call, close } = await setup();
try {
const { id, org, codes, tokens } = await readyWingmanMatch(call, 'bo1');
const spectator = codes.spectator;
const post = (token, channel, text) => call('POST', `/matches/${id}/chat`, { token, body: { channel, text } });
assert.equal((await post(tokens.a1, 'match', 'Hallo zusammen')).status, 200);
assert.equal((await post(tokens.a1, 'A', 'Wir bannen Nuke')).status, 200);
assert.equal((await post(org, 'match', 'Veto startet gleich')).status, 200);
// Fremder Team-Chat und Zuschauer: verboten
assert.equal((await post(tokens.b1, 'A', 'Spioniere')).status, 403);
assert.equal((await post(org, 'A', 'Organisator im Team-Chat')).status, 403);
assert.equal((await post(spectator, 'match', 'Zuschauer')).status, 403);
assert.equal((await post(tokens.a1, 'match', ' ')).status, 400);
assert.equal((await post(tokens.a1, 'match', 'x'.repeat(301))).status, 400);
const a = (await call('GET', `/matches/${id}`, { token: tokens.a2 })).body.chat;
assert.deepEqual(a.channels.map((c) => c.key), ['match', 'A']);
assert.deepEqual(a.messages.match.map((m) => m.text), ['Hallo zusammen', 'Veto startet gleich']);
assert.equal(a.messages.match[1].organizer, true);
assert.deepEqual(a.messages.A.map((m) => m.text), ['Wir bannen Nuke']);
const b = (await call('GET', `/matches/${id}`, { token: tokens.b1 })).body.chat;
assert.deepEqual(b.channels.map((c) => c.key), ['match', 'B']);
assert.ok(!JSON.stringify(b).includes('Wir bannen Nuke'), 'Team B sieht den Team-Chat von A nicht');
assert.deepEqual((await call('GET', `/matches/${id}`, { token: org })).body.chat.channels.map((c) => c.key), ['match']);
assert.equal((await call('GET', `/matches/${id}`, { token: spectator })).body.chat, null);
assert.equal((await call('GET', `/matches/${id}`)).body.chat, null);
// Spam-Schutz und nach Matchende nur lesbar
for (let i = 0; i < 5; i++) await post(tokens.b1, 'match', `Nachricht ${i}`);
assert.equal((await post(tokens.b1, 'match', 'eine zu viel')).status, 429);
await call('POST', `/matches/${id}/finish`, { token: org });
assert.equal((await post(tokens.a1, 'match', 'Nach dem Ende')).status, 409);
const closed = (await call('GET', `/matches/${id}`, { token: tokens.a1 })).body.chat;
assert.equal(closed.canWrite, false);
assert.equal(closed.messages.match.length, 7);
} finally {
await close();
}
});