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
+244 -2
View File
@@ -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.