UK
apps/caramail-chat / src / Chat / ChatClient.php · commit 6eebcdb
<?phpnamespace App\Chat;use App\Net\Link;use Evenement\EventEmitter;/** * The chat as the applet spoke it: frames "code TAB fields LF" over a Link. It keeps who I am, my room, its * members, and turns the frames into events for the window: * *   status (string)                         connecting, connected, the server's banner, closed... *   logged (string $id, string $nickname) *   room (string $name, string $creator, int $type)          I am in a room *   say (string $id, string $nickname, string $coded)        a sentence in my room *   private (string $id, string $nickname, string $coded, bool $mine) *   members (list<Member>)                                   the room's list changed *   joined (Member) / left (Member) *   rooms (string $code, list<array{name, creator, count}>)  90 public, 94 permanent *   found (string $code, list<Member>)                       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<string>) *   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<string, Member> the members of my room, by id */    public array $members = [];    /** @var array<string, Member> everybody met (searches, rooms, privates), by id */    public array $known = [];    /** @var array<string, true> "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<string> $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<Member>     */    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');    }}