) the room's list changed * joined (Member) / left (Member) * rooms (string $code, list) 90 public, 94 permanent * found (string $code, list) 56 / 58 search answers (and the list at login) * profile (Member) 82 * count (int) 199 * info (string $coded) 77, a line of the moderators * ejection (victim, by) 76 5 * ended (string $why, string $who) 11-14, 555: the server ends the session * gone (string $id) / refused (string $id, string $nick) 74 / 75 for the private talks * notice (string $kind, ...) 23, 550, 551, 76 * motto (string), flags (int), ignores (list) * error (string) */ final class ChatClient extends EventEmitter { public string $id = ''; public string $nickname = ''; public string $roomName = ''; public int $roomType = 0; public bool $connected = false; /** @var array the members of my room, by id */ public array $members = []; /** @var array everybody met (searches, rooms, privates), by id */ public array $known = []; /** @var array "nickname:origin" ignored (18) */ public array $ignores = []; public int $flags = 0; public string $motto = ''; public string $wantedRoom = ''; public bool $wantedPrivate = false; private string $buffer = ''; private float $quiet = 0; public function __construct(private readonly Link $link, private readonly string $tempid, private readonly bool $admin = false) { $link->on('open', function (): void { $this->connected = true; $this->emit('status', ['connected']); $this->send('0', $this->admin ? '1' : '0', $this->tempid); }); $link->on('data', fn (string $bytes) => $this->receive($bytes)); $link->on('close', function (?string $why = null): void { $this->connected = false; $this->emit('status', ['closed', (string)$why]); }); $link->on('error', function (string $message): void { $this->emit('error', [$message]); }); } // ---- what the window asks ------------------------------------------------------------------------------- public function send(string $code, string ...$fields): void { $this->quiet = 0; $this->link->write($code . "\t" . implode("\t", array_map(Styled::strip(...), $fields)) . "\n"); } public function enter(string $room, bool $private = false): void { $room = trim(Styled::strip($room)); if ($room !== '') { $this->send($private ? '40' : '20', $room, $private ? '1' : '0'); } } public function say(string $coded): void { $this->send('70', $coded); } public function whisper(string $toId, string $coded): void { $this->send('72', $toId, $coded); } public function listRooms(bool $permanent): void { $this->send($permanent ? '94' : '90'); } public function search(string $beginning, int $ageBand = -2, int $sex = -2, bool $byCity = false): void { $this->send($byCity ? '58' : '56', $beginning, (string)$ageBand, (string)$sex); } public function whois(string $id): void { $this->send('82', $id); } public function askCount(): void { $this->send('199'); } public function askMembers(): void { $this->send('85'); } public function eject(Member $member): void { $this->send('555', $member->nickname . ':' . $member->origin()); } public function close(): void { $this->link->close(); } /** The applet never pinged by itself: the server sends 667 and it answers (frame()). Kept for the window's timers. */ public function tick(float $dt): void { $this->quiet += $dt; } // ---- what the server says ------------------------------------------------------------------------------- public function receive(string $bytes): void { $this->buffer .= $bytes; while (($end = strpos($this->buffer, "\n")) !== false) { $line = rtrim(substr($this->buffer, 0, $end), "\r"); $this->buffer = (string)substr($this->buffer, $end + 1); // the applet cut a frame with StringTokenizer("\t\n"): an empty field does not exist $fields = array_values(array_filter(explode("\t", $line), static fn (string $v) => $v !== '')); if ($fields !== []) { $this->frame(array_shift($fields), $fields); } } } /** The default room, where the applet went after the login (bU[177] of its language file). */ public const DEFAULT_ROOM = 'Salon'; /** @param list $f */ private function frame(string $code, array $f): void { switch ($code) { case '100': $this->emit('status', ['banner', $f[0] ?? '']); break; case '0': $this->id = $f[0] ?? ''; $this->nickname = $f[3] ?? ($f[2] ?? ''); $this->emit('logged', [$this->id, $this->nickname]); // what the applet asked at once: its own profile, its catch phrase, the filter lists, the room $this->whois($this->id); $this->send('84'); $this->send('200', '4'); $this->send('200', '5'); $this->enter($this->wantedRoom !== '' ? $this->wantedRoom : self::DEFAULT_ROOM, $this->wantedPrivate); break; case '20': case '40': // type, room, x, creator $this->roomType = ((int)($f[0] ?? 0) & 3) !== 0 ? 1 : 0; $this->roomName = $f[1] ?? ''; $this->members = []; $this->emit('room', [$this->roomName, ($f[3] ?? '?') === '?' ? '' : $f[3], $this->roomType]); $this->askMembers(); break; case '23': $this->emit('notice', ['banned', $f[1] ?? '']); $this->enter(self::DEFAULT_ROOM); break; case '70': if (count($f) >= 4) { $this->emit('say', [$f[0], $f[2], $f[3]]); } break; case '72': case '73': case '79': if (count($f) >= 4) { $this->emit('private', [$f[0], $f[2], $f[3], $code !== '72']); } break; case '74': $this->emit('gone', [$f[0] ?? '']); break; case '75': $this->emit('refused', [$f[0] ?? '', $f[1] ?? '']); break; case '85': $this->membersFrame($f); break; case '90': case '94': $rooms = []; foreach (array_chunk($f, 4) as $chunk) { if (count($chunk) === 4) { $rooms[] = ['name' => $chunk[0], 'creator' => $chunk[2] === '?' ? '' : $chunk[2], 'count' => (int)$chunk[3]]; } } $this->emit('rooms', [$code, $rooms]); break; case '56': case '58': $this->emit('found', [$code, $this->searchFrame($f)]); break; case '82': $old = $this->known[$f[0] ?? ''] ?? null; $m = $this->remember(new Member($f[0] ?? '', $old->nickname ?? '', (int)($f[2] ?? 0), (int)($f[3] ?? 2), self::city($f[4] ?? ''), (int)($f[1] ?? 0), $old->where ?? '', $old->state ?? 0)); $this->emit('profile', [$m]); break; case '84': $this->motto = $f[0] ?? ''; $this->emit('motto', [$this->motto]); break; case '26': $this->flags = (int)($f[0] ?? 0); $this->emit('flags', [$this->flags]); break; case '18': if (($f[0] ?? '') === '-1') { $this->ignores = array_fill_keys(array_slice($f, 1), true); $this->emit('ignores', [array_keys($this->ignores)]); } break; case '199': $this->emit('count', [(int)($f[0] ?? 0)]); break; case '76': $this->roomEvent($f); break; case '77': $this->emit('info', [$f[0] ?? '']); break; case '550': // to a moderator: "550 room n" (n members signal a problem); to the caller: the acknowledgement $this->emit('notice', count($f) === 2 ? ['alert', $f[1], $f[0]] : ['called']); break; case '551': $this->emit('notice', ['nomodo']); break; case '555': $this->emit('ended', [($f[0] ?? '') === $this->id || count($f) < 2 ? 'ejected' : 'ejected-by', $f[1] ?? '']); $this->close(); break; case '667': $this->send('667'); break; case '101': $this->close(); break; case '11': case '12': case '13': case '14': $this->emit('ended', [$code, '']); $this->close(); break; } } /** 76: what happened in the room (5: somebody ejected by somebody, 6: ...). */ private function roomEvent(array $f): void { $type = array_shift($f); if ($type === '5') { [$id1, $nick1, $id2, $nick2] = $f + ['', '', '', '']; $this->emit('ejection', [$nick1, $id2 !== '' && $id2 !== $id1 ? $nick2 : '']); if (isset($this->members[$id1])) { unset($this->members[$id1]); $this->emit('members', [array_values($this->members)]); } return; } $kind = match ($type) { '1' => 'welcome', '2' => 'arrived', '3' => 'left', '4' => 'quit', default => '', }; if ($kind !== '') { $this->emit('notice', [$kind, $f[0] ?? '']); } } private static function city(string $city): string { return $city === '?' ? '' : $city; } /** 85: -1 the whole list, 0 somebody came (or changed), 1 somebody left (only his id is needed). */ private function membersFrame(array $f): void { $op = array_shift($f); if ($op === '-1') { $this->members = []; } if ($op === '1') { $id = $f[0] ?? ''; $m = $this->members[$id] ?? null; unset($this->members[$id]); if ($m !== null) { $this->emit('left', [$m]); } $this->emit('members', [array_values($this->members)]); return; } foreach (array_chunk($f, 7) as $c) { if (count($c) < 7) { continue; } $m = $this->remember(new Member($c[0], $c[2], (int)$c[4], (int)$c[5], self::city($c[6]), (int)$c[1], $this->roomName, (int)$c[3])); $new = !isset($this->members[$m->id]); $this->members[$m->id] = $m; if ($new && $op === '0') { $this->emit('joined', [$m]); } } $this->emit('members', [array_values($this->members)]); } /** * 56 / 58: "age:sex[:sex]" then, per member: id, nickname, flags, age, [sex,] city, room, where (0 / 4 a public * room, 1 / 5 a private one). The sex column is there only when both sexes were asked ("-2" as the third part). * * @return list */ private function searchFrame(array $f): array { $parts = explode(':', array_shift($f) ?? ''); $withSex = count($parts) < 3 || $parts[2] === '-2'; $size = $withSex ? 8 : 7; $out = []; foreach (array_chunk($f, $size) as $c) { if (count($c) < $size) { continue; } [$id, $nick, $flags, $age] = $c; $sex = $withSex ? (int)$c[4] : (int)$parts[2]; $rest = array_slice($c, $withSex ? 5 : 4); [$city, $room, $where] = $rest + ['', '', '']; $place = in_array($where, ['0', '4'], true) ? $room : (in_array($where, ['1', '5'], true) ? '*' : ''); $out[] = $this->remember(new Member($id, $nick, (int)$age, $sex, self::city($city), 0, $place === '?' ? '' : $place, (int)$flags)); } return $out; } private function remember(Member $m): Member { $old = $this->known[$m->id] ?? null; if ($old !== null && $m->nickname === '') { $m = $m->with(nickname: $old->nickname); } return $this->known[$m->id] = $m; } public function isIgnored(string $nickname, string $id): bool { $parts = explode(':', $id); return isset($this->ignores[$nickname . ':' . end($parts)]); } public function ignore(Member $m, bool $on = true): void { $this->send('18', $on ? '0' : '1', $m->nickname . ':' . $m->origin()); } public function setFlags(int $flags): void { $this->send('26', (string)$flags); } public function setMotto(string $text): void { $this->send('84', mb_substr(trim($text), 0, 80)); } public function callModerator(): void { $this->send('550'); } }