-- ===================================================================== -- aether-police / Modul: Interne Kommunikation -- -- Zwei Wege: Nachrichten zwischen Beamten und Aushänge der Leitung. -- -- Das System stellt zu, sortiert und erinnert. Es verfasst nichts, -- leitet nichts weiter und bestätigt keine Kenntnisnahme im Namen -- eines Beamten. Ein gelesener Posteingang ist reine Anzeige — eine -- Kenntnisnahme dagegen ist eine Erklärung und braucht einen Klick. -- ===================================================================== Kern.RegistriereModul({ id = 'kommunikation', name = 'Kommunikation', icon = '✉', reihenfolge = 12, recht = nil, beschreibung = 'Nachrichten, Aushänge und Dienstanweisungen', }) -- Erlaubte Kategorien eines Aushangs local AUSHANG_KATEGORIEN = { anweisung = 'Dienstanweisung', information = 'Information', warnung = 'Warnung', } -- --------------------------------------------------------------------- -- Hilfsfunktionen -- --------------------------------------------------------------------- --- Baut die Bedingung, welche Nachrichten einen Beamten erreichen --- @param beamter table Empfänger --- @return string Bedingung, table Werte local function EmpfangsBedingung(beamter) return [[( (n.empf_typ = 'person' AND n.empfaenger = ?) OR (n.empf_typ = 'abteilung' AND n.empfaenger = ?) OR (n.empf_typ = 'rundmeldung') )]], { tostring(beamter.charakterId), beamter.abteilung } end --- Zählt ungelesene Nachrichten eines Beamten --- @param beamter table Empfänger --- @return number local function ZaehleUngelesen(beamter) local bedingung, werte = EmpfangsBedingung(beamter) return MySQL.scalar.await(([[ SELECT COUNT(*) FROM police_nachricht n WHERE %s AND n.absender <> ? AND NOT EXISTS ( SELECT 1 FROM police_nachricht_gelesen g WHERE g.nachricht = n.id AND g.`character` = ? ) ]]):format(bedingung), { werte[1], werte[2], beamter.charakterId, beamter.charakterId }) or 0 end --- Zählt offene Kenntnisnahmen eines Beamten --- @param beamter table Empfänger --- @return number local function ZaehleOffeneKenntnis(beamter) return MySQL.scalar.await([[ SELECT COUNT(*) FROM police_aushang a WHERE a.aktiv = 1 AND a.kenntnispflicht = 1 AND (a.abteilung IS NULL OR a.abteilung = ?) AND NOT EXISTS ( SELECT 1 FROM police_aushang_kenntnis k WHERE k.aushang = a.id AND k.`character` = ? ) ]], { beamter.abteilung, beamter.charakterId }) or 0 end --- Liefert den Namen einer Abteilung local function AbteilungsName(id) for _, eintrag in ipairs(Polizei.Config.Abteilungen) do if eintrag.id == id then return eintrag.name end end return id end -- ===================================================================== -- POSTEINGANG -- ===================================================================== Kern.RegistriereAktion('kommunikation', 'uebersicht', nil, function(quelle, beamter, daten) local ordner = tostring(daten.ordner or 'eingang') local bedingung, werte = EmpfangsBedingung(beamter) local nachrichten if ordner == 'gesendet' then nachrichten = MySQL.query.await([[ SELECT n.id, n.empfaenger, n.empf_typ, n.betreff, n.text, n.gesendet, n.absender, c.firstname, c.lastname, p.dienstnummer, 1 AS gelesen FROM police_nachricht n JOIN aether_characters c ON c.id = n.absender LEFT JOIN police_personal p ON p.`character` = n.absender WHERE n.absender = ? ORDER BY n.id DESC LIMIT 100 ]], { beamter.charakterId }) or {} else nachrichten = MySQL.query.await(([[ SELECT n.id, n.empfaenger, n.empf_typ, n.betreff, n.text, n.gesendet, n.absender, c.firstname, c.lastname, p.dienstnummer, (SELECT COUNT(*) FROM police_nachricht_gelesen g WHERE g.nachricht = n.id AND g.`character` = ?) AS gelesen FROM police_nachricht n JOIN aether_characters c ON c.id = n.absender LEFT JOIN police_personal p ON p.`character` = n.absender WHERE %s AND n.absender <> ? ORDER BY n.id DESC LIMIT 100 ]]):format(bedingung), { beamter.charakterId, werte[1], werte[2], beamter.charakterId }) or {} end -- Empfängernamen und Vorschau ergänzen for _, eintrag in ipairs(nachrichten) do eintrag.absenderName = eintrag.firstname .. ' ' .. eintrag.lastname eintrag.gelesen = tonumber(eintrag.gelesen) > 0 if eintrag.empf_typ == 'rundmeldung' then eintrag.empfaengerName = 'Alle Einheiten' elseif eintrag.empf_typ == 'abteilung' then eintrag.empfaengerName = AbteilungsName(eintrag.empfaenger) else local ziel = MySQL.query.await([[ SELECT c.firstname, c.lastname, p.dienstnummer FROM aether_characters c LEFT JOIN police_personal p ON p.`character` = c.id WHERE c.id = ? ]], { tonumber(eintrag.empfaenger) or 0 }) eintrag.empfaengerName = ziel and ziel[1] and (ziel[1].firstname .. ' ' .. ziel[1].lastname) or 'Unbekannt' end -- Vorschau kurz halten, der volle Text kommt beim Öffnen eintrag.vorschau = tostring(eintrag.text):gsub('%s+', ' '):sub(1, 120) eintrag.text = nil eintrag.firstname, eintrag.lastname = nil, nil end return { ordner = ordner, nachrichten = nachrichten, ungelesen = ZaehleUngelesen(beamter), offeneKenntnis = ZaehleOffeneKenntnis(beamter), abteilungen = Polizei.Config.Abteilungen, meineAbteilung = beamter.abteilung, rechte = { senden = Rechte.Hat(beamter, 'nachricht.senden'), rundmeldung = Rechte.Hat(beamter, 'nachricht.rundmeldung'), aushang = Rechte.Hat(beamter, 'aushang.schreiben'), archivieren = Rechte.Hat(beamter, 'aushang.archivieren'), }, } end) Kern.RegistriereAktion('kommunikation', 'nachrichtOeffnen', nil, function(quelle, beamter, daten) local id = tonumber(daten.id) if not id then return nil, 'Ungültige Kennung.' end local zeilen = MySQL.query.await([[ SELECT n.*, c.firstname, c.lastname, p.dienstnummer, p.rolle FROM police_nachricht n JOIN aether_characters c ON c.id = n.absender LEFT JOIN police_personal p ON p.`character` = n.absender WHERE n.id = ? ]], { id }) if not zeilen or not zeilen[1] then return nil, 'Nachricht nicht gefunden.' end local nachricht = zeilen[1] -- Zustellprüfung: nur Absender und tatsächliche Empfänger dürfen lesen local darf = nachricht.absender == beamter.charakterId or nachricht.empf_typ == 'rundmeldung' or (nachricht.empf_typ == 'abteilung' and nachricht.empfaenger == beamter.abteilung) or (nachricht.empf_typ == 'person' and tonumber(nachricht.empfaenger) == beamter.charakterId) if not darf then return nil, 'Diese Nachricht ist nicht an dich gerichtet.' end -- Lesestatus vermerken: das ist reine Anzeige, keine Erklärung if nachricht.absender ~= beamter.charakterId then MySQL.query([[ INSERT IGNORE INTO police_nachricht_gelesen (nachricht, `character`) VALUES (?, ?) ]], { id, beamter.charakterId }) end local rolle = Polizei.HoleRolle(nachricht.rolle) return { id = nachricht.id, betreff = nachricht.betreff, text = nachricht.text, gesendet = nachricht.gesendet, absender = nachricht.absender, absenderName = nachricht.firstname .. ' ' .. nachricht.lastname, dienstnummer = nachricht.dienstnummer, rolleName = rolle and rolle.name or nachricht.rolle, empfTyp = nachricht.empf_typ, empfaengerName = nachricht.empf_typ == 'rundmeldung' and 'Alle Einheiten' or nachricht.empf_typ == 'abteilung' and AbteilungsName(nachricht.empfaenger) or nil, -- Antworten geht nur bei einer Nachricht von einer Person antwortAn = nachricht.absender ~= beamter.charakterId and nachricht.absender or nil, } end) --- Auswahlliste möglicher Empfänger Kern.RegistriereAktion('kommunikation', 'empfaenger', 'nachricht.senden', function(quelle, beamter) local beamte = MySQL.query.await([[ SELECT p.`character`, p.dienstnummer, p.rolle, p.abteilung, c.firstname, c.lastname FROM police_personal p JOIN aether_characters c ON c.id = p.`character` WHERE p.aktiv = 1 AND p.`character` <> ? ORDER BY p.dienstnummer ]], { beamter.charakterId }) or {} -- Wer gerade erreichbar ist, wird markiert — mehr nicht local imDienst = {} for _, eintrag in pairs(Dienst.AlleImDienst()) do imDienst[eintrag.charakterId] = true end for _, eintrag in ipairs(beamte) do local rolle = Polizei.HoleRolle(eintrag.rolle) eintrag.rolleName = rolle and rolle.name or eintrag.rolle eintrag.imDienst = imDienst[eintrag.character] == true end return { beamte = beamte, abteilungen = Polizei.Config.Abteilungen, rundmeldung = Rechte.Hat(beamter, 'nachricht.rundmeldung'), } end) Kern.RegistriereAktion('kommunikation', 'senden', 'nachricht.senden', function(quelle, beamter, daten) local typ = tostring(daten.empfTyp or 'person') local text = type(daten.text) == 'string' and daten.text or '' local betreff = type(daten.betreff) == 'string' and daten.betreff:sub(1, 160) or '' if #text < 2 then return nil, 'Die Nachricht ist leer.' end if #text > 4000 then return nil, 'Die Nachricht ist zu lang.' end if #betreff < 2 then return nil, 'Bitte einen Betreff angeben.' end local empfaenger, hinweisText if typ == 'rundmeldung' then if not Rechte.Hat(beamter, 'nachricht.rundmeldung') then return nil, 'Du darfst keine Rundmeldung absetzen.' end empfaenger = 'alle' hinweisText = 'Rundmeldung von ' .. beamter.name elseif typ == 'abteilung' then local gefunden = false for _, eintrag in ipairs(Polizei.Config.Abteilungen) do if eintrag.id == daten.empfaenger then gefunden = true break end end if not gefunden then return nil, 'Unbekannte Abteilung.' end empfaenger = daten.empfaenger hinweisText = 'Nachricht an ' .. AbteilungsName(empfaenger) else typ = 'person' local zielId = tonumber(daten.empfaenger) if not zielId then return nil, 'Kein Empfänger gewählt.' end local vorhanden = MySQL.scalar.await( 'SELECT `character` FROM police_personal WHERE `character` = ? AND aktiv = 1', { zielId }) if not vorhanden then return nil, 'Dieser Empfänger ist nicht im Dienstbestand.' end empfaenger = tostring(zielId) hinweisText = 'Nachricht von ' .. beamter.name end local id = MySQL.insert.await([[ INSERT INTO police_nachricht (absender, empfaenger, empf_typ, betreff, text) VALUES (?, ?, ?, ?, ?) ]], { beamter.charakterId, empfaenger, typ, betreff, text }) -- Zustellung: nur benachrichtigen, nicht öffnen if typ == 'person' then Hinweise.Erstelle({ charakterId = tonumber(empfaenger) }, { kategorie = 'system', prioritaet = 'normal', titel = hinweisText, text = betreff, zielTyp = 'nachricht', zielId = id, }) else for _, ziel in pairs(Dienst.AlleImDienst()) do local passt = typ == 'rundmeldung' or ziel.abteilung == empfaenger if passt and ziel.charakterId ~= beamter.charakterId then Hinweise.Erstelle({ charakterId = ziel.charakterId }, { kategorie = 'system', prioritaet = typ == 'rundmeldung' and 'hoch' or 'normal', titel = hinweisText, text = betreff, zielTyp = 'nachricht', zielId = id, }) end end end Protokoll.Schreibe(beamter, 'kommunikation', 'Nachricht gesendet', { typ = 'nachricht', id = id }, { empfTyp = typ, empfaenger = empfaenger }) Kern.Loese('nachricht.gesendet', id, beamter) return { id = id } end) -- ===================================================================== -- AUSHÄNGE -- ===================================================================== Kern.RegistriereAktion('kommunikation', 'aushaenge', nil, function(quelle, beamter, daten) local archiv = daten.archiv == true local aushaenge = MySQL.query.await([[ SELECT a.id, a.titel, a.text, a.kategorie, a.abteilung, a.angeheftet, a.kenntnispflicht, a.frist, a.erstellt_am, a.erstellt_von, a.aktiv, a.archiviert_am, c.firstname, c.lastname, p.dienstnummer, (SELECT COUNT(*) FROM police_aushang_kenntnis k WHERE k.aushang = a.id) AS kenntnisse, (SELECT COUNT(*) FROM police_aushang_kenntnis k WHERE k.aushang = a.id AND k.`character` = ?) AS meineKenntnis FROM police_aushang a JOIN aether_characters c ON c.id = a.erstellt_von LEFT JOIN police_personal p ON p.`character` = a.erstellt_von WHERE a.aktiv = ? AND (? = 1 OR a.abteilung IS NULL OR a.abteilung = ?) ORDER BY a.angeheftet DESC, a.id DESC LIMIT 100 ]], { beamter.charakterId, archiv and 0 or 1, -- Wer Aushänge veröffentlichen darf, sieht auch die anderer Abteilungen Rechte.Hat(beamter, 'aushang.schreiben') and 1 or 0, beamter.abteilung, }) or {} -- Wie viele Beamte kämen für eine Kenntnisnahme überhaupt in Frage? for _, eintrag in ipairs(aushaenge) do eintrag.autor = eintrag.firstname .. ' ' .. eintrag.lastname eintrag.kategorieName = AUSHANG_KATEGORIEN[eintrag.kategorie] or eintrag.kategorie eintrag.abteilungName = eintrag.abteilung and AbteilungsName(eintrag.abteilung) or nil eintrag.bestaetigt = tonumber(eintrag.meineKenntnis) > 0 eintrag.angeheftet = eintrag.angeheftet == 1 eintrag.kenntnispflicht = eintrag.kenntnispflicht == 1 eintrag.betroffen = MySQL.scalar.await([[ SELECT COUNT(*) FROM police_personal WHERE aktiv = 1 AND (? IS NULL OR abteilung = ?) ]], { eintrag.abteilung, eintrag.abteilung }) or 0 eintrag.firstname, eintrag.lastname, eintrag.meineKenntnis = nil, nil, nil end return { aushaenge = aushaenge, archiv = archiv, kategorien = AUSHANG_KATEGORIEN, abteilungen= Polizei.Config.Abteilungen, rechte = { schreiben = Rechte.Hat(beamter, 'aushang.schreiben'), archivieren = Rechte.Hat(beamter, 'aushang.archivieren'), }, } end) Kern.RegistriereAktion('kommunikation', 'aushangErstellen', 'aushang.schreiben', function(quelle, beamter, daten) local titel = type(daten.titel) == 'string' and daten.titel:sub(1, 160) or '' local text = type(daten.text) == 'string' and daten.text or '' if #titel < 3 then return nil, 'Bitte einen Titel angeben.' end if #text < 5 then return nil, 'Der Aushang hat keinen Inhalt.' end if #text > 8000 then return nil, 'Der Aushang ist zu lang.' end local kategorie = AUSHANG_KATEGORIEN[daten.kategorie] and daten.kategorie or 'information' -- Abteilung: leer bedeutet gesamte Behörde local abteilung = nil if daten.abteilung and daten.abteilung ~= '' then for _, eintrag in ipairs(Polizei.Config.Abteilungen) do if eintrag.id == daten.abteilung then abteilung = eintrag.id break end end if not abteilung then return nil, 'Unbekannte Abteilung.' end end -- Frist nur sinnvoll, wenn eine Kenntnisnahme verlangt wird local kenntnispflicht = daten.kenntnispflicht == true local fristMinuten = tonumber(daten.fristMinuten) if fristMinuten and (fristMinuten < 5 or fristMinuten > 20160) then return nil, 'Die Frist muss zwischen 5 Minuten und 14 Tagen liegen.' end if not kenntnispflicht then fristMinuten = nil end local id = MySQL.insert.await([[ INSERT INTO police_aushang (titel, text, kategorie, abteilung, angeheftet, kenntnispflicht, frist, erstellt_von) VALUES (?, ?, ?, ?, ?, ?, CASE WHEN ? IS NULL THEN NULL ELSE DATE_ADD(NOW(), INTERVAL ? MINUTE) END, ?) ]], { titel, text, kategorie, abteilung, daten.angeheftet == true and 1 or 0, kenntnispflicht and 1 or 0, fristMinuten, fristMinuten, beamter.charakterId, }) -- Betroffene informieren, aber nichts für sie bestätigen for _, ziel in pairs(Dienst.AlleImDienst()) do if (abteilung == nil or ziel.abteilung == abteilung) and ziel.charakterId ~= beamter.charakterId then Hinweise.Erstelle({ charakterId = ziel.charakterId }, { kategorie = 'system', prioritaet = kategorie == 'warnung' and 'hoch' or 'normal', titel = 'Neuer Aushang: ' .. (AUSHANG_KATEGORIEN[kategorie] or kategorie), text = titel, zielTyp = 'aushang', zielId = id, }) end end Protokoll.Schreibe(beamter, 'kommunikation', 'Aushang veröffentlicht', { typ = 'aushang', id = id }, { kategorie = kategorie, abteilung = abteilung, kenntnispflicht = kenntnispflicht }) Kern.Loese('aushang.erstellt', id, beamter) return { id = id } end) --- Kenntnisnahme: bestätigt ausschließlich der Beamte selbst Kern.RegistriereAktion('kommunikation', 'kenntnisnahme', nil, function(quelle, beamter, daten) local id = tonumber(daten.id) if not id then return nil, 'Ungültige Kennung.' end local aushang = MySQL.query.await( 'SELECT id, titel, abteilung, aktiv FROM police_aushang WHERE id = ?', { id }) if not aushang or not aushang[1] then return nil, 'Aushang nicht gefunden.' end if aushang[1].aktiv ~= 1 then return nil, 'Dieser Aushang ist archiviert.' end if aushang[1].abteilung and aushang[1].abteilung ~= beamter.abteilung then return nil, 'Dieser Aushang betrifft deine Abteilung nicht.' end MySQL.query.await([[ INSERT IGNORE INTO police_aushang_kenntnis (aushang, `character`) VALUES (?, ?) ]], { id, beamter.charakterId }) Protokoll.Schreibe(beamter, 'kommunikation', 'Kenntnisnahme bestätigt', { typ = 'aushang', id = id }, { titel = aushang[1].titel }) return { ok = true } end) --- Wer hat bestätigt, wer noch nicht — reine Auskunft für den Verfasser Kern.RegistriereAktion('kommunikation', 'kenntnisStand', 'aushang.schreiben', function(quelle, beamter, daten) local id = tonumber(daten.id) if not id then return nil, 'Ungültige Kennung.' end local aushang = MySQL.query.await( 'SELECT id, titel, abteilung, frist FROM police_aushang WHERE id = ?', { id }) if not aushang or not aushang[1] then return nil, 'Aushang nicht gefunden.' end local abteilung = aushang[1].abteilung local beamte = MySQL.query.await([[ SELECT p.`character`, p.dienstnummer, p.abteilung, c.firstname, c.lastname, k.bestaetigt_am FROM police_personal p JOIN aether_characters c ON c.id = p.`character` LEFT JOIN police_aushang_kenntnis k ON k.aushang = ? AND k.`character` = p.`character` WHERE p.aktiv = 1 AND (? IS NULL OR p.abteilung = ?) ORDER BY k.bestaetigt_am IS NULL DESC, p.dienstnummer ]], { id, abteilung, abteilung }) or {} local offen = 0 for _, eintrag in ipairs(beamte) do eintrag.name = eintrag.firstname .. ' ' .. eintrag.lastname eintrag.firstname, eintrag.lastname = nil, nil if not eintrag.bestaetigt_am then offen = offen + 1 end end return { titel = aushang[1].titel, frist = aushang[1].frist, beamte = beamte, offen = offen, gesamt = #beamte, } end) Kern.RegistriereAktion('kommunikation', 'aushangArchivieren', 'aushang.archivieren', function(quelle, beamter, daten) local id = tonumber(daten.id) if not id then return nil, 'Ungültige Kennung.' end local aushang = MySQL.query.await( 'SELECT id, titel, aktiv FROM police_aushang WHERE id = ?', { id }) if not aushang or not aushang[1] then return nil, 'Aushang nicht gefunden.' end if aushang[1].aktiv ~= 1 then return nil, 'Dieser Aushang ist bereits archiviert.' end MySQL.update.await([[ UPDATE police_aushang SET aktiv = 0, archiviert_von = ?, archiviert_am = NOW() WHERE id = ? ]], { beamter.charakterId, id }) Protokoll.Schreibe(beamter, 'kommunikation', 'Aushang archiviert', { typ = 'aushang', id = id }, { titel = aushang[1].titel }, daten.grund) return { ok = true } end) Kern.RegistriereAktion('kommunikation', 'anheften', 'aushang.schreiben', function(quelle, beamter, daten) local id = tonumber(daten.id) if not id then return nil, 'Ungültige Kennung.' end local angeheftet = daten.angeheftet == true MySQL.update.await('UPDATE police_aushang SET angeheftet = ? WHERE id = ? AND aktiv = 1', { angeheftet and 1 or 0, id }) Protokoll.Schreibe(beamter, 'kommunikation', angeheftet and 'Aushang angeheftet' or 'Aushang losgelöst', { typ = 'aushang', id = id }) return { ok = true } end) -- --------------------------------------------------------------------- -- Schnittstelle für andere Ressourcen -- --------------------------------------------------------------------- --- Zählt offene Post eines Beamten im Dienst --- @param quelle number Server-ID --- @return table { nachrichten, kenntnisnahmen } exports('OffenePost', function(quelle) local beamter = Dienst.Hole(quelle) if not beamter then return { nachrichten = 0, kenntnisnahmen = 0 } end return { nachrichten = ZaehleUngelesen(beamter), kenntnisnahmen = ZaehleOffeneKenntnis(beamter), } end) --- Schickt eine Nachricht aus einer anderen Ressource in den Posteingang. --- Der Absender muss ein Beamter sein — das System schreibt nicht selbst. --- @param absenderCharakterId number Verfassender Beamter --- @param empfTyp string person/abteilung/rundmeldung --- @param empfaenger string|number Ziel --- @param betreff string Betreff --- @param text string Inhalt --- @return number|nil Nachrichten-ID exports('SendeNachricht', function(absenderCharakterId, empfTyp, empfaenger, betreff, text) if not absenderCharakterId or not betreff or not text then return nil end local quelle = Dienst.QuelleVonCharakter(absenderCharakterId) local beamter = quelle and Dienst.Hole(quelle) if not beamter then return nil end local ergebnis = Kern.FuehreAus(quelle, beamter, 'kommunikation', 'senden', { empfTyp = empfTyp, empfaenger = empfaenger, betreff = betreff, text = text, }) return ergebnis and ergebnis.id or nil end)