File Coverage

lib/PAGI/App/WebSocket/Chat.pm
Criterion Covered Total %
statement 77 102 75.4
branch 17 42 40.4
condition 10 34 29.4
subroutine 11 11 100.0
pod 0 2 0.0
total 115 191 60.2


line stmt bran cond sub pod time code
1             package PAGI::App::WebSocket::Chat;
2              
3 1     1   1656 use strict;
  1         2  
  1         30  
4 1     1   3 use warnings;
  1         1  
  1         33  
5 1     1   4 use Future::AsyncAwait;
  1         1  
  1         4  
6 1     1   395 use JSON::MaybeXS ();
  1         10029  
  1         1894  
7              
8             =head1 NAME
9              
10             PAGI::App::WebSocket::Chat - Multi-room chat application
11              
12             =head1 SYNOPSIS
13              
14             use PAGI::App::WebSocket::Chat;
15              
16             my $app = PAGI::App::WebSocket::Chat->new->to_app;
17              
18             =cut
19              
20             # Shared state
21             my %rooms; # room => { users => { id => { send => cb, name => str } } }
22             my %user_rooms; # user_id => { room => 1 }
23             my $next_id = 1;
24              
25             sub new {
26 3     3 0 6414 my ($class, %args) = @_;
27              
28             return bless {
29             default_room => $args{default_room} // 'lobby',
30 3   50     25 max_rooms => $args{max_rooms} // 100,
      50        
31             }, $class;
32             }
33              
34             sub to_app {
35 3     3 0 6 my ($self) = @_;
36              
37 3         6 my $default_room = $self->{default_room};
38 3         4 my $max_rooms = $self->{max_rooms};
39              
40 3     3   56 return async sub {
41 3         4 my ($scope, $receive, $send) = @_;
42 3 50       8 die "Unsupported scope type: $scope->{type}" if $scope->{type} ne 'websocket';
43              
44 3         8 await $send->({ type => 'websocket.accept' });
45              
46 3         113 my $user_id = $next_id++;
47 3         5 my $username = "user_$user_id";
48 3         8 $user_rooms{$user_id} = {};
49              
50             # Join default room
51 3         6 _join_room($user_id, $username, $send, $default_room);
52              
53             # Send welcome message
54 3         52 await $send->({
55             type => 'websocket.send',
56             text => _encode({
57             type => 'welcome',
58             user_id => $user_id,
59             username => $username,
60             room => $default_room,
61             }),
62             });
63              
64 3         74 eval {
65 3         3 while (1) {
66 5         84 my $event = await $receive->();
67              
68 5 100       117 if ($event->{type} eq 'websocket.receive') {
    50          
69 2   50     3 my $data = eval { JSON::MaybeXS::decode_json($event->{text} // '{}') } // {};
  2   50     13  
70 2   50     4 my $cmd = $data->{type} // 'message';
71              
72 2 50       32 if ($cmd eq 'message') {
    50          
    50          
    100          
    50          
    50          
73             # Broadcast to current room(s)
74 0   0     0 my $msg = $data->{message} // '';
75 0         0 my $target_room = $data->{room};
76              
77             my @target_rooms = $target_room
78             ? ($target_room)
79 0 0       0 : keys %{$user_rooms{$user_id}};
  0         0  
80              
81 0         0 for my $room (@target_rooms) {
82 0 0       0 next unless $user_rooms{$user_id}{$room};
83 0         0 await _broadcast_to_room($room, {
84             type => 'message',
85             room => $room,
86             user_id => $user_id,
87             username => $username,
88             message => $msg,
89             timestamp => time(),
90             }, $user_id);
91             }
92             } elsif ($cmd eq 'join') {
93 0   0     0 my $room = $data->{room} // $default_room;
94 0 0 0     0 if (keys(%rooms) >= $max_rooms && !$rooms{$room}) {
95 0         0 await $send->({
96             type => 'websocket.send',
97             text => _encode({ type => 'error', message => 'Max rooms reached' }),
98             });
99             } else {
100 0         0 _join_room($user_id, $username, $send, $room);
101 0         0 await $send->({
102             type => 'websocket.send',
103             text => _encode({ type => 'joined', room => $room }),
104             });
105             }
106             } elsif ($cmd eq 'leave') {
107 0         0 my $room = $data->{room};
108 0 0 0     0 if ($room && $user_rooms{$user_id}{$room}) {
109 0         0 await _leave_room($user_id, $username, $room);
110 0         0 await $send->({
111             type => 'websocket.send',
112             text => _encode({ type => 'left', room => $room }),
113             });
114             }
115             } elsif ($cmd eq 'nick') {
116 1   33     3 my $new_name = $data->{username} // $username;
117 1         2 $new_name =~ s/[^\w\-]//g;
118 1 50       3 $new_name = substr($new_name, 0, 20) if length($new_name) > 20;
119              
120             # Update in all rooms
121 1         2 for my $room (keys %{$user_rooms{$user_id}}) {
  1         2  
122 1 50       2 if ($rooms{$room}{users}{$user_id}) {
123 1         2 $rooms{$room}{users}{$user_id}{name} = $new_name;
124             }
125             }
126 1         2 $username = $new_name;
127 1         3 await $send->({
128             type => 'websocket.send',
129             text => _encode({ type => 'nick', username => $username }),
130             });
131             } elsif ($cmd eq 'list') {
132 0         0 my $room = $data->{room};
133 0 0 0     0 if ($room && $rooms{$room}) {
134 0         0 my @users = map { $_->{name} } values %{$rooms{$room}{users}};
  0         0  
  0         0  
135 0         0 await $send->({
136             type => 'websocket.send',
137             text => _encode({ type => 'users', room => $room, users => \@users }),
138             });
139             }
140             } elsif ($cmd eq 'rooms') {
141             my @room_list = map {
142 1         24 { name => $_, count => scalar keys %{$rooms{$_}{users}} }
  1         2  
  1         5  
143             } keys %rooms;
144 1         3 await $send->({
145             type => 'websocket.send',
146             text => _encode({ type => 'rooms', rooms => \@room_list }),
147             });
148             }
149             } elsif ($event->{type} eq 'websocket.disconnect') {
150 3         26 last;
151             }
152             }
153             };
154              
155             # Cleanup - leave all rooms
156 3         3 for my $room (keys %{$user_rooms{$user_id}}) {
  3         6  
157 3         4 eval { _leave_room($user_id, $username, $room) };
  3         5  
158             }
159 3         39 delete $user_rooms{$user_id};
160 3         18 };
161             }
162              
163             sub _encode {
164 11     11   11 my ($data) = @_;
165              
166 11         50 return JSON::MaybeXS::encode_json($data);
167             }
168              
169             sub _join_room {
170 3     3   7 my ($user_id, $username, $send, $room) = @_;
171              
172 3   50     40 $rooms{$room} //= { users => {} };
173 3         10 $rooms{$room}{users}{$user_id} = { send => $send, name => $username };
174 3         5 $user_rooms{$user_id}{$room} = 1;
175              
176             # Notify others
177 3         11 _broadcast_to_room($room, {
178             type => 'user_joined',
179             room => $room,
180             user_id => $user_id,
181             username => $username,
182             }, $user_id);
183             }
184              
185 3     3   3 async sub _leave_room {
186 3         7 my ($user_id, $username, $room) = @_;
187              
188 3 50       4 return unless $rooms{$room};
189              
190 3         7 delete $rooms{$room}{users}{$user_id};
191 3         4 delete $user_rooms{$user_id}{$room};
192              
193             # Notify others
194 3         10 await _broadcast_to_room($room, {
195             type => 'user_left',
196             room => $room,
197             user_id => $user_id,
198             username => $username,
199             });
200              
201             # Cleanup empty room
202 3 50       64 delete $rooms{$room} if !keys %{$rooms{$room}{users}};
  3         14  
203             }
204              
205 6     6   6 async sub _broadcast_to_room {
206 6         8 my ($room, $data, $exclude_id) = @_;
207 6   100     14 $exclude_id //= undef;
208              
209 6 50       9 return unless $rooms{$room};
210              
211 6         11 my $json = _encode($data);
212 6         7 my $users = $rooms{$room}{users};
213              
214 6         17 for my $id (keys %$users) {
215 3 50 33     19 next if defined $exclude_id && $id eq $exclude_id;
216 0           eval {
217 0           await $users->{$id}{send}->({
218             type => 'websocket.send',
219             text => $json,
220             });
221             };
222 0 0         delete $users->{$id} if $@;
223             }
224             }
225              
226             1;
227              
228             __END__