by spl_object_id of the session */ private array $clients = []; /** @var array by id */ private array $users = []; /** @var array by Room::key() */ private array $rooms = []; /** @var \Closure(string): void */ private \Closure $log; /** @var \Closure(): float */ private \Closure $clock; public function __construct( private readonly RoomStore $store, private readonly string $adminKey = '', ?\Closure $log = null, ?\Closure $clock = null, ) { $this->log = $log ?? static function (string $line): void { }; $this->clock = $clock ?? static fn (): float => microtime(true); foreach ($this->store->load() as $room) { $this->rooms[$room->key()] = $room; } } /** the key that makes a client an administrator ("Super Modérateur", admin 2) */ private string $superKey = ''; /** @var list the owner's public addresses: from there the login form offers normal / moderator / admin */ private array $ownerIps = []; /** @param list $ownerIps */ public function setRoles(string $superKey, array $ownerIps): void { $this->superKey = $superKey; $this->ownerIps = array_values(array_filter(array_map('trim', $ownerIps), static fn (string $ip) => $ip !== '')); } public function isOwner(Session $session): bool { return $session->ip() !== '' && in_array($session->ip(), $this->ownerIps, true); } // ---- the transport's side ------------------------------------------------------------------------------- public function connect(Session $session): void { $now = ($this->clock)(); $this->clients[spl_object_id($session)] = ['session' => $session, 'user' => null, 'seen' => $now, 'pinged' => $now]; ($this->log)("connect {$session->address()}"); $session->send('100', [self::BANNER]); } /** @param list $fields */ public function receive(Session $session, string $code, array $fields): void { $id = spl_object_id($session); if (!isset($this->clients[$id])) { return; } $this->clients[$id]['seen'] = ($this->clock)(); $user = $this->clients[$id]['user']; if ($code === '0') { if ($user === null) { $this->login($session, $fields); } return; } if ($code === '700') { // not of 2002: the login form asks whether this address is the owner's (it then offers the roles) $session->send('700', [$this->isOwner($session) ? '1' : '0']); return; } if ($user === null) { // anything before the login: the original server dropped the client $this->drop($session, 'frame before login'); return; } match ($code) { '82' => $this->whois($user, $fields[0] ?? ''), '84' => $this->motto($user, $fields), '20', '40' => $this->enterRoom($user, $code, $fields[0] ?? '', (int)($fields[1] ?? 0)), '90', '94' => $this->listRooms($user, $code), '85' => $this->roomMembers($user), '199' => $user->session->send('199', [(string)count($this->users)]), '72' => $this->privateMessage($user, $fields[0] ?? '', $fields[1] ?? ''), '70', '170' => $this->say($user, $fields[0] ?? ''), // 170: a sentence with a filtered word '555' => $this->eject($user, $fields[0] ?? ''), '56', '58' => $this->search($user, $code, $fields[0] ?? '', (int)($fields[1] ?? -2), (int)($fields[2] ?? -2)), '18' => $this->ignore($user, $fields[0] ?? '', $fields[1] ?? ''), '26' => $this->setFlags($user, (int)($fields[0] ?? 0)), '200' => $user->session->send('200', [$fields[0] ?? '4', '-1']), // the filtered words / forbidden rooms: none '16' => $user->isAdmin() ? $user->session->send('16', ['-1']) : null, '550' => $this->callModerator($user), '255' => $this->deleteRoom($user, $fields[0] ?? ''), // accepted without an answer: "no new private" lists (19), private capabilities (25), invitations // (800-802), the HTTP tunnel (666), the ping's answer (667) '19', '25', '666', '667', '800', '801', '802' => null, default => ($this->log)("unknown frame $code from {$user->nickname}"), }; } public function disconnect(Session $session): void { $id = spl_object_id($session); if (!isset($this->clients[$id])) { return; } $user = $this->clients[$id]['user']; unset($this->clients[$id]); if ($user !== null) { $this->leaveRoom($user, true); unset($this->users[$user->id]); foreach (array_keys($user->partners) as $partnerId) { ($this->users[$partnerId] ?? null)?->session->send('74', [$user->id]); } ($this->log)("leave {$user->nickname}"); } ($this->log)("disconnect {$session->address()}"); } /** Pings quiet clients (667) and drops the silent ones. */ public function tick(): void { $now = ($this->clock)(); foreach ($this->clients as $client) { if ($now - $client['seen'] > self::TIMEOUT) { $this->drop($client['session'], 'timeout'); continue; } if ($now - $client['pinged'] > self::PING_EVERY) { $this->clients[spl_object_id($client['session'])]['pinged'] = $now; $client['session']->send('667'); } } } public function shutdown(): void { $this->store->save($this->rooms); } // ---- what the status command and the tests read --------------------------------------------------------- /** @return array */ public function users(): array { return $this->users; } /** @return array */ public function rooms(): array { return $this->rooms; } public function room(string $name, int $type = 0): ?Room { return $this->rooms[Room::keyOf($name, $type)] ?? null; } /** @return array{clients: int, users: int, rooms: list} */ public function stats(): array { $rooms = []; foreach ($this->rooms as $room) { $rooms[] = ['name' => $room->name, 'type' => $room->type, 'permanent' => $room->permanent, 'members' => $room->count()]; } return ['clients' => count($this->clients), 'users' => count($this->users), 'rooms' => $rooms]; } // ---- the frames ----------------------------------------------------------------------------------------- /** 0 TAB admin TAB tempid: who the client is. The tempid carries the profile (TempId). */ private function login(Session $session, array $fields): void { $profile = TempId::parse($fields[1] ?? ''); if ($profile === null) { $this->drop($session, 'bad tempid'); return; } $admin = match (true) { $this->superKey !== '' && hash_equals($this->superKey, $profile['key']) => 2, $this->adminKey !== '' && hash_equals($this->adminKey, $profile['key']) => 1, default => 0, }; if ($profile['role'] !== null && $this->isOwner($session)) { $admin = $profile['role']; } $id = random_int(0, 9999) . ':' . random_int(0, 499) . ':' . random_int(0, 199999) . ':' . $profile['origin']; $user = new User($id, $this->freeNickname($profile['nickname']), $profile['age'], $profile['sex'], $profile['city'], $admin, $profile['origin'], $session); $this->users[$id] = $user; $this->clients[spl_object_id($session)]['user'] = $user; ($this->log)("login {$user->nickname} ($id)" . ($admin ? " admin $admin" : '') . " from {$session->ip()}"); $session->send('0', [$id, '197', '0', $user->nickname]); $session->send('330', ['-1']); $session->send('332', ['-1']); // the "who is connected" list the applet shows at once (a search with no filter) $this->search($user, '56', '', -2, -2); } private function freeNickname(string $wanted): string { $wanted = trim($wanted) !== '' ? trim($wanted) : 'noname'; $taken = []; foreach ($this->users as $user) { $taken[mb_strtolower($user->nickname)] = true; } $name = $wanted; for ($n = 2; isset($taken[mb_strtolower($name)]); $n++) { $name = $wanted . '_' . $n; } return $name; } /** 82 TAB id: the profile of a member. */ private function whois(User $user, string $id): void { $other = $this->users[$id] ?? null; if ($other !== null) { $user->session->send('82', [Frame::list([$other->id, $other->admin, $other->age, $other->sex, $other->cityField()])]); } } /** 20 (public) / 40 (private room) TAB name TAB type: leave the room, enter that one, get its last sentences. */ private function enterRoom(User $user, string $code, string $name, int $type): void { $name = trim($name); if ($name === '') { return; } $this->leaveRoom($user); $room = $this->roomFor($user, $name, $type); $room->members[$user->id] = $user; $user->room = $room; ($this->log)("{$user->nickname} enters {$room->name}"); // everybody in the room (the newcomer too) learns who came: 85 TAB 0 TAB member $entry = Frame::list($user->roomEntry()); foreach ($room->members as $member) { $member->session->send('85', ['0', $entry]); } $user->session->send($code, ['0', $room->name, '?', $room->creator, '']); // the applet's information messages (bU[86..88]): "Bienvenue dans le salon X", "Y est arrivé(e) dans le salon" $user->session->send('76', ['1', $room->name]); foreach ($room->members as $member) { if ($member !== $user) { $member->session->send('76', ['2', $user->nickname]); } } foreach ($room->history as $line) { $user->session->send('70', [Frame::list([$line['id'], '0', $line['nickname'], $line['text']])]); } } /** * The room of that name, created when missing. A full one sends to "name#1", then "name#2"... (the chat's help of * 1999: "Cinema, Cinema#1, Cinema#2"; the applet shows them "Cinema 1"). */ private function roomFor(User $user, string $name, int $type): Room { $base = $name; for ($i = 0; $i < 100; $i++) { $room = $this->rooms[Room::keyOf($name, $type)] ?? null; if ($room === null) { $room = new Room($name, $type, $user->nickname); $this->rooms[$room->key()] = $room; return $room; } if (!$room->isFull()) { return $room; } $name = $base . '#' . ($i + 1); } return $room; } /** @param bool $quits the member leaves the chat ("a quitté le dialogue en direct"), not only the room */ private function leaveRoom(User $user, bool $quits = false, bool $silent = false): void { $room = $user->room; if ($room === null) { return; } unset($room->members[$user->id]); $user->room = null; $entry = Frame::list($user->roomEntry()); foreach ($room->members as $member) { $member->session->send('85', ['1', $entry]); if (!$silent) { $member->session->send('76', [$quits ? '4' : '3', $user->nickname]); } } if ($room->count() === 0 && !$room->permanent) { unset($this->rooms[$room->key()]); } } /** 90: the public rooms with somebody in; 94: the permanent rooms. */ private function listRooms(User $user, string $code): void { $items = []; foreach ($this->rooms as $room) { if ($room->type !== 0) { continue; } if ($code === '94' ? $room->permanent : (!$room->permanent && $room->count() > 0)) { array_push($items, ...$room->listEntry()); } } $user->session->send($code, [Frame::list($items)]); } /** 85: who is in my room (85 TAB -1 TAB members...). */ private function roomMembers(User $user): void { $items = []; foreach (array_slice($user->room === null ? [] : $user->room->members, 0, Room::MAX_MEMBERS) as $member) { array_push($items, ...$member->roomEntry()); } $user->session->send('85', ['-1', implode(Frame::TAB, $items)]); } /** 72 TAB to TAB text: a private message; the sender gets 73 (echo), the receiver 72. */ private function privateMessage(User $from, string $toId, string $text): void { $to = $this->users[$toId] ?? $this->byKey($toId); if ($to === null || $text === '') { if ($to === null && $text !== '') { $from->session->send('74', [$toId]); // not (or no longer) connected } return; } if (($to->flags & User::REFUSES_PRIVATE) && !isset($to->partners[$from->id]) && !$from->isAdmin()) { $from->session->send('75', [$to->id, $to->nickname]); return; } $from->partners[$to->id] = true; $to->partners[$from->id] = true; if (isset($to->ignores[$from->key()])) { $from->session->send('73', [Frame::list([$to->id, '0', $to->nickname, $text])]); return; } $from->session->send('73', [Frame::list([$to->id, '0', $to->nickname, $text])]); $to->session->send('72', [Frame::list([$from->id, '0', $from->nickname, $text])]); } /** * 70 TAB text: a sentence in my room. The text starts with 4 digits (colour and style, "0100bonjour"); the * moderators have two commands: "!update text" (an information line to everybody, 77) and "!bold text". */ private function say(User $user, string $text): void { $room = $user->room; if ($room === null || $text === '') { return; } $body = (string)substr($text, 4); if ($user->isAdmin() && str_starts_with($body, '!update')) { $info = '05' . substr($text, 2, 2) . ltrim(substr($body, 7)); foreach ($this->users as $other) { $other->session->send('77', [$info]); } return; } if ($user->isAdmin() && str_starts_with($body, '!bold')) { $text = '05' . substr($text, 2, 2) . ltrim(substr($body, 5)); } $room->remember($user, $text); $line = Frame::list([$user->id, '0', $user->nickname, $text]); foreach ($room->members as $member) { $member->session->send('70', [$line]); } } /** 555 TAB nickname:origin: a moderator ejects somebody, or somebody leaves by himself. */ private function eject(User $user, string $target): void { $nickname = str_contains($target, ':') ? substr($target, 0, (int)strrpos($target, ':')) : $target; $self = mb_strtolower($nickname) === mb_strtolower($user->nickname); if (!$user->isAdmin() && !$self) { return; } $victim = null; foreach ($this->users as $other) { if (mb_strtolower($other->nickname) === mb_strtolower($nickname)) { $victim = $other; break; } } if ($victim === null) { return; } $victim->session->send('555', [Frame::list([$user->id, $user->nickname])]); $room = $victim->room ?? $user->room; if ($room !== null) { $notice = Frame::list(['5', $victim->id, $victim->nickname, $user->id, $user->nickname]); foreach ($room->members as $member) { $member->session->send('76', [$notice]); } } ($this->log)("{$user->nickname} ejects {$victim->nickname}"); $this->leaveRoom($victim, false, true); // the room was told "a été éjecté(e) par" (76 5), not "a quitté" $this->drop($victim->session, 'ejected'); } /** * 56 (by nickname) / 58 (by city) TAB beginning TAB age band TAB sex: who matches. * Age bands: 1-5 = 5-10, 10-15 ... 25-30; 6-9 = 30-40 ... 60-70; 10 = 70 and more; -2 = any. */ private function search(User $user, string $code, string $filter, int $ageBand, int $sex): void { [$min, $max] = self::ageRange($ageBand); $filter = mb_strtolower($filter); $items = []; $found = 0; foreach ($this->users as $other) { $field = mb_strtolower($code === '58' ? $other->city : $other->nickname); if ($filter !== '' && !str_starts_with($field, $filter)) { continue; } if ($sex !== -2 && $other->sex !== ($sex === -1 ? 2 : $sex)) { continue; } if ($other->age < $min || $other->age > $max) { continue; } array_push($items, ...$other->searchEntry($sex === -2)); if (++$found >= 200) { break; } } $head = $found === 0 ? "$ageBand:$sex" : ($sex === -2 ? '-2:-2:-2' : "$ageBand:$sex:$sex"); $user->session->send($code, [$head . Frame::TAB . Frame::list($items)]); } /** @return array{0: int, 1: int} */ public static function ageRange(int $band): array { if ($band <= 0) { return [PHP_INT_MIN, PHP_INT_MAX]; } if ($band === 10) { return [70, PHP_INT_MAX]; } if ($band < 6) { return [$band * 5, $band * 5 + 5]; } $min = ($band - 3) * 10; // 6 = 30-40, 7 = 40-50, 8 = 50-60, 9 = 60-70 return [$min, $min + 10]; } /** A member by "nickname:origin" (the applet's /nickname:N command) or by nickname alone. */ private function byKey(string $key): ?User { $key = mb_strtolower($key); foreach ($this->users as $user) { if (mb_strtolower($user->key()) === $key || mb_strtolower($user->nickname) === $key) { return $user; } } return null; } /** 84: no text = what is my phrase; a text = my new phrase (80 characters at most). */ private function motto(User $user, array $fields): void { if (isset($fields[0])) { $user->motto = mb_substr(trim($fields[0]), 0, 80); } if ($user->motto !== '') { $user->session->send('84', [$user->motto]); } } /** 18 TAB 0|1 TAB nickname:origin: ignore / stop ignoring somebody; the answer is the whole list. */ private function ignore(User $user, string $op, string $key): void { if ($key !== '') { if ($op === '0') { $user->ignores[$key] = true; } elseif ($op === '1') { unset($user->ignores[$key]); } } $user->session->send('18', ['-1', ...array_keys($user->ignores)]); } /** 26 TAB flags: 2 refuses new private talks, 4 invisible (moderators only), 64 busy. */ private function setFlags(User $user, int $flags): void { $allowed = User::REFUSES_PRIVATE | User::BUSY | ($user->isAdmin() ? User::INVISIBLE : 0); $user->flags = max(0, $flags) & $allowed; $user->session->send('26', [(string)$user->flags]); if ($user->room !== null) { $entry = Frame::list($user->roomEntry()); foreach ($user->room->members as $member) { $member->session->send('85', ['0', $entry]); } } } /** * 550 ("Problème de comportement"): the moderators hear "550 TAB room TAB n" (n members signal a problem in the * room, the last 10 minutes); the caller gets 550 with one field ("Un modérateur a été prévenu") or 551 when no * moderator is there ("Aucun modérateur n'est joignable pour le moment."). */ private function callModerator(User $user): void { $now = ($this->clock)(); $room = $user->room; $count = 1; if ($room !== null) { $room->reports[$user->id] = $now; $room->reports = array_filter($room->reports, static fn (float $at) => $now - $at < 600); $count = count($room->reports); } $told = 0; foreach ($this->users as $other) { if ($other->isAdmin() && $other !== $user) { $other->session->send('550', [$room === null ? '?' : $room->name, (string)$count]); $told++; } } $told > 0 ? $user->session->send('550', ['1']) : $user->session->send('551'); } /** 255 TAB room: a moderator removes a room (its members stay until they leave). */ private function deleteRoom(User $user, string $name): void { $room = $this->rooms[Room::keyOf($name, 0)] ?? null; if (!$user->isAdmin() || $room === null) { return; } $room->permanent = false; if ($room->count() === 0) { unset($this->rooms[$room->key()]); } ($this->log)("{$user->nickname} removes the room {$room->name}"); $this->store->save($this->rooms); } private function drop(Session $session, string $why): void { ($this->log)("drop {$session->address()}: $why"); $this->disconnect($session); $session->close(); } }