Chat: Sprachnachrichten -- und ein stiller Fehler, der jedes Foto betraf

Filipe: "dan will ich auch dass man im chat auch sprachnachrichten und
gifts reinschicken kann. also nur die modis rechte linke hand und
dogfather."

Dieser Commit macht die Sprachnachrichten. Die GIFs kommen als
naechstes -- ich lese "gifts" als GIFs und sage das ausdruecklich,
statt es stillschweigend anzunehmen.

AUFNEHMEN: TIPPEN, NICHT HALTEN
-------------------------------
Gedruecktbleiben ist der bekanntere Weg und der schlechtere: Wer beim
Sprechen verrutscht, verliert die ganze Aufnahme, und mit einer Hand am
Kaffee haelt niemand neunzig Sekunden still. Einmal tippen startet, das
Haekchen schickt, das Kreuz verwirft.

Drei Minuten Hoechstdauer, danach stoppt sie von selbst -- und schickt
NICHT von selbst ab. Wer die Grenze erreicht, hat gerade mitten im Satz
aufgehoert; das ungefragt zu verschicken waere die schlechteste Sekunde
dafuer.

Unter einer Sekunde wird gar nichts geschickt: Das ist ein Versehen,
kein Inhalt.

Die Aufnahmespur wird beim Beenden UND beim Verlassen der Seite
geschlossen. Ein Mikrofon, das offen bleibt, ist ein Vertrauensbruch,
auch wenn niemand zuhoert.

DREI BEHAELTER, WEIL DIE GERAETE DREI LIEFERN
---------------------------------------------
WebM/Opus (Chrome, Android), MP4/AAC (Safari, iPhone), Ogg/Opus
(Firefox). Wer nur einen nimmt, baut eine Funktion, die bei der Haelfte
des Teams stumm bleibt -- und zwar ohne Fehlermeldung.

DER BEHAELTER ALLEIN GENUEGT NICHT: WebM und MP4 tragen genauso gut
Video. Ein Video, das als "Ton" durchginge, landete in einem
Abspielgeraet ohne Bild -- es liefe, man hoerte etwas, und niemand
wuesste, dass die Haelfte fehlt. Die Erkennung sieht deshalb auf die
KENNUNG DER SPUR: Videospur -> abgelehnt, keine Tonspur -> abgelehnt.

Kein MP3: Kein Aufnahmegeraet im Browser erzeugt MP3. Es zuzulassen
hiesse, eine Tuer fuer Musikdateien aufzumachen, nach der niemand
gefragt hat.

"ALSO NUR" IST DER PUNKT
------------------------
Fotos und PDF darf jeder schicken, auch Creator -- das war eine
Entscheidung vom 09.09. und bleibt. Ton nicht. Geprueft wird in der
Route (istTeamDogi), nicht nur im Browser: Ein ausgeblendeter Knopf ist
eine Bitte, abgelehnt wird erst am Server.

Eigene Byte-Grenze fuer Ton (4 MB statt 12). Zwoelf Megabyte sind fuer
ein Foto richtig und fuer eine Sprachnachricht sinnlos -- das waeren
rund fuenfzig Minuten am Stueck.

Die Laenge kommt vom Absender und ist damit eine BEHAUPTUNG, keine
Messung; das steht so an der Spalte. Sie dient nur der Vorschau, bis
das Abspielgeraet die wahre Laenge kennt. Was schuetzt, sind die Bytes.

"FOTO"/"PDF" STAND AN VIER STELLEN
----------------------------------
Dieselbe Aufzaehlung in Gespraechsliste, Zitat, angehefteter Nachricht
und Benachrichtigung -- jede mit ihrem eigenen Fragezeichen-Doppelpunkt.
Mit der Sprachnachricht waeren es vier Aenderungen gewesen, und die
vierte ist die, die man vergisst. Jetzt anhangWort() an einer Stelle.

DER FUND: JEDES FOTO MELDETE "KEINE VERBINDUNG"
-----------------------------------------------
Beim Anfassen derselben Funktion aufgefallen: In `anhangSchicken` stand
seit dem 19.09. `const { nachricht } = e.daten;` -- ein `e`, das es
dort nicht gibt (gemeint war `ergebnis`). Die Zeile wirft, der `catch`
daneben faengt, und der Absender liest:

    "Keine Verbindung -- der Anhang wurde nicht geschickt."

Das Foto war aber da. Es kam Sekunden spaeter ueber den Ereignisstrom
in den Verlauf, mit einer roten Meldung darueber, die das Gegenteil
behauptet -- und der getippte Begleitsatz blieb im Feld stehen, also
schickt man es noch einmal.

WARUM DAS VIER TAGE UNBEMERKT BLIEB, und das ist der lehrreiche Teil:
pruef-chat-anhaenge war die ganze Zeit gruen. Sie schickt mit `fetch`
an die Route -- der bequeme Weg, und er prueft den Server gruendlich.
Sie prueft aber nicht den Weg, den ein Mensch geht. Der Fehler lag
hinter der Bueroklammer, und dort hat nie jemand hingesehen. Dazu
fuehlt er sich wie ein Netzproblem an -- und Netzprobleme sucht niemand
im Programm.

Jetzt geht ein Block durch das Dateifeld selbst und sieht auf das, was
der Mensch danach sieht: steht dort eine Meldung, und ist das Feld
leer? Gegenprobe gefahren -- mit dem alten Fehler wieder eingesetzt
werden genau diese zwei Zeilen rot, mit dem Wortlaut von oben.

Uebernommen wird eine frisch geschickte Nachricht jetzt an EINER
Stelle (nachrichtUebernehmen), fuer Anhang und Sprachnachricht. Zwei
Fassungen waeren zwei Gelegenheiten fuer denselben Fehler.

AUGENSCHONEND
-------------
Kein Blinkpunkt waehrend der Aufnahme -- der ist genau das, was die
Hausregel ausschliesst. Der Punkt atmet langsam (1,8 s, 45 bis 100 %)
und steht bei prefers-reduced-motion ganz still; die laufende Zeit
daneben traegt die Auskunft ohnehin.

Die Farben der Sprachnachricht-Blase kommen aus `--blase-leise`, der
fuer DIESE Blase ausgerechneten leisen Schrift. Eine feste Farbe waere
dieselbe Wette, die am 22.09. beim Loeschknopf 1,91:1 auf Babyblau
ergeben hat.

GEPRUEFT
--------
pruef-chat-anhaenge: 86 Pruefungen, 0 Fehler (vorher 60).
Aufgenommen wird mit MediaRecorder im echten Browser ueber den echten
Knopf, mit Chromiums erfundenem Mikrofon. Damit prueft der Abschnitt
nebenbei das, was keine gebastelte Datei pruefen koennte: ob die
Erkennung im Server versteht, was ein Browser wirklich erzeugt
(gemessen: 35 829 Bytes audio/webm, 2743 ms).

  * DogFather sieht den Mikrofonknopf, der Scout nicht
  * der Scout schickt dieselbe Aufnahme an der Oberflaeche vorbei:
    403, und nichts davon ist gespeichert
  * ein Video im Ton-Behaelter: 415
  * Laenge, Vorlesewort, Bedienbarkeit, preload=metadata, Breite
  * "Sprachnachricht" steht in der Gespraechsliste statt einer leeren
    Zeile

pruef-chat, pruef-chat-optik: unveraendert gruen.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-23 01:51:56 +02:00
co-authored by Claude Opus 5
parent 28b0143166
commit 2d14907d0d
44 changed files with 1532 additions and 614 deletions
+156 -13
View File
@@ -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,
};
}