Chat im Match: Match-Chat und Team-Chat in Tabs, Zuschauer ausgeschlossen, nach Matchende nur lesbar
This commit is contained in:
1 parent
e26c736f2e
commit
c820ba9f12
8 files changed
+267
No files matched your search
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user