diff --git a/server/chat-erwaehnung.js b/server/chat-erwaehnung.js
index c3929a1a..2e874408 100644
--- a/server/chat-erwaehnung.js
+++ b/server/chat-erwaehnung.js
@@ -58,19 +58,49 @@
Dreimal "@Anna" in einem Satz ist trotzdem eine Ansprache.
---------------------------------------------------------------------
- WAS ES BEWUSST NICHT GIBT
+ ---------------------------------------------------------------------
+ "@RUDEL" -- ALLE AUF EINMAL (23.09.2026)
- KEIN "@alle". Es wäre in fünf Minuten gebaut und ist der zuverlässigste
- Weg, dass alle die Benachrichtigungen abschalten -- und dann kommt
- auch die an, die wirklich für einen bestimmten Menschen gedacht war.
- Wenn Filipe es ausdrücklich will, gehört dazu eine Entscheidung, WER
- es benutzen darf; das ist eine Frage an ihn, keine, die ich hier
- beantworte.
+ Hier stand bis heute: „KEIN @alle. Es wäre in fünf Minuten gebaut und
+ ist der zuverlässigste Weg, dass alle die Benachrichtigungen
+ abschalten -- und dann kommt auch die an, die wirklich für einen
+ bestimmten Menschen gedacht war. Wenn Filipe es ausdrücklich will,
+ gehört dazu eine Entscheidung, WER es benutzen darf; das ist eine
+ Frage an ihn, keine, die ich hier beantworte."
+ Filipe hat sie beantwortet, und zwar genau in dem Punkt, an dem sie
+ offen war: „dan will ich auch dass nur die modis, rechte hand, linke
+ hand und dogfather, alle auch auf einmal markieren können im chat mit
+ einem, @rudel ,dann sollen alle eine benarichtigung bekommen."
+
+ DIE EINSCHRÄNKUNG IST DIE ANTWORT AUF DIE SORGE. Nicht jeder kann
+ das Rudel rufen -- nur Team Dogi und DogFather. In einem Raum, in dem
+ auch Creator und Scouts sitzen, kann ein Creator „@rudel" schreiben,
+ und es passiert nichts: kein Ruf, keine Hervorhebung, kein Eintrag.
+
+ WER DARF, ENTSCHEIDET DIESE DATEI NICHT. Sie hat keine
+ Abhängigkeiten und soll keine bekommen (siehe oben). Der Aufrufer
+ sagt es ihr: `erwaehnungenFinden(text, leute, { rudel: true })`. Im
+ Server kommt das aus `istTeamDogi(person)`, der einen Stelle des
+ Hauses dafür.
+
+ DAS SCHLÜSSELWORT GEWINNT GEGEN EINEN MENSCHEN, DER "RUDEL" HEISST.
+ Heute heißt niemand so -- aber das ist eine Tatsache von heute, kein
+ Gesetz (dieselbe Überlegung wie bei Regel 4). Andersherum wäre der
+ Schaden größer: Ein einziger Zugang mit diesem Namen würde den Ruf
+ ans ganze Team stillschweigend abschalten, und niemand fände je
+ heraus, warum. So herum kostet es nichts -- wer „Rudel" heißt, ist im
+ Rudel ohnehin dabei und bekommt seine Meldung.
+
+ ---------------------------------------------------------------------
AUFRUF (auch aus einer Prüfung heraus):
import { erwaehnungenFinden } from "./chat-erwaehnung.js";
===================================================================== */
+/** Das Wort, das alle meint. Klein geschrieben -- verglichen wird
+ * ohnehin kleingeschrieben (Regel 5). */
+export const RUDEL_WORT = "rudel";
+
/** Ein Zeichen, das einen Namen fortsetzen könnte.
*
* BUCHSTABE ODER ZIFFER, und zwar auch mit Umlaut oder Akzent:
@@ -93,13 +123,21 @@ function wortzeichenBei(text, i) {
* @param {Array<{id:number,name:string}>} leute
* Die Teilnehmer DIESES Raums. Wer nicht darin steht, kann
* nicht gemeint sein -- siehe Regel (1) oben.
- * @returns {Array<{id:number, name:string, von:number, bis:number}>}
+ * @param {{rudel?: boolean}} [wie]
+ * `rudel: true` heisst: WER DAS HIER SCHREIBT, darf das ganze
+ * Rudel rufen. Vorgabe ist `false` -- wer nichts sagt, darf es
+ * nicht. Das ist Absicht: Eine vergessene Angabe soll zu wenig
+ * koennen, nicht zu viel.
+ * @returns {Array<{id:number|null, name:string, von:number, bis:number, rudel?:boolean}>}
* Je Treffer die Person und die Stelle im Text (`von` bis
* `bis`, ohne das "@"-Zeichen gerechnet wie `slice`). Die
* Stellen braucht der Browser zum Hervorheben; der Server
* benutzt nur `id`. Sortiert nach Vorkommen im Text.
+ * Beim Rudel ist `id` null und `rudel` true -- es ist keine
+ * Person, sondern alle.
*/
-export function erwaehnungenFinden(text, leute) {
+export function erwaehnungenFinden(text, leute, wie = {}) {
+ const darfRudel = wie?.rudel === true;
const satz = String(text || "");
if (!satz.includes("@")) return [];
@@ -119,6 +157,18 @@ export function erwaehnungenFinden(text, leute) {
/* REGEL (2): Was steht vor dem "@"? */
if (wortzeichenBei(satz, i - 1)) continue;
+ /* DAS RUDEL WIRD ZUERST GEPRUEFT -- und genau das ist die
+ Entscheidung aus dem Kopfkommentar: Das Schluesselwort gewinnt
+ gegen einen Menschen, der "Rudel" heisst. Die Regeln (2), (3)
+ und (5) gelten fuer es genauso wie fuer einen Namen, sonst
+ riefe eine Mailadresse "post@rudelhaus.de" das ganze Team. */
+ if (darfRudel && klein.startsWith(RUDEL_WORT, i + 1)
+ && !wortzeichenBei(satz, i + 1 + RUDEL_WORT.length)) {
+ treffer.push({ id: null, rudel: true, name: RUDEL_WORT,
+ von: i, bis: i + 1 + RUDEL_WORT.length });
+ continue;
+ }
+
for (const person of nachLaenge) {
const name = String(person.name);
const nk = name.toLowerCase();
@@ -146,7 +196,35 @@ export function erwaehnungenFinden(text, leute) {
* ALS EIGENE FUNKTION, damit an der Aufrufstelle nicht steht
* `[...new Set(x.map(t => t.id))]`. Diese Zeile wäre an drei Orten
* gleich zu schreiben, und an einem davon irgendwann anders.
+ *
+ * DAS RUDEL STEHT HIER NICHT DRIN, und das ist Absicht. "@rudel" ist
+ * keine Liste von Namen im Satz, sondern eine Gruppe -- und WER zu ihr
+ * gehört, ist eine Frage des Hauses, nicht des Textes. Diese Datei hat
+ * keine Abhängigkeiten und kann sie nicht beantworten (sie sähe einen
+ * Raum voller `{id, name}` und wüsste nichts von Rollen). Sie sagt nur,
+ * DASS gerufen wurde -- siehe rudelGerufen() darunter.
*/
-export function erwaehnteIds(text, leute) {
- return [...new Set(erwaehnungenFinden(text, leute).map((t) => t.id))];
+export function erwaehnteIds(text, leute, wie = {}) {
+ return [...new Set(erwaehnungenFinden(text, leute, wie)
+ .filter((t) => t.id != null)
+ .map((t) => t.id))];
+}
+
+/** Wurde in diesem Text das ganze Rudel gerufen?
+ *
+ * ALS EIGENE FRAGE, weil der Server sie getrennt beantworten muss:
+ * Die Nachricht merkt sich, DASS das Rudel gerufen wurde (Spalte
+ * `rudel`), und daraus entsteht spaeter die Hervorhebung beim Lesen
+ * und der Wortlaut der Meldung. Aus der ID-Liste liesse sich das
+ * nicht ablesen -- in einem Raum zu zweit sieht "alle" genauso aus
+ * wie "einer".
+ *
+ * WEN ES DANN TRIFFT, steht in workspace-chat.js: die Leute im Raum,
+ * die zu Team Dogi gehoeren (istTeamDogi). NICHT jeder im Raum -- im
+ * Treff sitzt die Community, und "Rudel" heisst das Team. Ein Ruf,
+ * der zweihundert Zuschauer weckt, waere etwas anderes als der, den
+ * Filipe bestellt hat.
+ */
+export function rudelGerufen(text, leute, wie = {}) {
+ return erwaehnungenFinden(text, leute, wie).some((t) => t.rudel === true);
}
diff --git a/server/pruef-erwaehnung.mjs b/server/pruef-erwaehnung.mjs
index 84affaf8..3bd51faf 100644
--- a/server/pruef-erwaehnung.mjs
+++ b/server/pruef-erwaehnung.mjs
@@ -40,7 +40,7 @@ import { fileURLToPath } from "node:url";
import { request as httpAnfrage } from "node:http";
import { eigenerPort } from "./helfer-port.mjs";
import { notbremse } from "./helfer-notbremse.mjs";
-import { erwaehnungenFinden, erwaehnteIds } from "./chat-erwaehnung.js";
+import { erwaehnungenFinden, erwaehnteIds, rudelGerufen, RUDEL_WORT } from "./chat-erwaehnung.js";
const HIER = dirname(fileURLToPath(import.meta.url));
const PORT = await eigenerPort(import.meta, "pruef-erwaehnung");
@@ -50,6 +50,19 @@ process.env.WORKSPACE_DB = join(ordner, "workspace.db");
process.env.PORT = String(PORT);
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
+/* KEINE NACHTRUHE WAEHREND DER PRUEFUNG (23.09.2026).
+
+ Abschnitt 7 schreibt im Treff -- und dort gilt von 0 bis 6 Uhr
+ Schreibverbot. Eine Pruefung, die zwischen Mitternacht und sechs
+ scheitert und danach wieder gruen ist, misst die Uhr, nicht das
+ Programm. Genau diese Falle steht in den Hausregeln ("Ein Test, der
+ die Wanduhr als Annahme benutzt, misst irgendwann das Gegenteil").
+
+ Gleiche Werte heissen "keine Nachtruhe" -- so steht es in
+ istNachtruhe(), erste Zeile. Die Nachtruhe SELBST wird in
+ pruef-treffchat geprueft, dort gehoert sie hin. */
+process.env.TREFF_NACHT_AB = "0";
+process.env.TREFF_NACHT_BIS = "0";
const express = (await import("express")).default;
const ec = express.response.cookie;
@@ -141,6 +154,73 @@ melde("\n=== 1. Wer ist gemeint? ===");
immer jemanden findet, findet nichts. */
ok(erwaehnteIds("@VanVan", []).length === 0,
"Gegenprobe: ohne Teilnehmerliste ist niemand gemeint");
+
+ /* ---- "@RUDEL" — ALLE AUF EINMAL (23.09.2026) ------------------
+
+ Filipe: „dan will ich auch dass nur die modis, rechte hand, linke
+ hand und dogfather, alle auch auf einmal markieren können im chat
+ mit einem, @rudel ,dann sollen alle eine benarichtigung bekommen."
+
+ DIE WICHTIGSTE PRÜFUNG HIER IST DIE ZWEITE: dass es NICHTS tut,
+ wenn der Schreibende nicht darf. Sonst hätte die Einschränkung,
+ die Filipe ausdrücklich genannt hat, nur im Kommentar gestanden.
+
+ Die Grenzregeln (2) und (3) gelten für das Schlüsselwort genauso
+ wie für einen Namen — sonst ruft eine Mailadresse das ganze Team. */
+ const rudelFaelle = [
+ ["@rudel alle mal herhören", true, true, "der einfache Fall"],
+ ["@RUDEL", true, true, "gross geschrieben trifft auch"],
+ ["@rudel alle mal herhören", false, false,
+ "wer nicht darf, ruft nichts — und merkt nichts davon"],
+ ["schreib an post@rudelhaus.de", true, false,
+ "eine Mailadresse ruft das Rudel nicht"],
+ ["@rudeltier", true, false, "ein längeres Wort ist kein Ruf"],
+ ["das rudel ohne at", true, false, "ohne @ ist es nur ein Wort"],
+ ["(@rudel)", true, true, "in Klammern"],
+ ["@Tili und @rudel", true, true, "neben einem Namen"],
+ ];
+ let rudelStimmt = 0;
+ for (const [text, darf, soll, was] of rudelFaelle) {
+ const ist = rudelGerufen(text, leute, { rudel: darf });
+ if (ist === soll) rudelStimmt++;
+ ok(ist === soll, `@rudel: ${was}${ist === soll ? "" : ` (${JSON.stringify(text)}, darf=${darf} -> ${ist})`}`);
+ }
+ ok(rudelStimmt === rudelFaelle.length && rudelFaelle.length >= 8,
+ `alle ${rudelFaelle.length} Rudel-Fälle stimmen`);
+
+ /* OHNE ANGABE DARF MAN NICHT. Eine vergessene Angabe soll zu wenig
+ können, nicht zu viel — sonst ruft der erste Aufruf, der die
+ Option nicht kennt, aus Versehen das ganze Team. */
+ ok(rudelGerufen("@rudel", leute) === false,
+ "ohne Angabe gilt: darf nicht (eine vergessene Angabe kann zu wenig, nie zu viel)");
+
+ /* NEBEN EINEM NAMEN BLEIBT DER NAME. "@Tili und @rudel" ist beides. */
+ ok(JSON.stringify(erwaehnteIds("@Tili und @rudel", leute, { rudel: true })) === "[3]",
+ "neben dem Ruf bleibt der einzeln Genannte einzeln genannt");
+
+ /* DAS SCHLÜSSELWORT GEWINNT GEGEN EINEN MENSCHEN NAMENS "RUDEL".
+ Heute heisst niemand so — aber das ist eine Tatsache von heute.
+ So herum verliert niemand eine Meldung: Wer "Rudel" heisst, ist
+ im Rudel ohnehin dabei. Andersherum wäre der Ruf ans Team durch
+ einen einzigen Zugang stillschweigend abgeschaltet. */
+ const mitRudel = [{ id: 20, name: "Rudel" }, { id: 21, name: "Anna" }];
+ ok(rudelGerufen("@Rudel hallo", mitRudel, { rudel: true }) === true,
+ "heisst jemand „Rudel“, gewinnt trotzdem das Schlüsselwort");
+ ok(JSON.stringify(erwaehnteIds("@Rudel hallo", mitRudel, { rudel: false })) === "[20]",
+ " und wer nicht rufen darf, spricht damit genau diesen Menschen an");
+
+ /* DAS WORT STEHT AN EINER STELLE. Stünde es zweimal, hiesse es nach
+ einer Umbenennung an einer davon anders. */
+ ok(RUDEL_WORT === "rudel", `das Wort kommt aus der Datei („${RUDEL_WORT}“)`);
+
+ /* GEGENPROBE: Die Auflösung liefert beim Rudel KEINE Personennummer
+ — es ist keine Person. Wer daraus eine ID läse, bekäme `null` in
+ die Empfängerliste und benachrichtigte niemanden. */
+ const rt = erwaehnungenFinden("@rudel", leute, { rudel: true });
+ ok(rt.length === 1 && rt[0].rudel === true && rt[0].id === null,
+ "der Ruf ist keine Person (id null, rudel true)");
+ ok(erwaehnteIds("@rudel", leute, { rudel: true }).length === 0,
+ " und taucht deshalb in der Namensliste gar nicht auf");
}
/* =====================================================================
@@ -391,6 +471,22 @@ melde("\n=== 4. Die Meldung sagt, dass DU gemeint bist ===");
ok(art?.vorgabe === true, " und ist von sich aus an");
ok(ARTEN.find((a) => a.schluessel === "chat_nachricht")?.schluessel !== art?.schluessel,
" getrennt von der gewöhnlichen Chatnachricht — zwei Schalter, zwei Entscheidungen");
+
+ /* ---- UND BEIM RUDEL SAGT SIE, DASS ALLE GEMEINT SIND ----------
+
+ Filipe: „dann sollen alle eine benarichtigung bekommen."
+
+ WARUM NICHT DERSELBE SATZ WIE BEI DER ERWÄHNUNG: Weil er nicht
+ stimmt. Beim Rudel ist man mitgemeint, nicht gemeint — und wer
+ dreimal „hat dich erwähnt" liest und jedes Mal merkt, dass es
+ alle betraf, glaubt beim vierten Mal auch dem echten nicht mehr. */
+ const gerufen = pushTextFuer("rudel", n, raumId);
+ ok(/Rudel gerufen/.test(gerufen.titel), `-> ${gerufen.titel}`);
+ ok(gerufen.titel !== erw.titel && gerufen.titel !== normal.titel,
+ " und unterscheidet sich von BEIDEN anderen Meldungen");
+ ok(gerufen.text.includes("schaust du mal"),
+ " der Anfang der Nachricht steht auch hier darunter");
+ ok(gerufen.ziel === normal.ziel, " und führt in dasselbe Gespräch");
}
/* =====================================================================
@@ -451,6 +547,43 @@ melde("\n=== 5. Server und Browser rechnen gleich ===");
}
ok(gleich === proben.length && proben.length >= 10,
`beide kommen bei allen ${proben.length} Proben auf dasselbe Ergebnis`);
+
+ /* ---- UND DASSELBE MIT "@RUDEL" (23.09.2026) ----------------
+
+ DER DRITTE PARAMETER MUSS MITGEPRUEFT WERDEN, sonst waere er die
+ Stelle, an der die beiden Fassungen auseinanderlaufen -- und
+ zwar unbemerkt, weil die elf Proben darueber ihn gar nicht
+ benutzen. Beide Wahrheitswerte, damit auch das Nichtstun
+ verglichen wird. */
+ const rudelProben = [
+ "@rudel alle mal herhoeren", "@RUDEL", "post@rudelhaus.de",
+ "@rudeltier", "(@rudel)", "@Tili und @rudel", "kein at rudel",
+ ];
+ let rGleich = 0, rVerglichen = 0;
+ for (const t of rudelProben) {
+ for (const darf of [true, false]) {
+ rVerglichen++;
+ const a = JSON.stringify(erwaehnungenFinden(t, leute, { rudel: darf }));
+ const b = JSON.stringify(browserFassung(t, leute, { rudel: darf }));
+ if (a === b) rGleich++;
+ else {
+ console.log(` ungleich bei ${JSON.stringify(t)} (darf=${darf})`);
+ console.log(` Server: ${a}`);
+ console.log(` Browser: ${b}`);
+ }
+ }
+ }
+ ok(rGleich === rVerglichen && rVerglichen >= 14,
+ `auch beim Rudel dasselbe Ergebnis (${rVerglichen} Proben, beide Rechte)`);
+
+ /* GEGENPROBE DES VERGLEICHS: Er muss einen Unterschied auch SEHEN.
+ Ohne das waere er eine Schleife, die immer „gleich“ sagt -- und
+ genau so eine stand am 01.09. gruen da, ohne je etwas anzusehen. */
+ const ungleichErkannt =
+ JSON.stringify(erwaehnungenFinden("@rudel", leute, { rudel: true }))
+ !== JSON.stringify(browserFassung("@rudel", leute, { rudel: false }));
+ ok(ungleichErkannt,
+ "Gegenprobe: bei verschiedenen Rechten kommt Verschiedenes heraus — der Vergleich kann Unterschiede sehen");
}
}
@@ -467,6 +600,39 @@ melde("\n=== 5. Server und Browser rechnen gleich ===");
liegt, wenn Enter statt zu wählen die halbe Nachricht abschickt,
oder wenn der Name am Ende grau bleibt wie jedes andere Wort.
===================================================================== */
+/* EIN RAUM, IN DEM DER RUF ETWAS BEWIRKT (23.09.2026).
+
+ Fuer den Bildschirmteil wird ein Gespraech gebraucht, in dem ausser
+ mir noch jemand aus dem Team sitzt -- sonst bietet die Liste den Ruf
+ gar nicht erst an (und das ist richtig so: ein Vorschlag, der
+ niemanden erreicht, ist ein Vorschlag, der nichts tut).
+
+ ZWEI ZUGAENGE MIT DER DOGFATHER-ROLLE, und zwar bewusst statt eines
+ Modis: Der Browser dieser Pruefung laeuft auf 127.0.0.1, und dort
+ steht die Agentur-Wand -- die Rolle "modi" gibt es auf ihr nicht
+ (siehe der Kommentar ganz oben). Wer hier das Team braucht, nimmt
+ die einzige Team-Rolle, die auf beiden Waenden vorkommt. */
+let raumTeam = null;
+{
+ const d3 = new DatabaseSync(process.env.WORKSPACE_DB);
+ const sch = d3.prepare("SELECT wert FROM einstellungen WHERE schluessel = ?")
+ .get("code_kennung_schluessel").wert;
+ const salt = randomBytes(16).toString("hex");
+ const hash = scryptSync("CODE-DOGI-0002", salt, 64,
+ { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
+ d3.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)"
+ + " VALUES (?,?,?,?,?,?,1,?)")
+ .run("Zweiter", "admin", hash, salt, 32768,
+ createHmac("sha256", sch).update("CODE-DOGI-0002").digest("hex"), jetzt);
+ const idZwei = d3.prepare("SELECT last_insert_rowid() AS id").get().id;
+ d3.close();
+ const g = await ruf("/workspace/api/chat/gruppe", aDogi.keks, "POST",
+ { name: "Team intern", mit: [idZwei] });
+ raumTeam = json(g).raum_id ?? null;
+ ok(Number.isInteger(raumTeam),
+ `ein Raum, in dem noch jemand aus dem Team sitzt (Raum ${raumTeam})`);
+}
+
melde("\n=== 6. Am echten Bildschirm ===");
const BASIS = `http://127.0.0.1:${PORT}`;
const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
@@ -631,12 +797,270 @@ try {
const jetztRaus = await s.locator(".chat-nachricht").count();
ok(jetztRaus > vorher + 1, ` und Enter schickt wieder ab (${jetztRaus}, vorher ${vorher})`);
+ /* ---- "@RUDEL" AM BILDSCHIRM (23.09.2026) -----------------------
+
+ Alles bisher hat bewiesen, dass der Server den Richtigen ruft.
+ Hier geht es um die andere Haelfte: Findet man es, und sieht man
+ es? Ein Ruf, den niemand vorgeschlagen bekommt und der im Satz
+ grau bleibt wie jedes andere Wort, ist eine Attrappe. */
+ if (Number.isInteger(raumTeam)) {
+ await s.goto(`${BASIS}/workspace/chat.html?raum=${raumTeam}`, { waitUntil: "networkidle" });
+ await s.waitForTimeout(1200);
+ await s.click("#text");
+ await s.type("#text", "@", { delay: 40 });
+ await s.waitForTimeout(400);
+ const eintraege = await liste.locator(".erwaehn-liste__eintrag").count();
+ const ersterName = await liste.locator(".erwaehn-liste__name").first().innerText();
+ const ersterZusatz = await liste.locator(".erwaehn-liste__rolle").first().innerText();
+ ok(eintraege >= 2 && ersterName === "rudel",
+ `der Ruf steht ganz oben in der Liste (${eintraege} Eintraege, erster: ${ersterName})`);
+ /* WAS ES BEDEUTET, MUSS DANEBENSTEHEN. "rudel" allein sagt keinem
+ Neuen, dass damit das ganze Team gemeint ist -- und wer das erst
+ nach dem Absenden erfaehrt, hat schon alle geweckt. */
+ ok(/alle im Team/i.test(ersterZusatz),
+ ` und daneben steht, was es bedeutet ("${ersterZusatz}")`);
+ const alsPerson = await liste.locator('.erwaehn-liste__eintrag[data-rudel="ja"]').count();
+ ok(alsPerson === 1, ` genau einmal, als eigene Sorte Eintrag (${alsPerson})`);
+
+ await s.keyboard.press("Enter");
+ await s.waitForTimeout(300);
+ ok((await s.inputValue("#text")) === "@rudel ",
+ `Enter setzt den Ruf ein: ${JSON.stringify(await s.inputValue("#text"))}`);
+ await s.type("#text", "bitte alle kurz schauen", { delay: 10 });
+ await s.keyboard.press("Enter");
+ await s.waitForTimeout(1500);
+
+ const marke = await s.evaluate(() => {
+ const m = document.querySelector('.chat-erwaehnung[data-rudel="ja"]');
+ if (!m) return null;
+ const st = getComputedStyle(m);
+ const normal = document.querySelector(".chat-nachricht__text");
+ return {
+ wort: m.textContent.trim(),
+ gewicht: st.fontWeight,
+ schatten: st.boxShadow !== "none",
+ eigen: m.dataset.ich === "ja",
+ farbeGleichText: st.color === getComputedStyle(normal).color,
+ };
+ });
+ ok(!!marke, "der Ruf ist im Satz hervorgehoben");
+ /* Das Zeichen steht per Stilvorlage davor (::before) und zaehlt
+ nicht zum Textinhalt -- geprueft wird deshalb das Wort. */
+ ok(marke?.wort === "@rudel", ` und zwar genau: ${marke?.wort}`);
+ /* NICHT NUR DIE FARBE. Wer Farben schlecht unterscheidet, muss es
+ trotzdem sehen -- dieselbe Regel wie beim eigenen Namen. */
+ ok(Number(marke?.gewicht) >= 600 && marke?.schatten === true,
+ ` erkennbar an Gewicht (${marke?.gewicht}) und Rahmen (${marke?.schatten}), nicht nur an der Farbe`);
+ /* GEGENPROBE: Es ist NICHT die Marke fuer "du bist gemeint". Saehen
+ beide gleich aus, verloere die persoenliche ihre Wirkung. */
+ ok(marke?.eigen === false,
+ " und es ist nicht die Marke fuer den eigenen Namen — alle gemeint ist etwas anderes als du gemeint");
+ } else {
+ ok(false, "ohne Team-Raum ist der Ruf am Bildschirm nicht messbar");
+ }
+
await dogi.ctx.close();
await van.ctx.close();
} finally {
await browser.close();
}
+/* =====================================================================
+ 7. "@RUDEL" DURCH DIE SCHNITTSTELLE (23.09.2026)
+
+ Filipe: „dan will ich auch dass nur die modis, rechte hand, linke
+ hand und dogfather, alle auch auf einmal markieren können im chat
+ mit einem, @rudel ,dann sollen alle eine benarichtigung bekommen."
+
+ ALLES BISHER WAR RECHNEN OHNE SERVER. Hier geht es durch die
+ richtige Tür.
+
+ GEPRÜFT WIRD IM TREFF, UND DAS IST KEINE BEQUEMLICHKEIT. Der Treff
+ ist der einzige Raum des Hauses, in dem Team Dogi und Menschen
+ sitzen, die NICHT dazugehören (Rolle „gast" -- die Community). In
+ einem gewöhnlichen Gespräch geht das gar nicht: Die Häusertrennung
+ lässt einen Scout und einen Modi nicht in denselben Raum, das ist
+ beim Bauen dieser Prüfung gemessen worden (403, „Mit mindestens
+ einer Person kannst du nicht schreiben").
+
+ Damit ist der Treff die einzige Stelle, an der der Unterschied
+ zwischen „alle im Raum" und „alle im Team" überhaupt sichtbar wird
+ -- und zugleich die, an der die falsche Lesart wehtäte: Sie hätte
+ jeden Zuschauer geweckt.
+
+ ZWEI FRAGEN, UND DIE ZWEITE IST DIE WICHTIGERE:
+ 1. Ruft der Modi wirklich sein Team?
+ 2. Ruft ein Gast wirklich NICHTS? -- Ohne diese Prüfung stünde
+ Filipes „nur die modis, rechte hand, linke hand und dogfather"
+ bloss im Kommentar.
+ ===================================================================== */
+melde("\n" + "=== 7. Der Ruf ans Rudel geht durch die Tür ===");
+{
+ /* Die Anmeldewand entscheidet am HOST, in welchem Haus sie steht.
+ Team Dogi gibt es nur auf crew.… -- ohne diesen Kopf käme jede
+ Anfrage auf der Agenturseite an, und die Rolle "modi" gibt es
+ dort gar nicht. `fetch` verwirft den Host-Kopf, deshalb node:http. */
+ const CREW = "crew.dogfather-universe.com";
+ function crewRuf(pfad, keks, methode = "GET", koerper = null) {
+ return new Promise((fertig, schief) => {
+ const daten = koerper === null ? null : Buffer.from(JSON.stringify(koerper));
+ const a = httpAnfrage({
+ host: "127.0.0.1", port: PORT, path: pfad, method: methode,
+ headers: {
+ Host: CREW, Origin: `https://${CREW}`, Referer: `https://${CREW}/workspace/`,
+ ...(daten ? { "Content-Type": "application/json", "Content-Length": daten.length } : {}),
+ ...(keks ? { Cookie: keks } : {}),
+ },
+ }, (antwort) => {
+ const st = [];
+ antwort.on("data", (x) => st.push(x));
+ antwort.on("end", () => {
+ const text = Buffer.concat(st).toString("utf8");
+ let j = null; try { j = JSON.parse(text); } catch { /* kein JSON */ }
+ fertig({
+ code: antwort.statusCode, json: j, text,
+ keks: [].concat(antwort.headers["set-cookie"] || [])
+ .map((c) => c.split(";")[0]).join("; "),
+ });
+ });
+ });
+ a.on("error", schief);
+ if (daten) a.write(daten);
+ a.end();
+ });
+ }
+
+ /* Drei weitere Zugänge: ein Modi, die linke Hand und ein Gast. */
+ const d2 = new DatabaseSync(process.env.WORKSPACE_DB);
+ const schluessel2 = d2.prepare(
+ "SELECT wert FROM einstellungen WHERE schluessel = ?").get("code_kennung_schluessel").wert;
+ const anlegen2 = (name, rolle, code) => {
+ const salt = randomBytes(16).toString("hex");
+ const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
+ d2.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)"
+ + " VALUES (?,?,?,?,?,?,1,?)")
+ .run(name, rolle, hash, salt, 32768,
+ createHmac("sha256", schluessel2).update(code).digest("hex"), jetzt);
+ return d2.prepare("SELECT last_insert_rowid() AS id").get().id;
+ };
+ const idModi = anlegen2("Frida", "modi", "CODE-MODI-0001");
+ const idLinke = anlegen2("Lino", "linke", "CODE-LINK-0001");
+ const idGast = anlegen2("Zuschauer Zoe", "gast", "CODE-GAST-0001");
+ d2.close();
+
+ const aModi = await crewRuf("/workspace/api/anmelden", null, "POST",
+ { rolle: "modi", code: "CODE-MODI-0001" });
+ const aLinke = await crewRuf("/workspace/api/anmelden", null, "POST",
+ { rolle: "linke", code: "CODE-LINK-0001" });
+ /* EIN GAST BESTAETIGT SEIN ALTER. Ohne `alter_ok` kommt 400 --
+ gemessen, nicht vermutet; der erste Anlauf dieser Pruefung ist
+ genau daran haengengeblieben. Das Feld gilt NUR fuer die
+ Community-Kachel, fuer alle anderen Rollen verlangt der Server es
+ gar nicht erst. Dieselbe Stelle steht in pruef-treff. */
+ const aGast = await crewRuf("/workspace/api/anmelden", null, "POST",
+ { rolle: "gast", code: "CODE-GAST-0001", alter_ok: true });
+ ok(!!aModi.keks && !!aLinke.keks && !!aGast.keks,
+ `Modi, linke Hand und ein Gast sind angemeldet (${aModi.code}/${aLinke.code}/${aGast.code})`);
+
+ /* DER TREFF FUELLT SICH, WENN MAN IHN AUFMACHT. treffAngleichen()
+ laeuft beim Abrufen der Gespraechsliste -- also einmal je Zugang. */
+ for (const k of [aModi.keks, aLinke.keks, aGast.keks]) {
+ await crewRuf("/workspace/api/chat/raeume", k);
+ }
+
+ const drin = new DatabaseSync(process.env.WORKSPACE_DB);
+ const treff = drin.prepare(
+ "SELECT id FROM chat_raeume WHERE art = 'kanal' AND kategorie = ?").get("treff")?.id;
+ /* DRITTER AUSGANG: Gibt es den Treff nicht, steht hier warum --
+ statt dass die folgenden Pruefungen an `undefined` arbeiten und
+ Fehler melden, die mit dem Rudel nichts zu tun haben. */
+ ok(Number.isInteger(treff), `der Treff steht (Raum ${treff})`);
+
+ const besetzung = Number.isInteger(treff) ? drin.prepare(
+ `SELECT t.person_id AS id, p.rolle FROM chat_teilnehmer t
+ JOIN personen p ON p.id = t.person_id
+ WHERE t.raum_id = ? AND t.raus_am IS NULL`).all(treff) : [];
+ const gaeste = besetzung.filter((x) => x.rolle === "gast").map((x) => x.id);
+ /* DIE ZAHL GEHOERT IN DIE BEDINGUNG. Saesse kein Gast im Treff,
+ waere die Gegenprobe weiter unten leer und trotzdem gruen --
+ genau der Fehler, der am 01.09. dreimal gruen dastand. */
+ ok(gaeste.includes(idGast) && besetzung.length >= 3,
+ `im Treff sitzen ${besetzung.length} Leute, darunter ein Gast`);
+
+ /* WER ERWARTET WIRD, WIRD ABGELEITET -- nicht abgeschrieben. Eine
+ feste Liste hier waere beim naechsten Rollenzuschnitt still
+ falsch, und die Pruefung haette den Fehler mitgemacht. */
+ const TEAM = new Set(["admin", "hand", "linke", "modi"]);
+ const erwartet = besetzung
+ .filter((x) => TEAM.has(x.rolle) && x.id !== idModi)
+ .map((x) => x.id).sort((x, y) => x - y);
+
+ /* ---- 1. DER MODI RUFT ------------------------------------------ */
+ const ruf1 = await crewRuf(`/workspace/api/chat/raeume/${treff}/nachrichten`,
+ aModi.keks, "POST", { text: "@rudel bitte alle kurz herschauen" });
+ ok(ruf1.code === 201, `der Modi darf im Treff schreiben (${ruf1.code} ${ruf1.code === 201 ? "" : ruf1.text.slice(0, 90)})`);
+ ok(ruf1.json?.nachricht?.rudel === true,
+ " und die Nachricht weiss, dass sie das Rudel gerufen hat");
+
+ const nrId = ruf1.json?.nachricht?.id;
+ const gemeint = drin.prepare(
+ "SELECT person_id FROM chat_erwaehnungen WHERE nachricht_id = ? ORDER BY person_id")
+ .all(nrId).map((x) => x.person_id).sort((x, y) => x - y);
+ ok(gemeint.length > 0 && JSON.stringify(gemeint) === JSON.stringify(erwartet),
+ `gerufen wird genau das Team im Raum (${JSON.stringify(gemeint)}, erwartet ${JSON.stringify(erwartet)})`);
+ /* DIE EIGENTLICHE AUSSAGE. "Rudel" heisst das Team -- nicht jeder im
+ Raum. Im Treff sitzt die Community; ein Ruf, der dort jeden
+ Zuschauer weckt, waere etwas anderes als der bestellte. */
+ ok(!gemeint.includes(idGast),
+ " der Gast im selben Raum aber NICHT — das Rudel ist das Team, nicht der Raum");
+ ok(!gemeint.includes(idModi),
+ " und der Rufer selbst auch nicht — man ruft nicht sich");
+ ok(gemeint.includes(idLinke),
+ " die linke Hand ist dabei — sie gehoert zum Team");
+
+ /* Die Spalte bleibt stehen -- sie ist es, aus der beim Lesen die
+ Hervorhebung entsteht. */
+ const gespeichert = drin.prepare(
+ "SELECT rudel FROM chat_nachrichten WHERE id = ?").get(nrId)?.rudel;
+ ok(gespeichert === 1, `und es steht an der Nachricht (rudel = ${gespeichert})`);
+
+ const verlauf = await crewRuf(`/workspace/api/chat/raeume/${treff}/nachrichten`, aModi.keks);
+ const geladen = (verlauf.json?.nachrichten || []).find((x) => x.id === nrId);
+ ok(geladen?.rudel === true, "der Verlauf bringt es mit — auch nach dem Neuladen");
+ ok(verlauf.json?.darf_rudel === true, "und der Modi bekommt gesagt, dass er rufen darf");
+ ok(verlauf.json?.rudel_wort === RUDEL_WORT,
+ `das Wort kommt vom Server, nicht aus dem Browser (${verlauf.json?.rudel_wort})`);
+
+ /* ---- 2. GEGENPROBE: DER GAST RUFT INS LEERE --------------------- */
+ const ruf2 = await crewRuf(`/workspace/api/chat/raeume/${treff}/nachrichten`,
+ aGast.keks, "POST", { text: "@rudel und ihr alle mal" });
+ ok(ruf2.code === 201, `der Gast darf im Treff schreiben wie alle (${ruf2.code})`);
+ const nr2 = ruf2.json?.nachricht?.id;
+ ok(ruf2.json?.nachricht?.rudel !== true, " aber sein @rudel ruft niemanden");
+ const gemeint2 = drin.prepare(
+ "SELECT person_id FROM chat_erwaehnungen WHERE nachricht_id = ?").all(nr2);
+ ok(gemeint2.length === 0,
+ ` kein einziger Eintrag (${gemeint2.length}) — nur Team Dogi und DogFather duerfen rufen`);
+ const gespeichert2 = drin.prepare(
+ "SELECT rudel FROM chat_nachrichten WHERE id = ?").get(nr2)?.rudel;
+ ok(!gespeichert2, ` und an der Nachricht steht nichts (rudel = ${gespeichert2})`);
+
+ const verlauf2 = await crewRuf(`/workspace/api/chat/raeume/${treff}/nachrichten`, aGast.keks);
+ ok(verlauf2.json?.darf_rudel === false,
+ "dem Gast sagt der Server, dass er nicht rufen darf — der Vorschlag erscheint gar nicht erst");
+
+ /* ---- 3. EIN NAME WIRKT WEITERHIN FUER ALLE ---------------------- */
+ const ruf3 = await crewRuf(`/workspace/api/chat/raeume/${treff}/nachrichten`,
+ aGast.keks, "POST", { text: "@Frida schaust du mal?" });
+ const gemeint3 = drin.prepare(
+ "SELECT person_id FROM chat_erwaehnungen WHERE nachricht_id = ?")
+ .all(ruf3.json?.nachricht?.id).map((x) => x.person_id);
+ ok(gemeint3.length === 1 && gemeint3[0] === idModi,
+ "wer nicht rufen darf, kann trotzdem einzeln ansprechen — das bleibt unberuehrt");
+
+ drin.close();
+}
+
console.log(`\n${fehler ? fehler + " FEHLER" : "Alles in Ordnung"} — ${geprueft} Prüfungen\n`);
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
process.exit(fehler ? 1 : 0);
diff --git a/server/workspace-chat.js b/server/workspace-chat.js
index a7d252e5..7f5af58c 100644
--- a/server/workspace-chat.js
+++ b/server/workspace-chat.js
@@ -45,9 +45,10 @@ import {
db, protokolliere, echteIp, sitzungLesen, istDogFather,
schreibbareIds, darfSchreibenMit, ROLLEN_SORTIERUNG, DATEN_ORDNER,
fuehrtTeamDogi, MODI_KATEGORIEN, ROLLEN_GRUPPE, TEAM_DOGI_ROLLEN,
+ istTeamDogi,
} from "./workspace.js";
-import { erwaehnteIds } from "./chat-erwaehnung.js";
+import { erwaehnteIds, rudelGerufen, RUDEL_WORT } from "./chat-erwaehnung.js";
import { KATALOG, ERLAUBT, VORSCHLAG, FAVORITEN_MAX } from "./workspace-reaktionen.js";
/* Der Treff-Chat: eigener Raum, eigene Nachtruhe, keine Anrufe.
@@ -1296,7 +1297,7 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => {
const reihen = db().prepare(`
SELECT n.id, n.person_id, n.text, n.erstellt, n.weg_am, p.name AS von, p.rolle,
p.bild AS von_bild, p.chat_kachel AS von_kachel,
- n.antwort_auf, n.angeheftet_am,
+ n.antwort_auf, n.angeheftet_am, n.rudel,
n.anhang_name, n.anhang_art, n.anhang_typ, n.anhang_groesse,
n.anhang_breite, n.anhang_hoehe,
a.text AS zitat_text, a.weg_am AS zitat_weg, ap.name AS zitat_von,
@@ -1368,6 +1369,31 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => {
waere eine Auskunft ueber das Haus an jeden Leser. Und es
waere eine zweite Wahrheit neben der Schranke im Server. */
darf_alles_loeschen: darfJedeNachrichtLoeschen(req.person),
+ /* DARF ICH DAS RUDEL RUFEN? (23.09.2026)
+
+ Vom Server, aus derselben Funktion wie die Schranke beim
+ Schreiben (istTeamDogi). Die Oberflaeche koennte
+ `ich.rolle === 'modi' || ...` vergleichen -- das waere eine
+ zweite Wahrheit, und assets/js/chat.js bekommt jeder, der die
+ Seite oeffnet. Ein Ja/Nein verraet nichts ueber das Haus.
+
+ DAS WORT KOMMT MIT. Stuende "rudel" im Browser fest, hiesse es
+ nach einer Umbenennung an einer Stelle anders als an der
+ anderen -- und der Vorschlag beim Tippen setzte etwas ein, das
+ der Server nicht mehr erkennt. */
+ /* UND OB ER HIER ueberhaupt jemanden erreichen wuerde. In einem
+ Zweier-Gespraech mit einem Creator ist ausser mir niemand aus
+ dem Team -- der Vorschlag "rudel, alle im Team" waere dort ein
+ Eintrag, der nichts tut. Dieselbe Ueberlegung wie beim eigenen
+ Namen, den die Liste auch nicht anbietet.
+
+ DIE SCHRANKE BEIM SCHREIBEN HAENGT NICHT DARAN. Wer den Ruf
+ von Hand tippt, ruft eben niemanden -- genau wie ein "@Anna",
+ das Anna nicht beantwortet. Verboten ist es nicht, es ist nur
+ keine Hilfe, es vorzuschlagen. */
+ darf_rudel: istTeamDogi(req.person)
+ && leute.some((t) => t.id !== req.person.id && istTeamDogi(t)),
+ rudel_wort: RUDEL_WORT,
angeheftet: angeheftet.map((a) => ({
id: a.id,
von: a.von || "Gelöscht",
@@ -1405,6 +1431,10 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => {
erstellt: n.erstellt,
selbst: n.person_id === req.person.id,
angeheftet: !!n.angeheftet_am,
+ /* WURDE HIER DAS RUDEL GERUFEN? Steht an der Nachricht, weil
+ der Browser sonst die damalige Rolle des Absenders kennen
+ muesste -- siehe die Spalte in workspace.js. */
+ rudel: !!n.rudel,
/* Das Zitat wird GEKUERZT geschickt, nicht ganz: Es ist ein
Hinweis auf die Stelle, nicht ihre Wiederholung. Und eine
zurueckgenommene Nachricht bleibt auch im Zitat
@@ -1530,6 +1560,11 @@ chatRouter.post("/workspace/api/chat/raeume/:id/nachrichten", gleicheHerkunft,
zurueckgenommen: !!zitat.weg_am,
} : null,
anhang: null,
+ /* Wird gleich gesetzt, wenn das Rudel gerufen wurde. Steht
+ hier schon drin, damit das Feld in JEDER Nachricht vorkommt
+ -- eine Eigenschaft, die mal da ist und mal nicht, ist die,
+ die im Browser irgendwann `undefined` heisst. */
+ rudel: false,
};
/* ==== WER IST MIT "@" GEMEINT? (20.09.2026) =====================
@@ -1548,17 +1583,73 @@ chatRouter.post("/workspace/api/chat/raeume/:id/nachrichten", gleicheHerkunft,
Die Regeln stehen in chat-erwaehnung.js, samt Begruendung je
Regel. Sie sind bewusst ohne Abhaengigkeiten, damit die
Pruefung sie lesen kann, ohne einen Server hochzufahren. */
+ /* ==== "@RUDEL" -- ALLE AUF EINMAL (23.09.2026) =================
+
+ Filipe: „dan will ich auch dass nur die modis, rechte hand,
+ linke hand und dogfather, alle auch auf einmal markieren
+ koennen im chat mit einem, @rudel ,dann sollen alle eine
+ benarichtigung bekommen."
+
+ WER DARF, WIRD HIER ENTSCHIEDEN -- nicht im Browser und nicht
+ in chat-erwaehnung.js. Der Browser schickt nur Text; die Regeln
+ haben keine Abhaengigkeiten und sollen keine bekommen. Bleibt
+ diese Stelle, und sie fragt die eine Funktion des Hauses:
+ istTeamDogi(). Schreibt ein Creator "@rudel", passiert nichts
+ -- kein Ruf, kein Eintrag, keine Hervorhebung. */
+ const darfRudel = istTeamDogi(req.person);
const alleLeute = teilnehmerVon(raumId);
- const erwaehnt = erwaehnteIds(text, alleLeute)
+ const rudel = rudelGerufen(text, alleLeute, { rudel: darfRudel });
+
+ /* WEN DER RUF ERREICHT: die Leute IN DIESEM RAUM, die zu Team
+ Dogi gehoeren -- nicht jeden im Raum.
+
+ Das Wort sagt es selbst. Ein Rudel ist das Team, und Filipe
+ nennt in einem Atemzug dieselben vier Gruppen, die es auch
+ rufen duerfen. In einem reinen Team-Kanal ist das jeder
+ Anwesende, also genau "alle auf einmal".
+
+ DER UNTERSCHIED WIRD IM TREFF SICHTBAR, und dort haette die
+ andere Lesart wehgetan: Dort sitzt die Community. "@rudel"
+ haette zweihundert Zuschauer gleichzeitig geweckt -- und beim
+ zweiten Mal haetten sie die Benachrichtigungen abgeschaltet.
+ Genau davor warnt der erste Absatz dieser Datei seit dem
+ 20.09. Ein Moderator, der im Treff "@rudel" schreibt, erreicht
+ damit seine Kollegen; die Zuschauer lesen es einfach mit. */
+ const ausDemRudel = rudel
+ ? alleLeute.filter((t) => istTeamDogi(t)).map((t) => t.id)
+ : [];
+
+ const erwaehnt = [...new Set([
+ ...erwaehnteIds(text, alleLeute, { rudel: darfRudel }),
+ ...ausDemRudel,
+ ])]
/* SICH SELBST ANSPRECHEN LOEST NICHTS AUS. Man schreibt den
eigenen Namen durchaus ("@Anna hat recht, ich auch") -- eine
- Meldung an sich selbst waere Unsinn. */
+ Meldung an sich selbst waere Unsinn. Beim Rudel genauso: Wer
+ das Team ruft, ruft nicht sich. */
.filter((pid) => pid !== req.person.id);
if (erwaehnt.length) {
const einf = d.prepare(
"INSERT OR IGNORE INTO chat_erwaehnungen (nachricht_id, person_id) VALUES (?,?)");
for (const pid of erwaehnt) einf.run(id, pid);
}
+ if (rudel) {
+ /* DIE NACHRICHT MERKT SICH, DASS DAS RUDEL GERUFEN WURDE.
+ Daraus entsteht beim Lesen die Hervorhebung -- ohne dass der
+ Browser die Rolle des Absenders von damals nachschlagen
+ muesste. Begruendung steht bei der Spalte in workspace.js. */
+ d.prepare("UPDATE chat_nachrichten SET rudel = 1 WHERE id = ?").run(id);
+ nachricht.rudel = true;
+ /* INS PROTOKOLL. Ein Ruf ans ganze Team ist der eine Handgriff
+ im Chat, der alle gleichzeitig unterbricht. Wer ihn benutzt,
+ soll das nicht heimlich tun koennen -- nicht als Misstrauen,
+ sondern weil "@rudel wird zu oft benutzt" sonst ein Gefuehl
+ bleibt statt einer Zahl. */
+ protokolliere("chat_rudel", {
+ personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
+ detail: `#${id} an ${erwaehnt.length} im Raum ${raumId}`.slice(0, 120),
+ });
+ }
/* Sofort an alle, die gerade zusehen -- und per Benachrichtigung
an die, die es nicht tun. Beides läuft über chatEreignis(),
@@ -1570,7 +1661,7 @@ chatRouter.post("/workspace/api/chat/raeume/:id/nachrichten", gleicheHerkunft,
abschaltet. */
const empfaenger = alleLeute.filter((t) => t.id !== req.person.id);
chatEreignis(raumId, nachricht, empfaenger.map((t) => t.id), "neu",
- new Set(erwaehnt));
+ new Set(erwaehnt), rudel);
res.status(201).json({ nachricht });
} catch (fehler) {
@@ -2481,7 +2572,7 @@ const zuschauer = new Map();
* bekommt was" gehoert an EINE Stelle.
*/
function chatEreignis(raumId, nachricht, benachrichtigen, art = "neu",
- erwaehnte = new Set()) {
+ erwaehnte = new Set(), rudel = false) {
/* 1. An alle offenen Verbindungen der Beteiligten. */
const leute = teilnehmerVon(raumId).map((t) => t.id);
for (const personId of leute) {
@@ -2520,8 +2611,19 @@ function chatEreignis(raumId, nachricht, benachrichtigen, art = "neu",
Wortlaut UND Art -- und die Art kann jeder einzeln abschalten.
Wer den Chat stumm stellt, weil dort viel geredet wird, will
trotzdem wissen, wenn ihn jemand direkt anspricht. */
+ /* DREI STUFEN STATT ZWEI (23.09.2026): nur dabei -- angesprochen
+ -- das ganze Rudel gerufen. Der Unterschied steht im WORTLAUT,
+ nicht in einer zweiten Meldung: "Anna hat das Rudel gerufen"
+ sagt sofort, dass es alle betrifft und nicht nur mich.
+
+ AUF DEMSELBEN SCHALTER WIE DIE ERWAEHNUNG, mit Absicht (siehe
+ meldeNeueNachricht). Ein Ruf ans Team IST eine Erwaehnung -- nur
+ eben von allen. Ein eigener vierter Schalter waere der, den
+ jemand abschaltet und der dann genau im wichtigen Moment fehlt. */
void meldeNeueNachricht(personId, raumId, nachricht,
- art === "neu" && erwaehnte.has(personId) ? "erwaehnung" : art);
+ art === "neu" && erwaehnte.has(personId)
+ ? (rudel ? "rudel" : "erwaehnung")
+ : art);
}
}
@@ -2591,6 +2693,27 @@ export function pushTextFuer(art, nachricht, raumId) {
};
}
+ /* ---- DAS GANZE RUDEL (23.09.2026) ----------------------------
+
+ Filipe: „dann sollen alle eine benarichtigung bekommen."
+
+ WARUM NICHT EINFACH „hat dich erwähnt": Weil es nicht stimmt.
+ Beim Rudel ist man nicht gemeint, sondern mitgemeint -- und wer
+ dreimal „hat dich erwähnt" liest und jedes Mal feststellt, dass
+ es alle betraf, glaubt beim vierten Mal auch dem echten nicht
+ mehr. Der Wortlaut sagt deshalb, was los ist.
+
+ ES IST TROTZDEM DIESELBE ART von Meldung (chat_erwaehnung, siehe
+ meldeNeueNachricht) -- ein Ruf ans Team ist eine Erwähnung, nur
+ eben von allen. */
+ if (art === "rudel") {
+ return {
+ titel: `${wer} hat das Rudel gerufen`,
+ text: String(nachricht.text || "").slice(0, 120),
+ ziel,
+ };
+ }
+
return {
titel: `Nachricht von ${wer}`,
/* Der Anfang der Nachricht steht in der Meldung -- eine
@@ -2636,7 +2759,12 @@ async function meldeNeueNachricht(personId, raumId, nachricht, art = "neu") {
Wer den lauten Chat stumm stellt, verliert damit weder Anrufe
noch die Faelle, in denen ihn jemand direkt anspricht. */
const pushArt = art === "anruf" ? "anruf"
- : art === "erwaehnung" ? "chat_erwaehnung"
+ /* Der Ruf ans Rudel laeuft ueber DENSELBEN Schalter wie die
+ Erwaehnung. Kein vierter Schalter: Wer "@rudel" stummschaltet,
+ schaltet den Fall stumm, in dem das ganze Team gebraucht wird
+ -- und genau dafuer gibt es ihn. Wem es zu viel wird, der sagt
+ es demjenigen, der ruft; im Protokoll steht, wer das war. */
+ : (art === "erwaehnung" || art === "rudel") ? "chat_erwaehnung"
: "chat_nachricht";
await benachrichtige(personId, pushArt, pushTextFuer(art, nachricht, raumId));
} catch (fehler) {
diff --git a/server/workspace.js b/server/workspace.js
index d34a6de9..10745f83 100644
--- a/server/workspace.js
+++ b/server/workspace.js
@@ -753,10 +753,26 @@ export function ohneTeamDogi(praefix, spalten = ["creator_id", "erstellt_von"])
return ohneRollen(praefix, spalten, TEAM_DOGI_ROLLEN);
}
+/** Gehoert dieser Mensch zu Team Dogi -- DogFather eingerechnet?
+ *
+ * DAS SIND DIE VIER, DIE FILIPE IMMER WIEDER NENNT: „nur die modis
+ * rechte linke hand und dogfather". Genau diese Aufzaehlung stand
+ * hier dreimal wortgleich als Ausdruck da (siehtModis, kanaeleFuer,
+ * kategorienFuer) und waere mit dem naechsten Wunsch ein viertes Mal
+ * dazugekommen. Drei gleiche Ausdruecke sind drei Gelegenheiten, dass
+ * einer beim naechsten Rollenzuschnitt nicht mitgeht.
+ *
+ * ABGELEITET, NICHT ABGESCHRIEBEN: TEAM_DOGI_ROLLEN (hand, linke,
+ * modi) ist die eine Liste des Hauses; DogFather kommt dazu, weil er
+ * dazugehoert -- er steht nicht darueber, er ist dabei. Kaeme morgen
+ * eine weitere Team-Rolle, waere sie hier von selbst dabei.
+ */
+export const istTeamDogi = (person) => !!person
+ && (person.rolle === "admin" || TEAM_DOGI_ROLLEN.has(person.rolle));
+
/** Darf diese Person ueberhaupt etwas sehen, das einem Modi gehoert?
* Nur die DogFather-Rolle und die Modis selbst. */
-export const siehtModis = (person) => !!person
- && (person.rolle === "admin" || TEAM_DOGI_ROLLEN.has(person.rolle));
+export const siehtModis = istTeamDogi;
export const siehtAlles = (person) => !!person
&& (person.rolle === "admin" || person.rolle === "spicy");
@@ -2207,9 +2223,7 @@ export function kanalVonHandle(handle) {
* Liste gar nicht erst mit, statt mitzukommen und ausgeblendet zu
* werden. */
export function kanaeleFuer(person) {
- if (!person) return [];
- return TEAM_DOGI_ROLLEN.has(person.rolle) || person.rolle === "admin"
- ? KANAELE : [];
+ return istTeamDogi(person) ? KANAELE : [];
}
/* =====================================================================
@@ -2292,9 +2306,7 @@ export function loeschspurenAufraeumen(d, dbPfad = DB_PFAD) {
}
export function kategorienFuer(person) {
- if (!person) return [];
- return TEAM_DOGI_ROLLEN.has(person.rolle) || person.rolle === "admin"
- ? MODI_KATEGORIEN : [];
+ return istTeamDogi(person) ? MODI_KATEGORIEN : [];
}
/**
@@ -3186,6 +3198,31 @@ function umstellungen(d) {
Gelegenheit fuer eine Sperre. */
["chat_nachrichten", "antwort_auf", "INTEGER"],
+ /* WURDE HIER DAS GANZE RUDEL GERUFEN? (23.09.2026)
+
+ Filipe: „dan will ich auch dass nur die modis, rechte hand,
+ linke hand und dogfather, alle auch auf einmal markieren koennen
+ im chat mit einem, @rudel ,dann sollen alle eine benarichtigung
+ bekommen."
+
+ WARUM DAS GESPEICHERT WIRD UND NICHT BEIM LESEN NACHGERECHNET:
+ Beim Lesen muesste der Browser wissen, ob der ABSENDER es damals
+ durfte. Er weiss nur dessen heutige Rolle. Wechselt jemand die
+ Rolle, aenderte sich rueckwirkend, ob ein alter Satz das Rudel
+ gerufen hat -- die Hervorhebung verschwaende aus einer Nachricht
+ von vorletzter Woche, in der sie damals richtig war.
+
+ Und es waere die zweite Rechnung ueber dieselbe Frage: Der
+ Server entscheidet beim Schreiben, wer gerufen wird. Rechnete
+ der Browser beim Lesen noch einmal, haetten wir genau die Lage,
+ vor der chat-erwaehnung.js im ersten Absatz warnt -- die Seite
+ hebt jemanden hervor, den niemand benachrichtigt hat.
+
+ VORGABE 0. Alle Nachrichten von vor heute haben kein Rudel
+ gerufen, und das ist keine Annahme, sondern eine Tatsache: Es
+ gab das Wort noch nicht. */
+ ["chat_nachrichten", "rudel", "INTEGER NOT NULL DEFAULT 0"],
+
/* GESPRAECH LOESCHEN -- NUR BEI MIR (09.09.2026).
Filipe: "man muss die chats auch geloescht bekommen!!!"
diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html
index 0a2fb104..f779ed3e 100644
--- a/workspace/anruf-probe.html
+++ b/workspace/anruf-probe.html
@@ -16,15 +16,15 @@
verdeckt. Gefunden hat es pruef-struktur.
Richtig sind `gate.css` (die Farbwerte des Hauses) und `haus.css`. -->
-
-
+
+
-
+