RU
apps/caramail-server / src / Chat / ChatServer.php · commit 070afda
<?phpnamespace App\Chat;/** * The chat itself, ported from the original PHP server (proxyServer.php, 2012) without MySQL: who is connected, * the rooms and their last sentences live in memory; the permanent rooms are kept by a RoomStore. * * It knows nothing of sockets: a Session per client (TCP in production, a fake one in the tests). The transport * calls connect(), receive() for each frame, disconnect(), and tick() about once a second. */final class ChatServer{    public const BANNER = 'NewCaraChat v1.00 By Duch';    public const PING_EVERY = 120.0;    public const TIMEOUT = 300.0;    /** @var array<int, array{session: Session, user: ?User, seen: float, pinged: float}> by spl_object_id of the session */    private array $clients = [];    /** @var array<string, User> by id */    private array $users = [];    /** @var array<string, Room> 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<string> the owner's public addresses: from there the login form offers normal / moderator / admin */    private array $ownerIps = [];    /** @param list<string> $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<string> $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<string, User> */    public function users(): array    {        return $this->users;    }    /** @return array<string, Room> */    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<array{name: string, type: int, permanent: bool, members: int}>} */    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();    }}