diff --git a/server/pruef-chat-anhaenge.mjs b/server/pruef-chat-anhaenge.mjs
index 50cf09d6..ccef972f 100644
--- a/server/pruef-chat-anhaenge.mjs
+++ b/server/pruef-chat-anhaenge.mjs
@@ -84,7 +84,21 @@ d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)"
d.close();
const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
-const browser = await pw.chromium.launch();
+/* EIN ECHTES MIKROFON, NUR ERFUNDEN (23.09.2026).
+
+ Fuer Sprachnachrichten braucht diese Pruefung eine Tonquelle.
+ Chromium bringt eine mit: `--use-fake-device-for-media-stream`
+ liefert einen gleichmaessigen Ton, `--use-fake-ui-for-media-stream`
+ beantwortet die Nachfrage nach der Erlaubnis mit Ja.
+
+ WARUM NICHT EINE SELBST GEBAUTE BYTEFOLGE: Aus demselben Grund, der
+ zwanzig Zeilen weiter unten beim PNG steht. Eine gebastelte
+ WebM-Kopfzeile wuerde beweisen, dass meine Erkennung meine Bastelei
+ versteht. Was hier hochgeht, hat dagegen MediaRecorder erzeugt --
+ dasselbe Stueck Programm, das auf Filipes Handy laeuft. */
+const browser = await pw.chromium.launch({
+ args: ["--use-fake-device-for-media-stream", "--use-fake-ui-for-media-stream"],
+});
async function alsRolle(rolle, code, breite = 1280) {
const ctx = await browser.newContext({ viewport: { width: breite, height: 900 } });
@@ -259,6 +273,59 @@ let bildNachrichtId = 0;
springt -- das ist der Zweck, nicht Zierrat. */
ok(bild?.attrW === "240" && bild?.attrH === "150",
`Breite und Höhe stehen am Bild (${bild?.attrW}×${bild?.attrH}) — der Verlauf springt nicht`);
+
+ /* ---- ÜBER DEN ECHTEN KNOPF, NICHT ÜBER DIE SCHNITTSTELLE ---------
+
+ ================================================================
+ WARUM DAS HIER STEHT: EIN STILLER FEHLER, EINE LÜCKE IN DIESER
+ PRÜFUNG (23.09.2026)
+ ================================================================
+
+ Alles oben schickt mit `fetch` an die Route -- der bequeme Weg,
+ und er prüft den Server gründlich. Er prüft aber NICHT den Weg,
+ den ein Mensch geht: Büroklammer, Datei wählen, warten.
+
+ In genau diesem Weg stand seit dem 19.09. `const { nachricht } =
+ e.daten;` -- ein `e`, das es dort nicht gibt. Die Zeile warf, der
+ `catch` daneben fing, und der Absender las: „Keine Verbindung --
+ der Anhang wurde nicht geschickt." Das Foto war aber da. Es kam
+ Sekunden später über den Ereignisstrom, mit einer roten Meldung
+ darüber, die das Gegenteil behauptete, und der getippte
+ Begleitsatz blieb im Feld stehen -- also schickt man es noch
+ einmal.
+
+ Diese Prüfung war die ganze Zeit grün. Sie hat den Server
+ geprüft, nicht die Bedienung. Deshalb geht dieser Block jetzt
+ durch das Dateifeld selbst und sieht auf das, was der Mensch
+ danach sieht: steht dort eine Fehlermeldung, und ist das Feld
+ leer? */
+ await luna.seite.fill("#text", "über den Knopf geschickt");
+ await luna.seite.setInputFiles("#anhang-feld", {
+ name: "ueber-den-knopf.png",
+ mimeType: "image/png",
+ /* Das kleinste gültige PNG: 1×1, ein Pixel. Es geht hier nicht um
+ das Bild, sondern um den Weg. */
+ buffer: Buffer.from(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
+ "base64"),
+ });
+ await luna.seite.waitForTimeout(2500);
+ const danach = await luna.seite.evaluate(() => ({
+ fehlertext: document.getElementById("fehler")?.textContent.trim() || "",
+ feld: document.getElementById("text")?.value || "",
+ letzterName: document.querySelector(".chat-nachricht:last-of-type .chat-bild")
+ ?.getAttribute("title") || "",
+ }));
+ ok(danach.letzterName.includes("ueber-den-knopf.png"),
+ `über die Büroklammer geschickt: das Foto steht im Verlauf (${danach.letzterName.slice(0, 40)})`);
+ /* DIE EIGENTLICHE AUSSAGE. Eine Fehlermeldung über einem Foto, das
+ angekommen ist, ist schlimmer als gar keine Rückmeldung. */
+ ok(danach.fehlertext === "",
+ ` und darüber steht KEINE Fehlermeldung ("${danach.fehlertext}")`);
+ /* UND DAS FELD IST LEER. Bleibt der Begleitsatz stehen, schickt man
+ dasselbe Foto ein zweites Mal -- der Satz stand vorher drin. */
+ ok(danach.feld === "",
+ ` das Schreibfeld ist leer, der Begleitsatz ging mit ("${danach.feld}")`);
}
/* =======================================================================
@@ -592,7 +659,182 @@ melde("\n=== 8. Das leere Gespräch ===");
/* =======================================================================
9. KEINE STILLEN FEHLER
======================================================================= */
-melde("\n=== 9. Stille Fehler ===");
+/* =======================================================================
+ SPRACHNACHRICHTEN (23.09.2026)
+
+ Filipe: „dan will ich auch dass man im chat auch sprachnachrichten
+ und gifts reinschicken kann. also nur die modis rechte linke hand
+ und dogfather."
+
+ ZWEI FRAGEN, UND DIE ZWEITE IST DIE, DIE ER AUSDRUECKLICH GESTELLT
+ HAT:
+ 1. Geht es ueberhaupt -- aufnehmen, hochladen, abspielen?
+ 2. Geht es fuer die anderen NICHT? Ohne diesen Teil stuende sein
+ „also nur" bloss im Kommentar.
+
+ AUFGENOMMEN WIRD MIT MediaRecorder, im echten Browser, ueber den
+ echten Knopf. Damit prueft dieser Abschnitt nebenbei etwas, das
+ keine gebastelte Datei pruefen koennte: ob die Erkennung im Server
+ das versteht, was ein Browser tatsaechlich erzeugt.
+ ======================================================================= */
+melde("\n=== 9. Eine Sprachnachricht ===");
+
+/** Im Browser aufnehmen und die Bytes als Base64 zurueckgeben.
+ * Fuer die Gegenprobe gebraucht: Patrick soll GENAU DIESELBE Art
+ * Datei schicken wollen wie DogFather -- sonst bewiese eine Absage
+ * nur, dass seine Datei kaputt war. */
+async function tonAufnehmen(seite, ms) {
+ return seite.evaluate(async (dauer) => {
+ const spur = await navigator.mediaDevices.getUserMedia({ audio: true });
+ const typ = ["audio/webm;codecs=opus", "audio/webm", "audio/ogg", "audio/mp4"]
+ .find((t) => window.MediaRecorder.isTypeSupported(t)) || "";
+ const g = new MediaRecorder(spur, typ ? { mimeType: typ } : undefined);
+ const stuecke = [];
+ g.addEventListener("dataavailable", (e) => { if (e.data?.size) stuecke.push(e.data); });
+ const fertig = new Promise((r) => g.addEventListener("stop", r));
+ g.start(200);
+ await new Promise((r) => setTimeout(r, dauer));
+ g.stop();
+ await fertig;
+ spur.getTracks().forEach((t) => t.stop());
+ const brocken = new Blob(stuecke, { type: g.mimeType || typ });
+ const puffer = new Uint8Array(await brocken.arrayBuffer());
+ let roh = "";
+ for (const byte of puffer) roh += String.fromCharCode(byte);
+ return { b64: btoa(roh), typ: brocken.type, bytes: puffer.length };
+ }, ms);
+}
+
+{
+ await oeffne(dogi.seite, raumDogi);
+
+ /* ---- Der Knopf ist da -- und bei Patrick nicht ------------------ */
+ const knopfDogi = await dogi.seite.locator("#ton-auf").isVisible();
+ ok(knopfDogi, "DogFather sieht den Mikrofonknopf");
+ await oeffne(pat.seite, raumId);
+ const knopfPat = await pat.seite.locator("#ton-auf").isVisible();
+ /* DIE GEGENPROBE AM BILDSCHIRM. Ein Knopf, der da ist und dann
+ abgelehnt wird, ist schlimmer als keiner. */
+ ok(!knopfPat, "der Scout sieht ihn nicht — „also nur die modis rechte linke hand und dogfather“");
+
+ /* ---- Aufnehmen ueber den echten Knopf --------------------------- */
+ const vorher = await dogi.seite.locator(".chat-nachricht").count();
+ await dogi.seite.click("#ton-auf");
+ await dogi.seite.waitForSelector("#tonleiste:not([hidden])", { timeout: 8000 });
+ ok(true, "der Knopf startet die Aufnahme, die Leiste erscheint");
+ await dogi.seite.waitForTimeout(2600);
+ const zeit = (await dogi.seite.locator("#ton-zeit").innerText()).trim();
+ /* DIE ZEIT MUSS LAUFEN. Stuende sie still, wuesste niemand, ob noch
+ aufgenommen wird -- und man redet in eine Aufnahme, die steht. */
+ ok(/^0:0[2-9]$/.test(zeit), `die Zeit läuft mit (${zeit})`);
+ await dogi.seite.click("#ton-senden");
+ await dogi.seite.waitForTimeout(3000);
+
+ const nachher = await dogi.seite.locator(".chat-nachricht").count();
+ ok(nachher === vorher + 1, `die Sprachnachricht steht im Verlauf (${vorher} -> ${nachher})`);
+ ok(!(await dogi.seite.locator("#tonleiste").isVisible()),
+ " und die Aufnahmeleiste ist wieder zu");
+
+ const blase = await dogi.seite.evaluate(() => {
+ const k = document.querySelector(".chat-ton");
+ if (!k) return null;
+ const g = k.querySelector("audio");
+ return {
+ marke: k.querySelector(".chat-ton__marke")?.textContent.trim() || "",
+ dauer: k.querySelector(".chat-ton__dauer")?.textContent.trim() || "",
+ quelle: g?.getAttribute("src") || "",
+ bedienbar: !!g?.controls,
+ vorladen: g?.getAttribute("preload") || "",
+ vorlesewort: g?.getAttribute("aria-label") || "",
+ breite: Math.round(k.getBoundingClientRect().width),
+ };
+ });
+ ok(!!blase, "sie ist als Sprachnachricht gezeichnet");
+ ok(blase?.marke === "Sprachnachricht", ` beschriftet: ${blase?.marke}`);
+ ok(/^0:0[2-9]$/.test(blase?.dauer || ""), ` mit ihrer Länge (${blase?.dauer})`);
+ ok(blase?.bedienbar === true, " mit dem Abspielgerät des Browsers, nicht nachgebaut");
+ ok(blase?.vorladen === "metadata",
+ " das nur den Kopf lädt — zehn Sprachnachrichten kosten so Kilobyte statt Megabyte");
+ ok(/Sprachnachricht/.test(blase?.vorlesewort || ""),
+ ` und ein Vorleseprogramm sagt mehr als „Audio" (${blase?.vorlesewort})`);
+ ok((blase?.breite || 0) >= 200,
+ ` breit genug, um den Schieber zu treffen (${blase?.breite} px)`);
+
+ /* ---- Was steht in der Datenbank? -------------------------------- */
+ const db2 = new DatabaseSync(process.env.WORKSPACE_DB);
+ const zeile = db2.prepare(`SELECT anhang_art, anhang_typ, anhang_groesse, anhang_dauer
+ FROM chat_nachrichten WHERE anhang_art = 'ton' ORDER BY id DESC LIMIT 1`).get();
+ db2.close();
+ ok(zeile?.anhang_art === "ton", `erkannt als ${zeile?.anhang_art}`);
+ /* DER TYP KOMMT AUS DEN ERSTEN BYTES, nicht aus dem Content-Type --
+ und das ist hier die eigentliche Aussage: Die Erkennung versteht,
+ was ein echter Browser erzeugt. */
+ ok(/^audio\/(webm|ogg|mp4)$/.test(zeile?.anhang_typ || ""),
+ ` und der Typ kommt aus dem Inhalt (${zeile?.anhang_typ})`);
+ ok((zeile?.anhang_groesse || 0) > 500, ` ${zeile?.anhang_groesse} Bytes sind angekommen`);
+ ok((zeile?.anhang_dauer || 0) >= 2000 && (zeile?.anhang_dauer || 0) < 20000,
+ ` und die Länge steht dabei (${zeile?.anhang_dauer} ms)`);
+
+ /* ---- In der Gesprächsliste steht ein Wort dafür ------------------ */
+ const vorschau = await dogi.seite.evaluate(async () => {
+ const a = await fetch("/workspace/api/chat/raeume");
+ const j = await a.json();
+ return (j.raeume || []).map((r) => r.letzte?.text || "");
+ });
+ ok(vorschau.includes("Sprachnachricht"),
+ `in der Gesprächsliste steht „Sprachnachricht" statt einer leeren Zeile`);
+
+ /* ---- DIE GEGENPROBE: derselbe Ton, ein anderer Absender ---------- */
+ const aufnahme = await tonAufnehmen(pat.seite, 1500);
+ ok(aufnahme.bytes > 500, `Patrick nimmt dieselbe Art Datei auf (${aufnahme.bytes} Bytes)`);
+ const absage = await absichtlich([pat], () => pat.seite.evaluate(async ([r, b64, typ]) => {
+ const roh = atob(b64);
+ const puffer = new Uint8Array(roh.length);
+ for (let i = 0; i < roh.length; i++) puffer[i] = roh.charCodeAt(i);
+ const a = await fetch(`/workspace/api/chat/raeume/${r}/anhang`, {
+ method: "POST",
+ headers: { "Content-Type": typ, "X-Dateiname": "Sprachnachricht.webm", "X-Dauer": "1500" },
+ body: puffer,
+ });
+ return { status: a.status, text: (await a.json())?.fehler || "" };
+ }, [raumId, aufnahme.b64, aufnahme.typ]));
+ /* ER GEHT AN DER OBERFLAECHE VORBEI -- genau darum geht es. Der
+ ausgeblendete Knopf ist die Hoeflichkeit, diese Antwort ist die
+ Schranke. */
+ ok(absage.status === 403,
+ `am Knopf vorbei geschickt: abgelehnt (HTTP ${absage.status})`);
+ ok(/Team Dogi/.test(absage.text),
+ ` mit einem Satz, der sagt warum ("${absage.text}")`);
+ const db3 = new DatabaseSync(process.env.WORKSPACE_DB);
+ const vonPat = db3.prepare(
+ "SELECT COUNT(*) AS n FROM chat_nachrichten WHERE anhang_art = 'ton' AND person_id = ?")
+ .get(idPat).n;
+ db3.close();
+ ok(vonPat === 0, ` und nichts davon ist gespeichert (${vonPat})`);
+
+ /* ---- Und ein Video im Tonbehälter geht auch nicht ---------------- */
+ const alsVideo = await absichtlich([dogi], () => dogi.seite.evaluate(async (r) => {
+ /* Eine WebM-Kopfzeile mit einer VIDEO-Spur. Hier ist Basteln
+ ausnahmsweise richtig: Es geht nicht darum, ob echtes Video
+ erkannt wird, sondern ob die Spurkennung ueberhaupt angesehen
+ wird -- und das laesst sich nur mit einer Datei pruefen, die
+ genau daran scheitern muss. */
+ const kopf = [0x1a, 0x45, 0xdf, 0xa3];
+ const wort = [..."V_VP9"].map((c) => c.charCodeAt(0));
+ const puffer = new Uint8Array([...kopf, ...new Array(20).fill(0x42), ...wort,
+ ...new Array(200).fill(0x11)]);
+ const a = await fetch(`/workspace/api/chat/raeume/${r}/anhang`, {
+ method: "POST",
+ headers: { "Content-Type": "audio/webm", "X-Dateiname": "nicht-ton.webm" },
+ body: puffer,
+ });
+ return a.status;
+ }, raumDogi));
+ ok(alsVideo === 415,
+ `ein Video im Ton-Behälter wird abgelehnt (HTTP ${alsVideo}) — sonst liefe es ohne Bild und niemand wüsste es`);
+}
+
+melde("\n=== 10. Stille Fehler ===");
{
/* ABSICHTLICH PROVOZIERTE ABSAGEN ZÄHLEN NICHT.
diff --git a/server/workspace-chat.js b/server/workspace-chat.js
index 7f5af58c..a6ec751b 100644
--- a/server/workspace-chat.js
+++ b/server/workspace-chat.js
@@ -77,6 +77,24 @@ const ANHANG_MAX = 12 * 1024 * 1024;
const ANHANG_ORDNER = join(DATEN_ORDNER, "chat-anhaenge");
const ANHANG_NAME_MAX = 200;
+/* EIGENE GRENZE FUER TON (23.09.2026).
+
+ Zwoelf Megabyte sind fuer ein Foto richtig und fuer eine
+ Sprachnachricht sinnlos: Bei Opus mit 32 kbit/s waeren das rund
+ fuenfzig Minuten am Stueck. Vier Megabyte reichen fuer etwa zwanzig
+ Minuten -- mehr als genug fuer „hoer mal kurz" und immer noch eine
+ echte Schranke.
+
+ SIE WIRD NACH DEM EMPFANG GEPRUEFT, und das ist kein Versehen: Ob
+ etwas Ton ist, sagen erst die ersten Bytes. Vorher weiss niemand,
+ welche Grenze gilt. Gespeichert wird trotzdem nichts -- abgelehnt
+ wird, bevor die Datei die Platte sieht.
+
+ DIE LAENGE IN MINUTEN STEHT IM BROWSER (chat.js) und stoppt die
+ Aufnahme von selbst. Hier zaehlen Bytes, weil nur die hier messbar
+ sind -- eine Dauer waere eine Behauptung des Absenders. */
+const TON_MAX = 4 * 1024 * 1024;
+
const jetzt = () => new Date().toISOString();
/* ---------- Schranke ---------------------------------------------------- */
@@ -549,9 +567,84 @@ function dateiErkennen(b) {
return { art: "pdf", typ: "application/pdf", endung: ".pdf", breite: 0, hoehe: 0 };
}
+ /* =====================================================================
+ SPRACHNACHRICHTEN (23.09.2026)
+
+ Filipe: „dan will ich auch dass man im chat auch sprachnachrichten
+ und gifts reinschicken kann. also nur die modis rechte linke hand
+ und dogfather."
+
+ DREI BEHAELTER, WEIL DIE GERAETE DREI LIEFERN -- das ist keine
+ Grosszuegigkeit, sondern die Bedingung dafuer, dass es ueberhaupt
+ auf jedem Handy geht:
+ WebM/Opus Chrome, Edge, Android
+ MP4/AAC Safari, iPhone, iPad
+ Ogg/Opus Firefox
+ Wer nur einen davon nimmt, baut eine Funktion, die bei der Haelfte
+ des Teams stumm bleibt.
+
+ WARUM KEIN MP3: Kein Aufnahmegeraet im Browser erzeugt MP3. Es
+ zuzulassen hiesse, eine Tuer fuer Musikdateien aufzumachen, nach
+ der niemand gefragt hat. Bestellt waren Sprachnachrichten.
+
+ DER BEHAELTER ALLEIN GENUEGT NICHT. WebM und MP4 tragen genauso
+ gut Video -- und ein Video, das hier als "Ton" durchginge, landete
+ in einem Abspielgeraet ohne Bild: Es liefe, man hoerte etwas, und
+ niemand wuesste, dass die Haelfte fehlt. Deshalb wird im Kopf nach
+ der KENNUNG DER SPUR gesucht: Steht dort eine Videospur, wird
+ abgelehnt; steht keine Tonspur darin, ebenfalls.
+
+ GESUCHT WIRD NUR IM ANFANG (16 kB). Dort stehen bei allen drei
+ Formaten die Spurangaben. Die ganze Datei zu durchsuchen hiesse,
+ bei vier Megabyte nach Zeichenketten zu graben -- und dabei
+ irgendwann ein "avc1" mitten in den Tondaten zu finden.
+ ===================================================================== */
+ const anfang = b.toString("latin1", 0, Math.min(b.length, 16384));
+ const hatVideo = /V_VP8|V_VP9|V_AV1|V_MPEG|avc1|hvc1|hev1|av01|mp4v|theora/.test(anfang);
+
+ /* --- WebM / Matroska: EBML-Kennung ------------------------------- */
+ if (b[0] === 0x1a && b[1] === 0x45 && b[2] === 0xdf && b[3] === 0xa3) {
+ if (hatVideo) return null;
+ if (!/A_OPUS|A_VORBIS|A_AAC|A_PCM/.test(anfang)) return null;
+ return { art: "ton", typ: "audio/webm", endung: ".webm", breite: 0, hoehe: 0 };
+ }
+
+ /* --- Ogg ---------------------------------------------------------- */
+ if (kopf(0, 4) === "OggS") {
+ if (hatVideo) return null;
+ if (!/OpusHead|vorbis|Speex|FLAC/.test(anfang)) return null;
+ return { art: "ton", typ: "audio/ogg", endung: ".ogg", breite: 0, hoehe: 0 };
+ }
+
+ /* --- MP4 / M4A: "ftyp" steht an Stelle 4, nicht 0 ----------------- */
+ if (kopf(4, 8) === "ftyp") {
+ if (hatVideo) return null;
+ if (!/M4A |mp4a|Opus/.test(anfang)) return null;
+ return { art: "ton", typ: "audio/mp4", endung: ".m4a", breite: 0, hoehe: 0 };
+ }
+
return null;
}
+/** Wie heisst ein Anhang in einem Satz?
+ *
+ * „Foto" steht in der Gespraechsliste, im Zitat, an einer angehefteten
+ * Nachricht und in der Benachrichtigung -- an vier Stellen stand
+ * dieselbe Aufzaehlung, jede mit ihrem eigenen `? :`. Beim Hinzukommen
+ * der Sprachnachricht waeren es vier Aenderungen gewesen, und die
+ * vierte ist die, die man vergisst. Jetzt eine Stelle.
+ *
+ * LEER FUER UNBEKANNTES, nicht "Anhang": Der Aufrufer setzt das Wort
+ * hinter ein `||` -- ein Ersatzwort fuer etwas, das es nicht gibt,
+ * stuende sonst in einer Vorschau, in der nichts ist.
+ */
+export function anhangWort(art) {
+ return art === "bild" ? "Foto"
+ : art === "pdf" ? "PDF"
+ : art === "ton" ? "Sprachnachricht"
+ : "";
+}
+
/** Der Anzeigename. Er steht nur in der Datenbank -- auf der Platte
* bekommt jede Datei einen erzeugten Zufallsnamen, damit ein Name mit
* "../" darin nirgendwo hinkommt. */
@@ -963,12 +1056,13 @@ chatRouter.get("/workspace/api/chat/raeume", (req, res) => {
bild: t.bild })),
letzte: letzte ? {
/* Ein Anhang ohne Begleittext hätte hier eine leere Zeile
- hinterlassen. "Foto"/"PDF" ist die Vorschau, die man in
- jeder Gesprächsliste erwartet. */
+ hinterlassen. „Foto", „PDF", „Sprachnachricht" ist die
+ Vorschau, die man in jeder Gesprächsliste erwartet -- das
+ Wort kommt aus anhangWort(), an einer Stelle für alle vier
+ Orte, an denen es gebraucht wird. */
text: letzte.weg_am ? null
: (letzte.text ? letzte.text.slice(0, 140)
- : (letzte.anhang_art === "bild" ? "Foto"
- : letzte.anhang_art === "pdf" ? "PDF" : "")),
+ : anhangWort(letzte.anhang_art)),
zurueckgenommen: !!letzte.weg_am,
von: letzte.von, erstellt: letzte.erstellt,
} : null,
@@ -1299,7 +1393,7 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => {
p.bild AS von_bild, p.chat_kachel AS von_kachel,
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,
+ n.anhang_breite, n.anhang_hoehe, n.anhang_dauer,
a.text AS zitat_text, a.weg_am AS zitat_weg, ap.name AS zitat_von,
a.anhang_art AS zitat_anhang
FROM chat_nachrichten n
@@ -1394,13 +1488,25 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => {
darf_rudel: istTeamDogi(req.person)
&& leute.some((t) => t.id !== req.person.id && istTeamDogi(t)),
rudel_wort: RUDEL_WORT,
+ /* DARF ICH SPRACHNACHRICHTEN SCHICKEN? (23.09.2026)
+
+ Aus derselben Funktion wie die Schranke in der Anhang-Route.
+ Der Browser vergleicht keine Rollen -- assets/js/chat.js
+ bekommt jeder, der die Seite oeffnet.
+
+ ANDERS ALS BEIM RUDEL HAENGT ES NICHT AM RAUM: Eine
+ Sprachnachricht an einen Creator ist eine Sprachnachricht an
+ einen Creator; sie erreicht ihn. Der Ruf ans Rudel erreicht in
+ so einem Raum niemanden -- deshalb dort die zweite Bedingung
+ und hier nicht. */
+ darf_ton: istTeamDogi(req.person),
angeheftet: angeheftet.map((a) => ({
id: a.id,
von: a.von || "Gelöscht",
heftete: a.heftete || null,
seit: a.angeheftet_am,
text: String(a.text || "").slice(0, 200)
- || (a.anhang_art === "bild" ? "Foto" : a.anhang_art === "pdf" ? "PDF" : ""),
+ || anhangWort(a.anhang_art),
})),
/* Aufsteigend zurück -- gelesen wird von oben nach unten. */
/* Die Reaktionen in EINER Abfrage fuer alle Nachrichten -- nicht
@@ -1445,7 +1551,7 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => {
von: n.zitat_von || "Gelöscht",
text: n.zitat_weg ? null
: (String(n.zitat_text || "").slice(0, 140)
- || (n.zitat_anhang === "bild" ? "Foto" : n.zitat_anhang === "pdf" ? "PDF" : "")),
+ || anhangWort(n.zitat_anhang)),
zurueckgenommen: !!n.zitat_weg,
} : null,
/* Der Anhang wird als fertige ADRESSE geliefert, nicht als
@@ -1455,6 +1561,7 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => {
art: n.anhang_art, typ: n.anhang_typ,
name: n.anhang_name, groesse: n.anhang_groesse,
breite: n.anhang_breite || 0, hoehe: n.anhang_hoehe || 0,
+ dauer: n.anhang_dauer || 0,
weg: `/workspace/api/chat/anhang/${n.id}`,
} : null,
}));
@@ -1722,10 +1829,45 @@ chatRouter.post("/workspace/api/chat/raeume/:id/anhang",
const erkannt = dateiErkennen(req.body);
if (!erkannt) {
return res.status(415).json({
- fehler: "Das geht nicht – erlaubt sind Fotos (PNG, JPEG, WebP, GIF) und PDF.",
+ fehler: "Das geht nicht – erlaubt sind Fotos (PNG, JPEG, WebP, GIF), PDF und Sprachnachrichten.",
});
}
+ /* ==== SPRACHNACHRICHTEN SIND NICHT FUER ALLE (23.09.2026) =====
+
+ Filipe: „dan will ich auch dass man im chat auch
+ sprachnachrichten und gifts reinschicken kann. also nur die
+ modis rechte linke hand und dogfather."
+
+ Das „also nur" ist der Punkt. Fotos und PDF darf jeder
+ schicken, auch Creator -- das steht als Entscheidung im
+ Kommentar ueber dieser Route und bleibt. Ton nicht.
+
+ HIER UND NICHT NUR IM BROWSER. Ein ausgeblendeter Knopf ist
+ eine Bitte; abgelehnt wird erst an dieser Stelle. Dieselbe
+ Ueberlegung wie bei der Nachtruhe zwanzig Zeilen darueber. */
+ if (erkannt.art === "ton" && !istTeamDogi(req.person)) {
+ return res.status(403).json({
+ fehler: "Sprachnachrichten schicken nur Team Dogi und DogFather.",
+ });
+ }
+ if (erkannt.art === "ton" && req.body.length > TON_MAX) {
+ return res.status(413).json({
+ fehler: `Die Sprachnachricht ist zu lang – erlaubt sind ${Math.round(TON_MAX / 1048576)} MB.`,
+ });
+ }
+
+ /* WIE LANG SIE IST, SAGT DER ABSENDER -- und das ist eine
+ Behauptung, keine Messung. Sie steht nur in der Vorschau, bis
+ das Abspielgeraet die wahre Laenge kennt. Begrenzt wird sie
+ trotzdem: Ohne Obergrenze stuende in der Gespraechsliste
+ „Sprachnachricht (999:59)", und die Liste saehe kaputt aus. */
+ let dauer = null;
+ if (erkannt.art === "ton") {
+ const roh = Number.parseInt(req.get("x-dauer") || "", 10);
+ dauer = Number.isFinite(roh) && roh > 0 ? Math.min(roh, 60 * 60 * 1000) : null;
+ }
+
let begleittext = "";
try { begleittext = decodeURIComponent(req.get("x-text") || ""); } catch { begleittext = ""; }
begleittext = begleittext.trim().slice(0, TEXT_MAX);
@@ -1744,11 +1886,11 @@ chatRouter.post("/workspace/api/chat/raeume/:id/anhang",
d.prepare(`INSERT INTO chat_nachrichten
(raum_id, person_id, text, erstellt,
anhang_datei, anhang_name, anhang_art, anhang_typ,
- anhang_groesse, anhang_breite, anhang_hoehe)
- VALUES (?,?,?,?,?,?,?,?,?,?,?)`)
+ anhang_groesse, anhang_breite, anhang_hoehe, anhang_dauer)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`)
.run(raumId, req.person.id, begleittext, n,
aufPlatte, name, erkannt.art, erkannt.typ,
- req.body.length, erkannt.breite || null, erkannt.hoehe || null);
+ req.body.length, erkannt.breite || null, erkannt.hoehe || null, dauer);
const id = d.prepare("SELECT last_insert_rowid() AS id").get().id;
d.prepare("UPDATE chat_raeume SET letzte_am = ? WHERE id = ?").run(n, raumId);
d.prepare("UPDATE chat_teilnehmer SET gelesen_bis = ? WHERE raum_id = ? AND person_id = ?")
@@ -1762,8 +1904,10 @@ chatRouter.post("/workspace/api/chat/raeume/:id/anhang",
art: erkannt.art, typ: erkannt.typ, name,
groesse: req.body.length,
breite: erkannt.breite || 0, hoehe: erkannt.hoehe || 0,
+ dauer: dauer || 0,
weg: `/workspace/api/chat/anhang/${id}`,
},
+ rudel: false,
};
const empfaenger = teilnehmerVon(raumId).filter((t) => t.id !== req.person.id);
chatEreignis(raumId, nachricht, empfaenger.map((t) => t.id));
@@ -2720,8 +2864,7 @@ export function pushTextFuer(art, nachricht, raumId) {
Benachrichtigung, die nur "Neue Nachricht" sagt, zwingt zum
Öffnen, um zu erfahren, ob es wichtig war. */
text: String(nachricht.text || "").slice(0, 120)
- || (nachricht.anhang?.art === "bild" ? "Foto"
- : nachricht.anhang?.art === "pdf" ? "PDF" : ""),
+ || anhangWort(nachricht.anhang?.art),
ziel,
};
}
diff --git a/server/workspace.js b/server/workspace.js
index 10745f83..d7e46f7f 100644
--- a/server/workspace.js
+++ b/server/workspace.js
@@ -3267,6 +3267,26 @@ function umstellungen(d) {
["chat_nachrichten", "anhang_breite", "INTEGER"],
["chat_nachrichten", "anhang_hoehe", "INTEGER"],
+ /* WIE LANG IST DIE SPRACHNACHRICHT? (23.09.2026)
+
+ Filipe: „dan will ich auch dass man im chat auch sprachnachrichten
+ und gifts reinschicken kann."
+
+ In Millisekunden, und sie ist AUSDRUECKLICH nur fuer die Anzeige,
+ bevor die Datei geladen ist: In der Gespraechsliste steht
+ „Sprachnachricht (0:14)", und in der Blase steht die Laenge schon
+ da, waehrend der Ton noch laedt. Ohne sie springt die Zeile in
+ dem Moment, in dem die Zahl nachkommt.
+
+ SIE KOMMT VOM ABSENDER und ist damit eine Behauptung, keine
+ Messung -- das gehoert gesagt. Sie zu ueberpruefen hiesse, vier
+ Behaelterformate zu zerlegen (WebM schreibt die Dauer beim
+ Aufnehmen oft gar nicht erst hinein). Der Wert wird deshalb
+ begrenzt, und die WAHRE Laenge sagt ohnehin das Abspielgeraet,
+ sobald es die Datei kennt. Was wirklich schuetzt, ist die
+ Byte-Grenze, nicht diese Zahl. */
+ ["chat_nachrichten", "anhang_dauer", "INTEGER"],
+
/* WELCHE VORLAGE DAHINTERSTECKT (09.09.2026).
Filipe: "ich will nur dass du dich informierst und schaust was
diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html
index f779ed3e..a25cc1c5 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`. -->
-
-
+
+
-
+