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