]> git.sesse.net Git - skvidarsync/blob - bin/sync.pl
Use an ephemeral message for nagging about the spreadsheet; hopefully more visible...
[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 use Time::HiRes;
11 binmode STDOUT, ':utf8';
12 binmode STDERR, ':utf8';
13 use utf8;
14
15 require '../include/config.pm';
16
17 my $dbh;
18 my @log = ();
19
20 my %rgb = (
21         yellow => {
22                 red => 1,
23                 green => 1,
24                 blue => 0,
25                 alpha => 1
26         },
27         blue => {
28                 red => 0,
29                 green => 1,
30                 blue => 1,
31                 alpha => 1
32         },
33         white => {
34                 red => 1,
35                 green => 1,
36                 blue => 1,
37                 alpha => 0
38         }
39 );
40
41 sub get_oauth_bearer_token {
42         my $ua = shift;
43         my $now = time();
44         my $jwt = JSON::XS::encode_json({
45                 "iss" => $config::jwt_key->{'client_email'},
46                 "scope" => "https://www.googleapis.com/auth/spreadsheets",
47                 "aud" => "https://www.googleapis.com/oauth2/v4/token",
48                 "exp" => $now + 1800,
49                 "iat" => $now,
50         });
51         my $jws_token = Crypt::JWT::encode_jwt(payload=>$jwt, alg=>'RS256', key=>\$config::jwt_key->{'private_key'});
52         my $response = $ua->post('https://www.googleapis.com/oauth2/v4/token', [
53                 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
54                 'assertion' => $jws_token ]);
55         return JSON::XS::decode_json($response->decoded_content)->{'access_token'};
56 }
57
58 sub get_slack_name {
59         my ($ua, $userid) = @_;
60         my $req = HTTP::Request->new('GET', 'https://slack.com/api/users.info?user=' . $userid, [
61                'Authorization' => 'Bearer ' . $config::slack_oauth_token
62         ]);
63         my $response = $ua->request($req);
64         die $response->status_line if !$response->is_success;
65
66         my $user_json = JSON::XS::decode_json($response->decoded_content);
67         die "Something went wrong: " . $response->decoded_content if (!defined($user_json) || !$user_json->{'ok'});
68
69         return $user_json->{'user'}{'real_name'};
70 }
71
72 sub get_spreadsheet_name {
73         my $cell = shift;
74         my $name = $cell->{'userEnteredValue'}{'stringValue'};
75         return undef if (!defined($name));
76         return undef if ($name =~ /^G[1-4]\.[1-5]/);
77         $name =~ s/šŸ†•//;
78         $name =~ s/\(.*\)//g;
79         $name =~ s/\[.*\]//g;
80         $name =~ s/ - .*//;
81         $name =~ s/G\d\.\d?\??//;
82         $name =~ s/\?//g;
83         $name =~ s/\s*$//;
84         $name =~ s/^\s*//;
85         return $name;
86 }
87
88 sub matches_name {
89         my ($slack_name, $spreadsheet_name) = @_;
90         if (lc($slack_name) eq lc($spreadsheet_name)) {
91                 return 1;
92         }
93
94         my @ap = split /\s+/, $slack_name;
95         my @bp = split /\s+/, $spreadsheet_name;
96         if (scalar @ap >= 2 && scalar @bp >= 2 && lc($ap[0]) eq lc($bp[0])) {
97                 # First name matches, try to match some surname
98                 my $found = 0;
99                 for my $ai (1..$#ap) {
100                         for my $bi (1..$#bp) {
101                                 $found = 1 if (lc($ap[$ai]) eq lc($bp[$bi]));
102                         }
103                 }
104                 if ($found) {
105                         skv_log("Fuzzy-matchet $slack_name -> $spreadsheet_name.");
106                         return 1;
107                 }
108         }
109
110         return 0;
111 }
112
113 sub format_cell_names_for_seen {
114         my $seen = shift;
115         my @cells = map { chr(ord('A') + $_->[2]) . ($_->[1] + 1) } @$seen;
116         return join(', ', @cells);
117 }
118
119 sub skv_log {
120         my $msg = shift;
121         print STDERR "$msg\n";
122         push @log, $msg;
123 }
124
125 sub serialize_skv_log_to_sheet {
126         return {
127                 updateCells => {
128                         rows => [{
129                                 values => [{
130                                         userEnteredValue => { stringValue => join("\n", @log) }
131                                 }]
132                         }],
133                         fields => 'userEnteredValue.stringValue',
134                         range => {
135                                 sheetId => $config::log_tab_id,
136                                 startRowIndex => 0,
137                                 endRowIndex => 1,
138                                 startColumnIndex => 0,
139                                 endColumnIndex => 1
140                         }
141                 }
142         };
143 }
144
145 sub sheet_batch_update {
146         my ($ua, $token, @requests) = @_;
147         my $update = {
148                 requests => \@requests
149         };
150         my $response = $ua->post(
151                 'https://sheets.googleapis.com/v4/spreadsheets/' . $config::sheet_id . ':batchUpdate?key=' . $config::gsheets_api_key,
152                 Content => JSON::XS::encode_json($update),
153                 Content_type => 'application/json;charset=UTF-8',
154                 Authorization => 'Bearer ' . $token
155         );
156         die $response->decoded_content if !$response->is_success;
157 }
158
159 sub get_group_assignments {
160         my $json = shift;
161
162         my %assignments = ();
163         my $rows = $json->{'sheets'}[0]{'data'}[0]{'rowData'};
164         my @curr_groups = ();
165         for my $row (@$rows) {
166                 my $col = 0;
167                 for my $val (@{$row->{'values'}}) {
168                         ++$col;
169                         my $contents = $val->{'userEnteredValue'}{'stringValue'};
170                         next if !defined($contents);
171                         if ($contents =~ /Gruppe /) {
172                                 @curr_groups = ();
173                                 last;
174                         }
175                         next if $contents =~ /^VL:/;
176                         next if $contents =~ /^LT\b/;
177                         next if $contents =~ /^400m/;
178                         next if $contents =~ /^546m/;
179                         if ($contents =~ /^(G\d\.\d)/ || $contents =~ /^(Nye lĆøpere.*)/) {
180                                 $curr_groups[$col] = $1;
181                         } else {
182                                 my $name = get_spreadsheet_name($val);
183                                 next if (!defined($name));
184                                 my $group = $curr_groups[$col] // $curr_groups[$col - 1];
185                                 # print $group, " ", $name, "\n";
186                                 if (exists($assignments{$name})) {
187                                         $assignments{$name} = "(flere grupper)";
188                                 } else {
189                                         $assignments{$name} = $group;
190                                 }
191                         }
192                 }
193         }
194         return %assignments;
195 }
196
197 sub update_assignment_db {
198         my ($dbh, $channel, $ts, $assignments) = @_;
199
200         local $dbh->{AutoCommit} = 0;
201         my %db_assignments = ();
202         my $q = $dbh->prepare('SELECT name,group_name FROM current_group_membership_history WHERE channel=? AND ts=?');
203         $q->execute($channel, $ts);
204         while (my $ref = $q->fetchrow_hashref) {
205                 if (defined($ref->{'group_name'})) {
206                         $db_assignments{$ref->{'name'}} = $ref->{'group_name'};
207                 }
208         }
209
210         $q = $dbh->prepare('INSERT INTO group_membership_history (channel, ts, name, change_seen, group_name) VALUES (?, ?, ?, CURRENT_TIMESTAMP, ?)');
211         for my $name (keys %$assignments) {
212                 if (!exists($db_assignments{$name}) || $db_assignments{$name} ne $assignments->{$name}) {
213                         $q->execute($channel, $ts, $name, $assignments->{$name});
214                 }
215         }
216         for my $name (keys %db_assignments) {
217                 if (!exists($assignments->{$name})) {
218                         $q->execute($channel, $ts, $name, undef);
219                 }
220         }
221         $dbh->commit;
222 }
223
224 sub get_spreadsheet_with_title {
225         my ($ua, $token, $wanted_sheet_title) = @_;
226
227         # See if we have any spreadsheets that match this title.
228         my $response = $ua->get('https://sheets.googleapis.com/v4/spreadsheets/' . $config::sheet_id . '?key=' . $config::gsheets_api_key . '&fields=sheets/properties',
229                 Authorization => 'Bearer ' . $token
230         );
231         my $sheets_json = JSON::XS::decode_json($response->decoded_content);
232         my ($tab_name, $tab_id);
233         for my $sheet (@{$sheets_json->{'sheets'}}) {
234                 my $title = $sheet->{'properties'}{'title'};
235                 my $sheet_id = $sheet->{'properties'}{'sheetId'};
236                 if ($title =~ /\Q$wanted_sheet_title\E/) {
237                         # skv_log("Synkroniserer ($config::invitation_channel, $invitation_ts) mot arket ā€œ$titleā€ (fane-ID $sheet_id).");
238                         return ($title, $sheet_id);
239                 }
240         }
241         return (undef, undef);
242 }
243
244 # Make a mapping of lowercase name -> list of [canonical name, row number, column number]
245 sub find_where_each_name_is {
246         my $json = shift;
247
248         my %seen_names = ();
249         my $rows = $json->{'sheets'}[0]{'data'}[0]{'rowData'};
250         my $rowno = 3;
251         for my $row (@$rows) {
252                 my $colno = 0;
253                 for my $val (@{$row->{'values'}}) {
254                         my $name = get_spreadsheet_name($val);
255                         if (defined($name)) {
256                                 push @{$seen_names{lc $name}}, [$name, $rowno, $colno];
257                         }
258                         ++$colno;
259                 }
260                 ++$rowno;
261         }
262
263         return %seen_names;
264 }
265
266 sub best_name_for_log {
267         my ($userid, $slack_userid_to_real_name, $slack_userid_to_slack_name) = @_;
268         if (exists($slack_userid_to_real_name->{$userid})) {
269                 return $slack_userid_to_real_name->{$userid};
270         } elsif (exists($slack_userid_to_slack_name->{$userid})) {
271                 return $slack_userid_to_slack_name->{$userid} . ' (fant ikke regneark-navn)';
272         } else {
273                 # Should only happen if we didn't see the initial reaction_add, only reaction_remove.
274                 # (TODO: Is the comment above true anymore, now that we use this from multiple contexts?)
275                 return $userid . ' (fant ikke Slack-navn)';
276         }
277 }
278
279 # Add the reaction log. (This only takes into account the last change
280 # for each user; earlier ones are irrelevant and don't count. But it
281 # doesn't deduplicate across reactions. Meh.)
282 sub create_reaction_log {
283         my ($dbh, $invitation_ts, $slack_userid_to_real_name, $slack_userid_to_slack_name) = @_;
284
285         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\',\'blue_heart\') 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');
286         $q->execute($config::invitation_channel, $invitation_ts);
287         my @recent_changes = ();
288         while (my $ref = $q->fetchrow_hashref) {
289                 my $msg = $ref->{'event_ts'};
290                 if ($ref->{'event_type'} eq 'reaction_added') {
291                         $msg .= ' +';
292                 } else {
293                         $msg .= ' ā€“';
294                 }
295                 if ($ref->{'reaction'} eq 'open_mouth') {
296                         $msg .= 'šŸ˜®';
297                 } elsif ($ref->{'reaction'} eq 'blue_heart') {
298                         $msg .= 'šŸ’™';
299                 } else {
300                         $msg .= 'ā¤ļø';
301                 }
302                 $msg .= ' ';
303                 $msg .= best_name_for_log($ref->{'userid'}, $slack_userid_to_real_name, $slack_userid_to_slack_name);
304                 push @recent_changes, { values => [{ userEnteredValue => { stringValue => $msg } }] };
305         }
306         while (scalar @recent_changes < 50) {
307                 push @recent_changes, { values => [{ userEnteredValue => { stringValue => '' } }] };
308         }
309         return @recent_changes;
310 }
311
312 sub create_move_log {
313          my ($dbh, $invitation_ts, $prev_invitation_ts) = @_;
314          my $q = $dbh->prepare(<<"EOF");
315 SELECT
316   name, g_old.group_name as old_group, g_new.group_name as new_group, TO_CHAR(g_new.change_seen, \'YYYY-mm-dd HH24:MI\') AS change_seen
317 FROM ( SELECT * FROM current_group_membership_history WHERE ts=? ) g_old
318   FULL OUTER JOIN ( SELECT * FROM current_group_membership_history WHERE ts=? ) g_new USING (name)
319 WHERE
320   g_new.group_name IS DISTINCT FROM g_old.group_name
321   AND g_new.group_name IS NOT NULL
322 ORDER BY g_new.change_seen DESC, name
323 LIMIT 50
324 EOF
325         $q->execute($prev_invitation_ts, $invitation_ts);
326         my @recent_moves = ();
327         while (my $ref = $q->fetchrow_hashref) {
328                 my $name = $ref->{'name'};
329                 my $old_group = $ref->{'old_group'};
330                 my $new_group = $ref->{'new_group'};
331
332                 my $msg = $ref->{'change_seen'} . " ";
333                 if (!defined($old_group)) {
334                         $msg .= "$name, (ny lĆøper) ā†’ $new_group";
335                 } else {
336                         $msg .= "$name, $old_group ā†’ $new_group";
337                 }
338                 push @recent_moves, { values => [{ userEnteredValue => { stringValue => $msg } }] };
339         }
340         while (scalar @recent_moves < 50) {
341                 push @recent_moves, { values => [{ userEnteredValue => { stringValue => '' } }] };
342         }
343         return @recent_moves;
344 }
345
346 # Also applies the diff to the database (a bit ugly).
347 sub find_diff {
348         my ($dbh, $invitation_ts, $want_colors, $have_colors, $seen_names) = @_;
349
350         my @diffs = ();
351         for my $real_name (keys %$want_colors) {
352                 my $wc = $want_colors->{$real_name};
353                 if (exists($have_colors->{$real_name})) {
354                         if ($have_colors->{$real_name} eq $wc) {
355                                 # Already good.
356                                 next;
357                         }
358                         skv_log("Markerer at $real_name har byttet treningssted.");
359                         push @diffs, [
360                                 $real_name, { backgroundColor => $rgb{$wc} }
361                         ];
362                         $dbh->do('UPDATE applied SET color=? WHERE channel=? AND ts=? AND name=?', undef,
363                                 $wc, $config::invitation_channel, $invitation_ts, $real_name);
364                 } else {
365                         skv_log("Markerer at $real_name skal pĆ„ trening.");
366                         push @diffs, [
367                                 $real_name, { backgroundColor => $rgb{$wc} }
368                         ];
369                         $dbh->do('INSERT INTO applied (channel, ts, name, color) VALUES (?, ?, ?, ?)', undef,
370                                 $config::invitation_channel, $invitation_ts, $real_name, $wc);
371                 }
372         }
373         for my $real_name (keys %$have_colors) {
374                 next if (exists($want_colors->{$real_name}));
375                 if (!exists($seen_names->{lc $real_name})) {
376                         # TODO: This can somehow come if we try to add someone who's not in the sheet, too?
377                         skv_log("Ƙnsket Ć„ fjerne at $real_name skulle pĆ„ trening, men de var ikke i regnearket lenger.");
378                 } elsif (scalar @{$seen_names->{lc $real_name}} > 1) {
379                         # Don't touch them.
380                 } else {
381                         skv_log("Fjerner at $real_name skal pĆ„ trening.");
382                         push @diffs, [
383                                 $real_name, { backgroundColor => $rgb{white} }
384                         ];
385                         $dbh->do('DELETE FROM applied WHERE channel=? AND ts=? AND name=?', undef,
386                                 $config::invitation_channel, $invitation_ts, $real_name);
387                 }
388         }
389         return @diffs;
390 }
391
392 sub possibly_nag_user {
393         my ($dbh, $ua, $userid) = @_;
394
395         # See if we've nagged this user before.
396         my $q = $dbh->prepare('SELECT * FROM users_nagged WHERE userid=?');
397         $q->execute($userid);
398         if (defined($q->fetchrow_hashref)) {
399                 return;
400         }
401
402         my $msg = "Hei! Du meldte deg akkurat pĆ„ trening, men vi klarer ikke Ć„ finne deg i en gruppe i regnearket. For at det skal vƦre enklere for trenerne, Ćønsker vi gjerne at du gĆ„r inn pĆ„ https://regneark.skvidar.run/ og skriver deg inn der med samme navn som du bruker pĆ„ Slack. Om du er usikker pĆ„ hvilken gruppe som passer for deg, ta gjerne kontakt med en trener. Velkommen pĆ„ trening og til klubben!";
403
404         skv_log("Sender melding til $userid for Ć„ spĆørre om gruppe.");
405         my $content = {
406                 channel => $config::invitation_channel,
407                 user => $userid,
408                 text => $msg
409         };
410         my $response = $ua->post(
411                 'https://slack.com/api/chat.postEphemeral',
412                 Content => JSON::XS::encode_json($content),
413                 Content_type => 'application/json;charset=UTF-8',
414                 Authorization => 'Bearer ' . $config::slack_oauth_token
415         );
416         die $response->status_line if !$response->is_success;
417         my $msg_json = JSON::XS::decode_json($response->decoded_content);
418         die "Something went wrong: " . $response->decoded_content if (!defined($msg_json) || !$msg_json->{'ok'});
419
420         # Mark that we've sent the message, so it won't happen again.
421         $dbh->do('INSERT INTO users_nagged (userid, last_nag) VALUES (?, CURRENT_TIMESTAMP)', undef, $userid);
422 }
423
424 sub run {
425         my $start = [Time::HiRes::gettimeofday];
426
427         @log = ();
428         skv_log("Siste sync startet: " . POSIX::ctime(time));
429
430         # Initialize the handles we need for communication.
431         $dbh = DBI->connect("dbi:Pg:dbname=$config::dbname;host=127.0.0.1", $config::dbuser, $config::dbpass, {RaiseError => 1})
432                 or die "Could not connect to Postgres: " . DBI->errstr;
433         my $ua = LWP::UserAgent->new('SKVidarLang/1.0');
434         my $token = get_oauth_bearer_token($ua);
435
436         # Find the newest message, what it is linked to, and what was the one before it (for group diffing).
437         # TODO: Support more than one, and test better for errors here.
438         my $q = $dbh->prepare('select * from message_sheet_link where channel=? order by ts desc limit 2');
439         $q->execute($config::invitation_channel);
440         my $linkref = $q->fetchrow_hashref;
441         my $invitation_ts = $linkref->{'ts'};
442         my $wanted_sheet_title = $linkref->{'sheet_title'};
443         die "Could not get newest sheet title" if (!defined($wanted_sheet_title));
444
445         my ($tab_name, $tab_id) = get_spreadsheet_with_title($ua, $token, $wanted_sheet_title);
446         if (!defined($tab_name)) {
447                 skv_log("Fant ikke noen fane med Ā«$wanted_sheet_titleĀ» i navnet; kan ikke synkronisere.\n");
448                 sheet_batch_update($ua, $token, [ serialize_skv_log_to_sheet() ]);
449                 die;
450         }
451
452         # Store away the second-newest ID.
453         my $prev_invitation_ts = $q->fetchrow_hashref->{'ts'};
454
455         # Find everyone who are marked as attending on Slack (via reactions).
456         $q = $dbh->prepare('SELECT DISTINCT userid,reaction FROM current_reactions WHERE channel=? AND ts=? AND reaction IN (\'heart\', \'open_mouth\', \'blue_heart\')');
457         $q->execute($config::invitation_channel, $invitation_ts);
458         my @attending_userids = ();
459         my %colors = ();
460         my %double = ();
461         while (my $ref = $q->fetchrow_hashref) {
462                 my $userid = $ref->{'userid'};
463                 push @attending_userids, $userid;
464                 if ($ref->{'reaction'} eq 'blue_heart') {
465                         if (exists($colors{$userid}) && $colors{$userid} eq 'yellow') {
466                                 $double{$userid} = 1;
467                         }
468                         $colors{$userid} = 'blue';
469                 } else {
470                         if (exists($colors{$userid}) && $colors{$userid} eq 'blue') {
471                                 $double{$userid} = 1;
472                         }
473                         $colors{$userid} = 'yellow';
474                 }
475         }
476
477         # Remove double-attenders (we will log them as warnings further down).
478         @attending_userids = grep { !exists($double{$_}) } @attending_userids;
479         for my $userid (keys %double) {
480                 delete $colors{$userid};
481         }
482
483         # Get the list of all people in the sheet (we're going to need them soon anyway).
484         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',
485                 Authorization => 'Bearer ' . $token
486         );
487         my $main_sheet_json = JSON::XS::decode_json($response->decoded_content);
488
489         # Update the list of groups we've seen people in.
490         my %assignments = get_group_assignments($main_sheet_json);
491         update_assignment_db($dbh, $config::invitation_channel, $invitation_ts, \%assignments);
492
493         my %seen_names = find_where_each_name_is($main_sheet_json);
494
495         # Find duplicates.
496         for my $name (sort keys %seen_names) {
497                 my $seen = $seen_names{$name};
498                 if (scalar @$seen >= 2) {
499                         my $exemplar = $seen->[0][0];
500                         skv_log("Duplikat: $exemplar (" . format_cell_names_for_seen($seen) . ")");
501                 }
502         }
503
504         # Get our existing Slack->name mapping, from the sheets.
505         my %slack_userid_to_real_name = ();
506         my %slack_userid_to_slack_name = ();
507         my %slack_userid_to_row = ();
508         $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',
509                 Authorization => 'Bearer ' . $token
510         );
511         my $mapping_sheet_json = JSON::XS::decode_json($response->decoded_content);
512         my $mapping_sheet_rows = $mapping_sheet_json->{'sheets'}[0]{'data'}[0]{'rowData'};
513         my $cur_row = 5;
514         for my $row (@$mapping_sheet_rows) {
515                 my $slack_id = $row->{'values'}[0]{'userEnteredValue'}{'stringValue'};
516                 my $slack_name = $row->{'values'}[1]{'userEnteredValue'}{'stringValue'};
517                 my $real_name = get_spreadsheet_name($row->{'values'}[2]);  # TODO support more
518                 $slack_userid_to_row{$slack_id} = $cur_row++;
519                 next if (!defined($slack_name));
520                 $slack_userid_to_slack_name{$slack_id} = $slack_name;
521                 next if (!defined($real_name));
522                 $slack_userid_to_real_name{$slack_id} = $real_name;
523         }
524
525         # See which ones we don't have a mapping for, and look them up in Slack.
526         # TODO: Use an append call instead of $cur_row?
527         my @slack_mapping_updates = ();
528         for my $userid (@attending_userids) {
529                 next if (exists($slack_userid_to_real_name{$userid}));
530
531                 # Make sure they have a row in the spreadsheet.
532                 my $write_row;
533                 if (exists($slack_userid_to_row{$userid})) {
534                         $write_row = $slack_userid_to_row{$userid};
535                 } else {
536                         $write_row = $cur_row++;
537                         $slack_userid_to_row{$userid} = $write_row;
538                         push @slack_mapping_updates, {
539                                 range => "Slack-mapping!A$write_row:A$write_row",
540                                 values => [ [ $userid ]]
541                         };
542                 }
543
544                 # Fetch their Slack name if we don't already have it.
545                 my $slack_name;
546                 if (exists($slack_userid_to_slack_name{$userid})) {
547                         $slack_name = $slack_userid_to_slack_name{$userid};
548                 } else {
549                         $slack_userid_to_slack_name{$userid} = $slack_name;
550                         $slack_name = get_slack_name($ua, $userid);
551                         push @slack_mapping_updates, {
552                                 range => "Slack-mapping!B$write_row:B$write_row",
553                                 values => [ [ $slack_name ]]
554                         };
555                         $slack_userid_to_slack_name{$userid} = $slack_name;
556                 }
557
558                 if (exists($seen_names{lc $slack_name})) {
559                         # The name exists exactly, once or more, so it's a direct match and we ignore any fuzz.
560                         $slack_userid_to_real_name{$userid} = $slack_name;
561                         push @slack_mapping_updates, {
562                                 range => "Slack-mapping!C$write_row:C$write_row",
563                                 values => [ [ $slack_name ]]
564                         };
565                 } else {
566                         # Do a search through all the available names in the sheet to find an obvious(ish) match.
567                         my @candidates = ();
568                         my $main_sheet_rows = $main_sheet_json->{'sheets'}[0]{'data'}[0]{'rowData'};
569                         for my $row (@$main_sheet_rows) {
570                                 for my $val (@{$row->{'values'}}) {
571                                         my $name = get_spreadsheet_name($val);
572                                         if (defined($name) && matches_name($slack_name, $name)) {
573                                                 push @candidates, $name;
574                                         }
575                                 }
576                         }
577                         if ($#candidates == -1) {
578                                 skv_log("$slack_name ($userid) er pĆ„meldt pĆ„ Slack, men fant ikke et regneark-navn for dem.");
579                                 possibly_nag_user($dbh, $ua, $userid);
580                         } elsif ($#candidates == 0) {
581                                 my $name = $candidates[0];
582                                 $slack_userid_to_real_name{$userid} = $name;
583                                 push @slack_mapping_updates, {
584                                         range => "Slack-mapping!C$write_row:C$write_row",
585                                         values => [ [ $name ]]
586                                 };
587                         } else {
588                                 skv_log("$slack_name ($userid) er pĆ„meldt pĆ„ Slack, men hadde flere fuzzy-matcher; vet ikke hvilket regneark-navn som skal brukes.");
589                         }
590                 }
591         }
592         my $update = {
593                 valueInputOption => 'USER_ENTERED',
594                 data => \@slack_mapping_updates
595         };
596         $response = $ua->post(
597                 'https://sheets.googleapis.com/v4/spreadsheets/' . $config::sheet_id . '/values:batchUpdate?key=' . $config::gsheets_api_key,
598                 Content => JSON::XS::encode_json($update),
599                 Content_type => 'application/json;charset=UTF-8',
600                 Authorization => 'Bearer ' . $token
601         );
602         die $response->decoded_content if (!$response->is_success);
603
604         # Now that we have Slack names, we can log double-reacters.
605         for my $userid (keys %double) {
606                 my $name = best_name_for_log($userid, \%slack_userid_to_real_name, \%slack_userid_to_slack_name);
607                 skv_log("$name er pĆ„meldt flere steder pĆ„ Slack; vet ikke hvilken som skal brukes.");
608         }
609
610         # Find the list of names to mark yellow.
611         my %want_colors = ();
612         my $main_sheet_rows = $main_sheet_json->{'sheets'}[0]{'data'}[0]{'rowData'};
613         for my $userid (@attending_userids) {
614                 next if (!exists($slack_userid_to_real_name{$userid}));
615                 my $slack_name = $slack_userid_to_slack_name{$userid};
616                 my $real_name = $slack_userid_to_real_name{$userid};
617
618                 # See if we can find them in the spreadsheet.
619                 if (!exists($seen_names{lc $real_name})) {
620                         # TODO: Perhaps move this logic further down, for consistency?
621                         skv_log("$slack_name ($userid) er pĆ„meldt pĆ„ Slack, og er mappet til $real_name, men var ikke i noen gruppe.");
622                 } else {
623                         my $seen = $seen_names{lc $real_name};
624                         if (scalar @$seen >= 2) {
625                                 skv_log("$slack_name ($userid) er pĆ„meldt pĆ„ Slack, men stĆ„r flere steder (se over); vet ikke hvilken celle som skal brukes.");
626                         } else {
627                                 $want_colors{$seen->[0][0]} = $colors{$userid};
628                         }
629                 }
630         }
631
632         # Find the list of names we already marked yellow.
633         my %have_colors = ();
634         $dbh->{AutoCommit} = 0;
635         $dbh->do('SET TRANSACTION ISOLATION LEVEL SERIALIZABLE');
636         $q = $dbh->prepare('SELECT name,color FROM applied WHERE channel=? AND ts=?');
637         $q->execute($config::invitation_channel, $invitation_ts);
638         while (my $ref = $q->fetchrow_hashref) {
639                 $have_colors{$ref->{'name'}} = $ref->{'color'};
640         }
641
642         my @diffs = find_diff($dbh, $invitation_ts, \%want_colors, \%have_colors, \%seen_names);
643
644         my @yellow_updates = ();
645         if (scalar @diffs > 0) {
646                 # Now fill in the actual stuff.
647                 for my $diff (@diffs) {
648                         my $real_name = $diff->[0];
649
650                         my $seen = $seen_names{lc $real_name};
651
652                         # We've already complained about these earlier, so just skip them silently.
653                         next if (scalar @$seen > 1);
654
655                         # See if we can find them in the spreadsheet.
656                         die "Could not find $real_name" if (!defined($seen));
657                         my $rowno = $seen->[0][1];
658                         my $colno = $seen->[0][2];
659                         push @yellow_updates, {
660                                 updateCells => {
661                                         rows => [{
662                                                 values => [{
663                                                         userEnteredFormat => $diff->[1]
664                                                 }]
665                                         }],
666                                         fields => 'userEnteredFormat.backgroundColor',
667                                         range => {
668                                                 sheetId => $tab_id,
669                                                 startRowIndex => $rowno,
670                                                 endRowIndex => $rowno + 1,
671                                                 startColumnIndex => $colno,
672                                                 endColumnIndex => $colno + 1
673                                         }
674                                 }
675                         };
676                 }
677         }
678
679         my @recent_changes = create_reaction_log($dbh, $invitation_ts, \%slack_userid_to_real_name, \%slack_userid_to_slack_name);
680         push @yellow_updates, {
681                 updateCells => {
682                         rows => \@recent_changes,
683                         fields => 'userEnteredValue.stringValue',
684                         range => {
685                                 sheetId => $config::log_tab_id,
686                                 startRowIndex => 4,
687                                 endRowIndex => 4 + scalar @recent_changes,
688                                 startColumnIndex => 0,
689                                 endColumnIndex => 1
690                         }
691                 }
692         };
693
694         my @recent_moves = create_move_log($dbh, $invitation_ts, $prev_invitation_ts);
695         push @yellow_updates, {
696                 updateCells => {
697                         rows => \@recent_moves,
698                         fields => 'userEnteredValue.stringValue',
699                         range => {
700                                 sheetId => $config::log_tab_id,
701                                 startRowIndex => 4,
702                                 endRowIndex => 4 + scalar @recent_moves,
703                                 startColumnIndex => 1,
704                                 endColumnIndex => 2
705                         }
706                 }
707         };
708
709         # Push the final set of updates (including the log).
710         skv_log("Ferdig.");
711         push @yellow_updates, serialize_skv_log_to_sheet();
712         sheet_batch_update($ua, $token, \@yellow_updates);
713         $dbh->commit;
714
715         my $elapsed = Time::HiRes::tv_interval($start);
716         printf "Tok %.0f ms.\n", 1e3 * $elapsed;
717 }
718
719 if ($#ARGV >= 0 && $ARGV[0] eq '--daemon') {
720         # Start with a single, forced run.
721         unlink("/srv/skvidar-slack.sesse.net/marker");
722         run();
723
724         while (1) {
725                 if (!unlink("/srv/skvidar-slack.sesse.net/marker")) {
726                         unless ($!{ENOENT}) {
727                                 warn "/srv/skvidar-slack.sesse.net/marker: $!";
728                         }
729                         sleep 1;
730                         next;
731                 }
732                 eval {
733                         run();
734                 };
735                 if ($@) {
736                         warn "Died with: $@";
737                 }
738                 $dbh->disconnect;
739         }
740 } else {
741         run();
742 }