]> git.sesse.net Git - skvidarsync/blob - bin/sync.pl
Split out find_diff() into its own function.
[skvidarsync] / bin / sync.pl
1 #! /usr/bin/perl
2 use strict;
3 use warnings;
4 no warnings qw(once);
5 use Crypt::JWT;
6 use JSON::XS;
7 use LWP::UserAgent;
8 use DBI;
9 use POSIX;
10 binmode STDOUT, ':utf8';
11 binmode STDERR, ':utf8';
12 use utf8;
13
14 # TODO:
15 # - detect moves between groups
16 # - better daemon behavior
17
18 require '../include/config.pm';
19
20 my @log = ();
21
22 sub get_oauth_bearer_token {
23         my $ua = shift;
24         my $now = time();
25         my $jwt = JSON::XS::encode_json({
26                 "iss" => $config::jwt_key->{'client_email'},
27                 "scope" => "https://www.googleapis.com/auth/spreadsheets",
28                 "aud" => "https://www.googleapis.com/oauth2/v4/token",
29                 "exp" => $now + 1800,
30                 "iat" => $now,
31         });
32         my $jws_token = Crypt::JWT::encode_jwt(payload=>$jwt, alg=>'RS256', key=>\$config::jwt_key->{'private_key'});
33         my $response = $ua->post('https://www.googleapis.com/oauth2/v4/token', [
34                 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
35                 'assertion' => $jws_token ]);
36         return JSON::XS::decode_json($response->decoded_content)->{'access_token'};
37 }
38
39 sub get_slack_name {
40         my ($ua, $userid) = @_;
41         my $req = HTTP::Request->new('GET', 'https://slack.com/api/users.info?user=' . $userid, [
42                'Authorization' => 'Bearer ' . $config::oauth_token
43         ]);
44         my $response = $ua->request($req);
45         die $response->status_line if !$response->is_success;
46
47         my $user_json = JSON::XS::decode_json($response->decoded_content);
48         die "Something went wrong: " . $response->decoded_content if (!defined($user_json) || !$user_json->{'ok'});
49
50         return $user_json->{'user'}{'real_name'};
51 }
52
53 sub get_spreadsheet_name {
54         my $cell = shift;
55         my $name = $cell->{'userEnteredValue'}{'stringValue'};
56         return undef if (!defined($name));
57         return undef if ($name =~ /^G[1-4]\.[1-5]/);
58         $name =~ s/šŸ†•//;
59         $name =~ s/\(.*\)//g;
60         $name =~ s/\[.*\]//g;
61         $name =~ s/ - .*//;
62         $name =~ s/G\d\.\d?\??//;
63         $name =~ s/\?//g;
64         $name =~ s/\s*$//;
65         $name =~ s/^\s*//;
66         return $name;
67 }
68
69 sub matches_name {
70         my ($slack_name, $spreadsheet_name) = @_;
71         if (lc($slack_name) eq lc($spreadsheet_name)) {
72                 return 1;
73         }
74
75         my @ap = split /\s+/, $slack_name;
76         my @bp = split /\s+/, $spreadsheet_name;
77         if (scalar @ap >= 2 && scalar @bp >= 2 && lc($ap[0]) eq lc($bp[0])) {
78                 # First name matches, try to match some surname
79                 my $found = 0;
80                 for my $ai (1..$#ap) {
81                         for my $bi (1..$#bp) {
82                                 $found = 1 if (lc($ap[$ai]) eq lc($bp[$bi]));
83                         }
84                 }
85                 if ($found) {
86                         skv_log("Fuzzy-matchet $slack_name -> $spreadsheet_name.");
87                         return 1;
88                 }
89         }
90
91         return 0;
92 }
93
94 sub format_cell_names_for_seen {
95         my $seen = shift;
96         my @cells = map { chr(ord('A') + $_->[2]) . $_->[1] } @$seen;
97         return join(', ', @cells);
98 }
99
100 sub skv_log {
101         my $msg = shift;
102         print STDERR "$msg\n";
103         push @log, $msg;
104 }
105
106 sub serialize_skv_log_to_sheet {
107         return {
108                 updateCells => {
109                         rows => [{
110                                 values => [{
111                                         userEnteredValue => { stringValue => join("\n", @log) }
112                                 }]
113                         }],
114                         fields => 'userEnteredValue.stringValue',
115                         range => {
116                                 sheetId => $config::log_tab_id,
117                                 startRowIndex => 0,
118                                 endRowIndex => 1,
119                                 startColumnIndex => 0,
120                                 endColumnIndex => 1
121                         }
122                 }
123         };
124 }
125
126 sub sheet_batch_update {
127         my ($ua, $token, @requests) = @_;
128         my $update = {
129                 requests => \@requests
130         };
131         my $response = $ua->post(
132                 'https://sheets.googleapis.com/v4/spreadsheets/' . $config::sheet_id . ':batchUpdate?key=' . $config::gsheets_api_key,
133                 Content => JSON::XS::encode_json($update),
134                 Content_type => 'application/json;charset=UTF-8',
135                 Authorization => 'Bearer ' . $token
136         );
137         die $response->decoded_content if !$response->is_success;
138 }
139
140 sub get_spreadsheet_with_title {
141         my ($ua, $token, $wanted_sheet_title) = @_;
142
143         # See if we have any spreadsheets that match this title.
144         my $response = $ua->get('https://sheets.googleapis.com/v4/spreadsheets/' . $config::sheet_id . '?key=' . $config::gsheets_api_key . '&fields=sheets/properties',
145                 Authorization => 'Bearer ' . $token
146         );
147         my $sheets_json = JSON::XS::decode_json($response->decoded_content);
148         my ($tab_name, $tab_id);
149         for my $sheet (@{$sheets_json->{'sheets'}}) {
150                 my $title = $sheet->{'properties'}{'title'};
151                 my $sheet_id = $sheet->{'properties'}{'sheetId'};
152                 if ($title =~ /\Q$wanted_sheet_title\E/) {
153                         # skv_log("Synkroniserer ($config::invitation_channel, $invitation_ts) mot arket ā€œ$titleā€ (fane-ID $sheet_id).");
154                         return ($title, $sheet_id);
155                 }
156         }
157         return (undef, undef);
158 }
159
160 # Make a mapping of lowercase name -> list of [canonical name, row number, column number]
161 sub find_where_each_name_is {
162         my $json = shift;
163
164         my %seen_names = ();
165         my $rows = $json->{'sheets'}[0]{'data'}[0]{'rowData'};
166         my $rowno = 4;
167         for my $row (@$rows) {
168                 my $colno = 0;
169                 for my $val (@{$row->{'values'}}) {
170                         my $name = get_spreadsheet_name($val);
171                         if (defined($name)) {
172                                 push @{$seen_names{lc $name}}, [$name, $rowno, $colno];
173                         }
174                         ++$colno;
175                 }
176                 ++$rowno;
177         }
178
179         return %seen_names;
180 }
181 # Add the reaction log. (This only takes into account the last change
182 # for each user; earlier ones are irrelevant and don't count. But it
183 # doesn't deduplicate across reactions. Meh.)
184 sub create_reaction_log {
185         my ($dbh, $invitation_ts, $slack_userid_to_real_name, $slack_userid_to_slack_name) = @_;
186
187         my $q = $dbh->prepare('select userid,event_type,reaction,to_char(event_ts,\'YYYY-mm-dd HH24:MI\') as event_ts from ( select distinct on (channel,ts,userid,reaction) userid,event_type,reaction,timestamptz \'1970-01-01 utc\' + event_ts::float * interval \'1 second\' as event_ts from reaction_log where channel=? and ts=? and reaction in (\'heart\',\'open_mouth\') order by channel,ts,userid,reaction,event_ts desc ) t1 where event_ts > current_timestamp - interval \'8 hours\' order by event_ts desc limit 50');
188         $q->execute($config::invitation_channel, $invitation_ts);
189         my @recent_changes = ();
190         while (my $ref = $q->fetchrow_hashref) {
191                 my $msg = $ref->{'event_ts'};
192                 if ($ref->{'event_type'} eq 'reaction_added') {
193                         $msg .= ' +';
194                 } else {
195                         $msg .= ' ā€“';
196                 }
197                 if ($ref->{'reaction'} eq 'open_mouth') {
198                         $msg .= 'šŸ˜®';
199                 } else {
200                         $msg .= 'ā¤ļø';
201                 }
202                 $msg .= ' ';
203                 if (exists($slack_userid_to_real_name->{$ref->{'userid'}})) {
204                         $msg .= $slack_userid_to_real_name->{$ref->{'userid'}};
205                 } elsif (exists($slack_userid_to_slack_name->{$ref->{'userid'}})) {
206                         $msg .= $slack_userid_to_slack_name->{$ref->{'userid'}} . ' (fant ikke regneark-navn)';
207                 } else {
208                         # Should only happen if we didn't see the initial reaction_add, only reaction_remove.
209                         $msg .= $ref->{'userid'} . ' (fant ikke Slack-navn)';
210                 }
211                 push @recent_changes, { values => [{ userEnteredValue => { stringValue => $msg } }] };
212         }
213         while (scalar @recent_changes < 50) {
214                 push @recent_changes, { values => [{ userEnteredValue => { stringValue => '' } }] };
215         }
216         return @recent_changes;
217 }
218
219 # Also applies the diff to the database (a bit ugly).
220 sub find_diff {
221         my ($dbh, $invitation_ts, $want_names, $have_names, $seen_names) = @_;
222
223         my @diffs = ();
224         for my $real_name (keys %$want_names) {
225                 next if (exists($have_names->{$real_name}));
226                 skv_log("Markerer at $real_name skal pĆ„ trening.");
227                 push @diffs, [
228                         $real_name,
229                         {
230                                 backgroundColor => {
231                                         red => 1,
232                                         green => 1,
233                                         blue => 0,
234                                         alpha => 1
235                                 }
236                         }
237                 ];
238                 $dbh->do('INSERT INTO applied (channel, ts, name) VALUES (?, ?, ?)', undef,
239                         $config::invitation_channel, $invitation_ts, $real_name);
240         }
241         for my $real_name (keys %$have_names) {
242                 next if (exists($want_names->{$real_name}));
243                 if (!exists($seen_names->{lc $real_name})) {
244                         # TODO: This can somehow come if we try to add someone who's not in the sheet, too?
245                         skv_log("Ƙnsket Ć„ fjerne at $real_name skulle pĆ„ trening, men de var ikke i regnearket lenger.");
246                 } else {
247                         skv_log("Fjerner at $real_name skal pĆ„ trening.");
248                         push @diffs, [
249                                 $real_name,
250                                 {
251                                         backgroundColor => {
252                                                 red => 1,
253                                                 green => 1,
254                                                 blue => 1,
255                                                 alpha => 0
256                                         }
257                                 }
258                         ];
259                         $dbh->do('DELETE FROM applied WHERE channel=? AND ts=? AND name=?', undef,
260                                 $config::invitation_channel, $invitation_ts, $real_name);
261                 }
262         }
263         return @diffs;
264 }
265
266 skv_log("Siste sync startet: " . POSIX::ctime(time));
267
268 # Initialize the handles we need for communication.
269 my $dbh = DBI->connect("dbi:Pg:dbname=$config::dbname;host=127.0.0.1", $config::dbuser, $config::dbpass, {RaiseError => 1})
270         or die "Could not connect to Postgres: " . DBI->errstr;
271 my $ua = LWP::UserAgent->new('SKVidarLang/1.0');
272 my $token = get_oauth_bearer_token($ua);
273
274 # Find the newest message, and what it is linked to.
275 # TODO: Support more than one, and test better for errors here.
276 my $q = $dbh->prepare('select * from message_sheet_link where channel=? order by ts desc limit 1');
277 $q->execute($config::invitation_channel);
278 my $linkref = $q->fetchrow_hashref;
279 my $invitation_ts = $linkref->{'ts'};
280 my $wanted_sheet_title = $linkref->{'sheet_title'};
281 die "Could not get newest sheet title" if (!defined($wanted_sheet_title));
282
283 my ($tab_name, $tab_id) = get_spreadsheet_with_title($ua, $token, $wanted_sheet_title);
284 if (!defined($tab_name)) {
285         skv_log("Fant ikke noen fane med ā€œ$wanted_sheet_titleā€ i navnet; kan ikke synkronisere.\n");
286         sheet_batch_update($ua, $token, [ serialize_skv_log_to_sheet() ]);
287         die;
288 }
289
290 # Find everyone who are marked as attending on Slack (via reactions).
291 $q = $dbh->prepare('SELECT DISTINCT userid FROM current_reactions WHERE channel=? AND ts=? AND reaction IN (\'heart\', \'open_mouth\')');
292 $q->execute($config::invitation_channel, $invitation_ts);
293 my @attending_userids = ();
294 while (my $ref = $q->fetchrow_hashref) {
295         push @attending_userids, $ref->{'userid'};
296 }
297
298 # Get the list of all people in the sheet (we're going to need them soon anyway).
299 my $response = $ua->get('https://sheets.googleapis.com/v4/spreadsheets/' . $config::sheet_id . '?key=' . $config::gsheets_api_key . '&ranges=' . $tab_name . '!A4:Z5000&fields=sheets/data/rowData/values/userEnteredValue',
300         Authorization => 'Bearer ' . $token
301 );
302 my $main_sheet_json = JSON::XS::decode_json($response->decoded_content);
303
304 my %seen_names = find_where_each_name_is($main_sheet_json);
305
306 # Find duplicates.
307 for my $name (sort keys %seen_names) {
308         my $seen = $seen_names{$name};
309         if (scalar @$seen >= 2) {
310                 my $exemplar = $seen->[0][0];
311                 skv_log("Duplikat: $exemplar (" . format_cell_names_for_seen($seen) . ")");
312         }
313 }
314
315 # Get our existing Slack->name mapping, from the sheets.
316 my %slack_userid_to_real_name = ();
317 my %slack_userid_to_slack_name = ();
318 my %slack_userid_to_row = ();
319 $response = $ua->get('https://sheets.googleapis.com/v4/spreadsheets/' . $config::sheet_id . '?key=' . $config::gsheets_api_key . '&ranges=Slack-mapping!A5:C5000&fields=sheets/data/rowData/values/userEnteredValue',
320         Authorization => 'Bearer ' . $token
321 );
322 my $mapping_sheet_json = JSON::XS::decode_json($response->decoded_content);
323 my $mapping_sheet_rows = $mapping_sheet_json->{'sheets'}[0]{'data'}[0]{'rowData'};
324 my $cur_row = 5;
325 for my $row (@$mapping_sheet_rows) {
326         my $slack_id = $row->{'values'}[0]{'userEnteredValue'}{'stringValue'};
327         my $slack_name = $row->{'values'}[1]{'userEnteredValue'}{'stringValue'};
328         my $real_name = get_spreadsheet_name($row->{'values'}[2]);  # TODO support more
329         $slack_userid_to_row{$slack_id} = $cur_row++;
330         next if (!defined($slack_name));
331         $slack_userid_to_slack_name{$slack_id} = $slack_name;
332         next if (!defined($real_name));
333         $slack_userid_to_real_name{$slack_id} = $real_name;
334 }
335
336 # See which ones we don't have a mapping for, and look them up in Slack.
337 # TODO: Use an append call instead of $cur_row?
338 my @slack_mapping_updates = ();
339 for my $userid (@attending_userids) {
340         next if (exists($slack_userid_to_real_name{$userid}));
341
342         # Make sure they have a row in the spreadsheet.
343         my $write_row;
344         if (exists($slack_userid_to_row{$userid})) {
345                 $write_row = $slack_userid_to_row{$userid};
346         } else {
347                 $write_row = $cur_row++;
348                 $slack_userid_to_row{$userid} = $write_row;
349                 push @slack_mapping_updates, {
350                         range => "Slack-mapping!A$write_row:A$write_row",
351                         values => [ [ $userid ]]
352                 };
353         }
354
355         # Fetch their Slack name if we don't already have it.
356         my $slack_name;
357         if (exists($slack_userid_to_slack_name{$userid})) {
358                 $slack_name = $slack_userid_to_slack_name{$userid};
359         } else {
360                 $slack_userid_to_slack_name{$userid} = $slack_name;
361                 $slack_name = get_slack_name($ua, $userid);
362                 push @slack_mapping_updates, {
363                         range => "Slack-mapping!B$write_row:B$write_row",
364                         values => [ [ $slack_name ]]
365                 };
366                 $slack_userid_to_slack_name{$userid} = $slack_name;
367         }
368
369         if (exists($seen_names{lc $slack_name})) {
370                 # The name exists exactly, once or more, so it's a direct match and we ignore any fuzz.
371                 $slack_userid_to_real_name{$userid} = $slack_name;
372                 push @slack_mapping_updates, {
373                         range => "Slack-mapping!C$write_row:C$write_row",
374                         values => [ [ $slack_name ]]
375                 };
376         } else {
377                 # Do a search through all the available names in the sheet to find an obvious(ish) match.
378                 my @candidates = ();
379                 my $main_sheet_rows = $main_sheet_json->{'sheets'}[0]{'data'}[0]{'rowData'};
380                 for my $row (@$main_sheet_rows) {
381                         for my $val (@{$row->{'values'}}) {
382                                 my $name = get_spreadsheet_name($val);
383                                 if (defined($name) && matches_name($slack_name, $name)) {
384                                         push @candidates, $name;
385                                 }
386                         }
387                 }
388                 if ($#candidates == -1) {
389                         skv_log("$slack_name ($userid) er pĆ„meldt pĆ„ Slack, men fant ikke et regneark-navn for dem.");
390                 } elsif ($#candidates == 0) {
391                         my $name = $candidates[0];
392                         $slack_userid_to_real_name{$userid} = $name;
393                         push @slack_mapping_updates, {
394                                 range => "Slack-mapping!C$write_row:C$write_row",
395                                 values => [ [ $name ]]
396                         };
397                 } else {
398                         skv_log("$slack_name ($userid) er pĆ„meldt pĆ„ Slack, men hadde flere fuzzy-matcher; vet ikke hvilket regneark-navn som skal brukes.");
399                 }
400         }
401 }
402 my $update = {
403         valueInputOption => 'USER_ENTERED',
404         data => \@slack_mapping_updates
405 };
406 $response = $ua->post(
407         'https://sheets.googleapis.com/v4/spreadsheets/' . $config::sheet_id . '/values:batchUpdate?key=' . $config::gsheets_api_key,
408         Content => JSON::XS::encode_json($update),
409         Content_type => 'application/json;charset=UTF-8',
410         Authorization => 'Bearer ' . $token
411 );
412 die $response->decoded_content if (!$response->is_success);
413
414 # Find the list of names to mark yellow.
415 my %want_names = ();
416 my $main_sheet_rows = $main_sheet_json->{'sheets'}[0]{'data'}[0]{'rowData'};
417 for my $userid (@attending_userids) {
418         next if (!exists($slack_userid_to_real_name{$userid}));
419         my $slack_name = $slack_userid_to_slack_name{$userid};
420         my $real_name = $slack_userid_to_real_name{$userid};
421
422         # See if we can find them in the spreadsheet.
423         if (!exists($seen_names{lc $real_name})) {
424                 # TODO: Perhaps move this logic further down, for consistency?
425                 skv_log("$slack_name ($userid) er pĆ„meldt pĆ„ Slack, og er mappet til $real_name, men var ikke i noen gruppe.");
426         } else {
427                 my $seen = $seen_names{lc $real_name};
428                 if (scalar @$seen >= 2) {
429                         skv_log("$slack_name ($userid) er pĆ„meldt pĆ„ Slack, men stĆ„r flere steder (se over); vet ikke hvilken celle som skal brukes.");
430                 } else {
431                         $want_names{$seen->[0][0]} = 1;
432                 }
433         }
434 }
435
436 # Find the list of names we already marked yellow.
437 my %have_names = ();
438 $dbh->{AutoCommit} = 0;
439 $dbh->do('SET TRANSACTION ISOLATION LEVEL SERIALIZABLE');
440 $q = $dbh->prepare('SELECT name FROM applied WHERE channel=? AND ts=?');
441 $q->execute($config::invitation_channel, $invitation_ts);
442 while (my $ref = $q->fetchrow_hashref) {
443         $have_names{$ref->{'name'}} = 1;
444 }
445
446 my @diffs = find_diff($dbh, $invitation_ts, \%want_names, \%have_names, \%seen_names);
447
448 my @yellow_updates = ();
449 if (scalar @diffs > 0) {
450         # Now fill in the actual stuff.
451         for my $diff (@diffs) {
452                 my $real_name = $diff->[0];
453
454                 # See if we can find them in the spreadsheet.
455                 my $seen = $seen_names{lc $real_name};
456                 die if (!defined($seen) || scalar @$seen > 1);
457                 my $rowno = $seen->[0][1];
458                 my $colno = $seen->[0][2];
459                 push @yellow_updates, {
460                         updateCells => {
461                                 rows => [{
462                                         values => [{
463                                                 userEnteredFormat => $diff->[1]
464                                         }]
465                                 }],
466                                 fields => 'userEnteredFormat.backgroundColor',
467                                 range => {
468                                         sheetId => $tab_id,
469                                         startRowIndex => $rowno,
470                                         endRowIndex => $rowno + 1,
471                                         startColumnIndex => $colno,
472                                         endColumnIndex => $colno + 1
473                                 }
474                         }
475                 };
476         }
477 }
478
479 my @recent_changes = create_reaction_log($dbh, $invitation_ts, \%slack_userid_to_real_name, \%slack_userid_to_slack_name);
480 push @yellow_updates, {
481         updateCells => {
482                 rows => \@recent_changes,
483                 fields => 'userEnteredValue.stringValue',
484                 range => {
485                         sheetId => $config::log_tab_id,
486                         startRowIndex => 4,
487                         endRowIndex => 4 + scalar @recent_changes,
488                         startColumnIndex => 0,
489                         endColumnIndex => 1
490                 }
491         }
492 };
493
494 # Push the final set of updates (including the log).
495 skv_log("Ferdig.");
496 push @yellow_updates, serialize_skv_log_to_sheet();
497 sheet_batch_update($ua, $token, \@yellow_updates);
498 $dbh->commit;