aether-framework/resources/[aether]/aether-police/server/module/kommunikation.lua
Jerrit Fritzsche e4b82dd685 Aether-Framework: Fachmodule, Admin-Panel und Docker-Datenbank
- aether-medical: Stationen/Belegung, Intensivstation, Blutbank, Fuhrpark,
  Nachrichten/Aushänge, Berichte und Textbausteine (server + NUI)
- aether-admin: vollständiges Server-Admin-Panel als echtes FiveM-Resource,
  an aether-core gekoppelt (Rechte über aether_users.admin_group), mit
  WebRTC-Live-Kameras (Bildschirmfreigabe) und separatem Media-Server (SFU)
- Prototypen admin/ vervollständigt
- SQL-Schema aether_admin.sql
- Docker-Compose mit MariaDB und idempotenter Auto-Migration (schema_migrations)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 18:55:32 +02:00

574 lines
23 KiB
Lua

-- =====================================================================
-- 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)