13 binmode STDOUT, ':utf8';
14 binmode STDERR, ':utf8';
17 require '../include/config.pm';
20 my $uca = Unicode::Collate->new(level => 1);
44 my ($start, $msg) = @_;
45 my $elapsed = Time::HiRes::tv_interval($start);
46 printf "%s: %.0f ms.\n", $msg, 1e3 * $elapsed;
51 return $uca->getSortKey($m);
54 sub get_oauth_bearer_token {
58 # See if the database already has a token we could use, that doesn't expire in a while.
59 my $ref = $dbh->selectrow_hashref('SELECT token FROM oauth_tokens WHERE expiry - (TIMESTAMPTZ \'1970-01-01\' + ? * INTERVAL \'1 second\') > INTERVAL \'1 minute\' ORDER BY expiry DESC LIMIT 1', undef, $now);
60 if (defined($ref->{'token'})) {
61 return $ref->{'token'};
64 my $jwt = JSON::XS::encode_json({
65 "iss" => $config::jwt_key->{'client_email'},
66 "scope" => "https://www.googleapis.com/auth/spreadsheets",
67 "aud" => "https://www.googleapis.com/oauth2/v4/token",
71 my $jws_token = Crypt::JWT::encode_jwt(payload=>$jwt, alg=>'RS256', key=>\$config::jwt_key->{'private_key'});
72 my $start = [Time::HiRes::gettimeofday];
73 my $response = $ua->post('https://www.googleapis.com/oauth2/v4/token', [
74 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
75 'assertion' => $jws_token ]);
76 log_timing($start, '/oauth2/v4/token');
77 my $token = JSON::XS::decode_json($response->decoded_content)->{'access_token'};
78 $dbh->do('INSERT INTO oauth_tokens (token, acquired, expiry) VALUES (?, TIMESTAMPTZ \'1970-01-01\' + ? * INTERVAL \'1 second\', TIMESTAMPTZ \'1970-01-01\' + ? * INTERVAL \'1 second\')',
79 undef, $token, $now, $now + 1800);
84 my ($ua, $userid) = @_;
85 my $req = HTTP::Request->new('GET', 'https://slack.com/api/users.info?user=' . $userid, [
86 'Authorization' => 'Bearer ' . $config::slack_oauth_token
88 my $start = [Time::HiRes::gettimeofday];
89 my $response = $ua->request($req);
90 log_timing($start, '/users.info');
91 die $response->status_line if !$response->is_success;
93 my $user_json = JSON::XS::decode_json($response->decoded_content);
94 die "Something went wrong: " . $response->decoded_content if (!defined($user_json) || !$user_json->{'ok'});
96 return $user_json->{'user'}{'real_name'};
99 sub get_spreadsheet_name {
101 my $name = $cell->{'userEnteredValue'}{'stringValue'};
102 return undef if (!defined($name));
103 return undef if ($name =~ /^G[1-4]\.[1-5]/);
105 $name =~ s/\(.*\)//g;
106 $name =~ s/\[.*\]//g;
108 $name =~ s/G\d\.\d?\??//;
116 my ($slack_name, $spreadsheet_name) = @_;
117 if (sort_key($slack_name) eq sort_key($spreadsheet_name)) {
121 my @ap = split /\s+/, $slack_name;
122 my @bp = split /\s+/, $spreadsheet_name;
123 if (scalar @ap >= 2 && scalar @bp >= 2 && sort_key($ap[0]) eq sort_key($bp[0])) {
124 # First name matches, try to match some surname
126 for my $ai (1..$#ap) {
127 for my $bi (1..$#bp) {
128 $found = 1 if (sort_key($ap[$ai]) eq sort_key($bp[$bi]));
132 skv_log("Fuzzy-matchet $slack_name -> $spreadsheet_name.");
140 sub format_cell_names_for_seen {
142 my @cells = map { chr(ord('A') + $_->[2]) . ($_->[1] + 1) } @$seen;
143 return join(', ', @cells);
148 print STDERR "$msg\n";
152 sub serialize_skv_log_to_sheet {
157 userEnteredValue => { stringValue => join("\n", @log) }
160 fields => 'userEnteredValue.stringValue',
162 sheetId => $config::log_tab_id,
165 startColumnIndex => 0,
172 sub sheet_batch_update {
173 my ($ua, $token, @requests) = @_;
175 requests => \@requests
177 my $start = [Time::HiRes::gettimeofday];
178 my $response = $ua->post(
179 'https://sheets.googleapis.com/v4/spreadsheets/' . $config::sheet_id . ':batchUpdate?key=' . $config::gsheets_api_key,
180 Content => JSON::XS::encode_json($update),
181 Content_type => 'application/json;charset=UTF-8',
182 Authorization => 'Bearer ' . $token
184 log_timing($start, '/spreadsheets/values:batchUpdate');
185 die $response->decoded_content if !$response->is_success;
188 sub get_group_assignments {
191 my %assignments = ();
192 my $rows = $json->{'sheets'}[0]{'data'}[0]{'rowData'};
193 my @curr_groups = ();
194 for my $row (@$rows) {
196 for my $val (@{$row->{'values'}}) {
198 my $contents = $val->{'userEnteredValue'}{'stringValue'};
199 next if !defined($contents);
200 if ($contents =~ /Gruppe /) {
204 next if $contents =~ /^VL:/;
205 next if $contents =~ /^LT\b/;
206 next if $contents =~ /^400m/;
207 next if $contents =~ /^546m/;
208 if ($contents =~ /^(G\d\.\d)/ || $contents =~ /^(Nye lĆøpere.*)/) {
209 $curr_groups[$col] = $1;
211 my $name = get_spreadsheet_name($val);
212 next if (!defined($name));
213 my $group = $curr_groups[$col] // $curr_groups[$col - 1];
214 # print $group, " ", $name, "\n";
215 if (exists($assignments{$name})) {
216 $assignments{$name} = "(flere grupper)";
218 $assignments{$name} = $group;
226 sub update_assignment_db {
227 my ($dbh, $channel, $ts, $assignments) = @_;
229 local $dbh->{AutoCommit} = 0;
230 my %db_assignments = ();
231 my $q = $dbh->prepare('SELECT name,group_name FROM current_group_membership_history WHERE channel=? AND ts=?');
232 $q->execute($channel, $ts);
233 while (my $ref = $q->fetchrow_hashref) {
234 if (defined($ref->{'group_name'})) {
235 $db_assignments{$ref->{'name'}} = $ref->{'group_name'};
239 $q = $dbh->prepare('INSERT INTO group_membership_history (channel, ts, name, change_seen, group_name) VALUES (?, ?, ?, CURRENT_TIMESTAMP, ?)');
240 for my $name (keys %$assignments) {
241 if (!exists($db_assignments{$name}) || $db_assignments{$name} ne $assignments->{$name}) {
242 $q->execute($channel, $ts, $name, $assignments->{$name});
245 for my $name (keys %db_assignments) {
246 if (!exists($assignments->{$name})) {
247 $q->execute($channel, $ts, $name, undef);
253 sub get_spreadsheet_with_title {
254 my ($dbh, $ua, $token, $invitation_ts, $wanted_sheet_title) = @_;
256 # See if we have any spreadsheets that match this title.
257 my $start = [Time::HiRes::gettimeofday];
258 my $response = $ua->get('https://sheets.googleapis.com/v4/spreadsheets/' . $config::sheet_id . '?key=' . $config::gsheets_api_key . '&fields=sheets/properties',
259 Authorization => 'Bearer ' . $token,
260 Accept_Encoding => HTTP::Message::decodable
262 log_timing($start, '/spreadsheets/properties');
263 my $sheets_json = JSON::XS::decode_json($response->decoded_content);
264 my ($tab_name, $tab_id);
265 for my $sheet (@{$sheets_json->{'sheets'}}) {
266 my $title = $sheet->{'properties'}{'title'};
267 my $sheet_id = $sheet->{'properties'}{'sheetId'};
268 if ($title =~ /\Q$wanted_sheet_title\E/) {
269 # skv_log("Synkroniserer ($config::invitation_channel, $invitation_ts) mot arket ā$titleā (fane-ID $sheet_id).");
270 $dbh->do('UPDATE message_sheet_link SET tab_name=?, tab_id=? WHERE channel=? AND ts=?',
271 undef, $title, $sheet_id, $config::invitation_channel, $invitation_ts);
272 return ($title, $sheet_id);
275 return (undef, undef);
278 # Make a mapping of lowercase name -> list of [canonical name, row number, column number]
279 sub find_where_each_name_is {
283 my $rows = $json->{'sheets'}[0]{'data'}[0]{'rowData'};
285 for my $row (@$rows) {
287 for my $val (@{$row->{'values'}}) {
288 my $name = get_spreadsheet_name($val);
289 if (defined($name)) {
290 push @{$seen_names{sort_key($name)}}, [$name, $rowno, $colno];
300 sub best_name_for_log {
301 my ($userid, $slack_userid_to_real_name, $slack_userid_to_slack_name) = @_;
302 if (exists($slack_userid_to_real_name->{$userid})) {
303 return $slack_userid_to_real_name->{$userid};
304 } elsif (exists($slack_userid_to_slack_name->{$userid})) {
305 return $slack_userid_to_slack_name->{$userid} . ' (fant ikke regneark-navn)';
307 # Should only happen if we didn't see the initial reaction_add, only reaction_remove.
308 # (TODO: Is the comment above true anymore, now that we use this from multiple contexts?)
309 return $userid . ' (fant ikke Slack-navn)';
313 # Add the reaction log. (This only takes into account the last change
314 # for each user; earlier ones are irrelevant and don't count. But it
315 # doesn't deduplicate across reactions. Meh.)
316 sub create_reaction_log {
317 my ($dbh, $invitation_ts, $slack_userid_to_real_name, $slack_userid_to_slack_name) = @_;
319 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');
320 $q->execute($config::invitation_channel, $invitation_ts);
321 my @recent_changes = ();
322 while (my $ref = $q->fetchrow_hashref) {
323 my $msg = $ref->{'event_ts'};
324 if ($ref->{'event_type'} eq 'reaction_added') {
329 if ($ref->{'reaction'} eq 'open_mouth') {
331 } elsif ($ref->{'reaction'} eq 'blue_heart') {
337 $msg .= best_name_for_log($ref->{'userid'}, $slack_userid_to_real_name, $slack_userid_to_slack_name);
338 push @recent_changes, { values => [{ userEnteredValue => { stringValue => $msg } }] };
340 while (scalar @recent_changes < 50) {
341 push @recent_changes, { values => [{ userEnteredValue => { stringValue => '' } }] };
343 return @recent_changes;
346 sub create_move_log {
347 my ($dbh, $invitation_ts, $prev_invitation_ts) = @_;
348 my $q = $dbh->prepare(<<"EOF");
350 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
351 FROM ( SELECT * FROM current_group_membership_history WHERE ts=? ) g_old
352 FULL OUTER JOIN ( SELECT * FROM current_group_membership_history WHERE ts=? ) g_new USING (name)
354 g_new.group_name IS DISTINCT FROM g_old.group_name
355 AND g_new.group_name IS NOT NULL
356 ORDER BY g_new.change_seen DESC, name
359 $q->execute($prev_invitation_ts, $invitation_ts);
360 my @recent_moves = ();
361 while (my $ref = $q->fetchrow_hashref) {
362 my $name = $ref->{'name'};
363 my $old_group = $ref->{'old_group'};
364 my $new_group = $ref->{'new_group'};
366 my $msg = $ref->{'change_seen'} . " ";
367 if (!defined($old_group)) {
368 $msg .= "$name, (ny lĆøper) ā $new_group";
370 $msg .= "$name, $old_group ā $new_group";
372 push @recent_moves, { values => [{ userEnteredValue => { stringValue => $msg } }] };
374 while (scalar @recent_moves < 50) {
375 push @recent_moves, { values => [{ userEnteredValue => { stringValue => '' } }] };
377 return @recent_moves;
380 # Also applies the diff to the database (a bit ugly).
382 my ($dbh, $invitation_ts, $want_colors, $have_colors, $seen_names) = @_;
385 for my $real_name (keys %$want_colors) {
386 my $wc = $want_colors->{$real_name};
387 if (exists($have_colors->{$real_name})) {
388 if ($have_colors->{$real_name} eq $wc) {
392 skv_log("Markerer at $real_name har byttet treningssted.");
394 $real_name, { backgroundColor => $rgb{$wc} }
396 $dbh->do('UPDATE applied SET color=? WHERE channel=? AND ts=? AND name=?', undef,
397 $wc, $config::invitation_channel, $invitation_ts, $real_name);
399 skv_log("Markerer at $real_name skal pƄ trening.");
401 $real_name, { backgroundColor => $rgb{$wc} }
403 $dbh->do('INSERT INTO applied (channel, ts, name, color) VALUES (?, ?, ?, ?)', undef,
404 $config::invitation_channel, $invitation_ts, $real_name, $wc);
407 for my $real_name (keys %$have_colors) {
408 next if (exists($want_colors->{$real_name}));
409 if (!exists($seen_names->{sort_key($real_name)})) {
410 # TODO: This can somehow come if we try to add someone who's not in the sheet, too?
411 skv_log("Ćnsket Ć„ fjerne at $real_name skulle pĆ„ trening, men de var ikke i regnearket lenger.");
412 } elsif (scalar @{$seen_names->{sort_key($real_name)}} > 1) {
415 skv_log("Fjerner at $real_name skal pƄ trening.");
417 $real_name, { backgroundColor => $rgb{white} }
419 $dbh->do('DELETE FROM applied WHERE channel=? AND ts=? AND name=?', undef,
420 $config::invitation_channel, $invitation_ts, $real_name);
426 sub possibly_nag_user {
427 my ($dbh, $ua, $userid, $invitation_ts, $group, $slack_userid_to_slack_name) = @_;
429 my $slack_name = $slack_userid_to_slack_name->{$userid};
431 # See if we've nagged this user before.
432 my $q = $dbh->prepare('SELECT * FROM users_nagged WHERE userid=? AND ts=?');
433 $q->execute($userid, $invitation_ts);
434 if (defined($q->fetchrow_hashref)) {
439 if (!defined($group)) {
440 $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!";
441 skv_log("Sender Slack-melding til $slack_name ($userid) for Ć„ spĆørre om gruppe.");
442 } elsif ($group eq '(flere grupper)') {
443 $msg = "Hei! Du meldte deg akkurat pĆ„ trening, men du ser ut til Ć„ stĆ„ i flere forskjellige grupper 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 retter der. Om du er usikker pĆ„ hvilken gruppe som passer for deg, ta gjerne kontakt med en trener. Velkommen pĆ„ trening!";
444 skv_log("Sender Slack-melding til $slack_name ($userid) for Ć„ spĆørre om gruppe.");
446 $msg = "Hei! Du er pƄmeldt gruppe *$group*. Om dette er feil, gƄ gjerne inn og endre pƄ https://regneark.skvidar.run/. Vi gleder oss til Ƅ se deg pƄ trening!";
447 skv_log("Sender Slack-melding om at $slack_name ($userid) er i gruppe $group.");
451 channel => $config::invitation_channel,
455 my $start = [Time::HiRes::gettimeofday];
456 my $response = $ua->post(
457 'https://slack.com/api/chat.postEphemeral',
458 Content => JSON::XS::encode_json($content),
459 Content_type => 'application/json;charset=UTF-8',
460 Authorization => 'Bearer ' . $config::slack_oauth_token
462 log_timing($start, 'chat.postEphemeral');
463 die $response->status_line if !$response->is_success;
464 my $msg_json = JSON::XS::decode_json($response->decoded_content);
465 die "Something went wrong: " . $response->decoded_content if (!defined($msg_json) || !$msg_json->{'ok'});
467 # Mark that we've sent the message, so it won't happen again.
468 $dbh->do('INSERT INTO users_nagged (userid, ts, last_nag) VALUES (?, ?, CURRENT_TIMESTAMP)', undef, $userid, $invitation_ts);
472 my $dbh = DBI->connect("dbi:Pg:dbname=$config::dbname;host=127.0.0.1", $config::dbuser, $config::dbpass, {RaiseError => 1})
473 or warn "Could not connect to Postgres: " . DBI->errstr;
474 if (!defined($dbh)) {
477 $dbh->do('LISTEN skvupdate') or return undef;
483 my $total_start = [Time::HiRes::gettimeofday];
486 skv_log("Siste sync startet: " . POSIX::ctime(time));
488 # Initialize the handles we need for communication.
489 my $ua = LWP::UserAgent->new('SKVidarLang/1.0');
490 my $token = get_oauth_bearer_token($dbh, $ua);
492 # Find the newest message, what it is linked to, and what was the one before it (for group diffing).
493 # TODO: Support more than one, and test better for errors here.
494 my $q = $dbh->prepare('select * from message_sheet_link where channel=? order by ts desc limit 2');
495 $q->execute($config::invitation_channel);
496 my $linkref = $q->fetchrow_hashref;
497 my $invitation_ts = $linkref->{'ts'};
498 my $wanted_sheet_title = $linkref->{'sheet_title'};
499 die "Could not get newest sheet title" if (!defined($wanted_sheet_title));
500 my $tab_name = $linkref->{'tab_name'};
501 my $tab_id = $linkref->{'tab_id'};
503 # Store away the second-newest ID.
504 my $prev_invitation_ts = $q->fetchrow_hashref->{'ts'};
506 if (!defined($tab_name) || !defined($tab_id)) {
507 ($tab_name, $tab_id) = get_spreadsheet_with_title($dbh, $ua, $token, $invitation_ts, $wanted_sheet_title);
508 if (!defined($tab_name)) {
509 skv_log("Fant ikke noen fane med Ā«$wanted_sheet_titleĀ» i navnet; kan ikke synkronisere.\n");
510 sheet_batch_update($ua, $token, [ serialize_skv_log_to_sheet() ]);
515 # Find everyone who are marked as attending on Slack (via reactions).
516 $q = $dbh->prepare('SELECT DISTINCT userid,reaction FROM current_reactions WHERE channel=? AND ts=? AND reaction IN (\'heart\', \'open_mouth\', \'blue_heart\')');
517 $q->execute($config::invitation_channel, $invitation_ts);
518 my @attending_userids = ();
521 while (my $ref = $q->fetchrow_hashref) {
522 my $userid = $ref->{'userid'};
523 push @attending_userids, $userid;
524 if ($ref->{'reaction'} eq 'blue_heart') {
525 if (exists($colors{$userid}) && $colors{$userid} eq 'yellow') {
526 $double{$userid} = 1;
528 $colors{$userid} = 'blue';
530 if (exists($colors{$userid}) && $colors{$userid} eq 'blue') {
531 $double{$userid} = 1;
533 $colors{$userid} = 'yellow';
537 # Remove double-attenders (we will log them as warnings further down).
538 @attending_userids = grep { !exists($double{$_}) } @attending_userids;
539 for my $userid (keys %double) {
540 delete $colors{$userid};
543 # Get the list of all people in the sheet (we're going to need them soon anyway).
544 my $start = [Time::HiRes::gettimeofday];
545 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',
546 Authorization => 'Bearer ' . $token,
547 Accept_Encoding => HTTP::Message::decodable
549 log_timing($start, "/spreadsheets/$tab_name");
551 my $main_sheet_json = JSON::XS::decode_json($response->decoded_content);
553 # Update the list of groups we've seen people in.
554 my %assignments = get_group_assignments($main_sheet_json);
555 update_assignment_db($dbh, $config::invitation_channel, $invitation_ts, \%assignments);
557 my %seen_names = find_where_each_name_is($main_sheet_json);
560 for my $name (sort keys %seen_names) {
561 my $seen = $seen_names{$name};
562 if (scalar @$seen >= 2) {
563 my $exemplar = $seen->[0][0];
564 skv_log("Duplikat: $exemplar (" . format_cell_names_for_seen($seen) . ")");
568 # Get our existing Slack->name mapping, from the sheets.
569 my %slack_userid_to_real_name = ();
570 my %slack_userid_to_slack_name = ();
571 my %slack_userid_to_row = ();
573 $start = [Time::HiRes::gettimeofday];
574 $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',
575 Authorization => 'Bearer ' . $token,
576 Accept_Encoding => HTTP::Message::decodable
578 log_timing($start, "/spreadsheets/Slack-mapping");
579 my $mapping_sheet_json = JSON::XS::decode_json($response->decoded_content);
580 my $mapping_sheet_rows = $mapping_sheet_json->{'sheets'}[0]{'data'}[0]{'rowData'};
582 for my $row (@$mapping_sheet_rows) {
583 my $slack_id = $row->{'values'}[0]{'userEnteredValue'}{'stringValue'};
584 my $slack_name = $row->{'values'}[1]{'userEnteredValue'}{'stringValue'};
585 my $real_name = get_spreadsheet_name($row->{'values'}[2]); # TODO support more
586 $slack_userid_to_row{$slack_id} = $cur_row++;
587 next if (!defined($slack_name));
588 $slack_userid_to_slack_name{$slack_id} = $slack_name;
589 next if (!defined($real_name));
590 $slack_userid_to_real_name{$slack_id} = $real_name;
593 # See which ones we don't have a mapping for, and look them up in Slack.
594 # TODO: Use an append call instead of $cur_row?
595 my @slack_mapping_updates = ();
596 for my $userid (@attending_userids) {
597 next if (exists($slack_userid_to_real_name{$userid}));
599 # Make sure they have a row in the spreadsheet.
601 if (exists($slack_userid_to_row{$userid})) {
602 $write_row = $slack_userid_to_row{$userid};
604 $write_row = $cur_row++;
605 $slack_userid_to_row{$userid} = $write_row;
606 push @slack_mapping_updates, {
607 range => "Slack-mapping!A$write_row:A$write_row",
608 values => [ [ $userid ]]
612 # Fetch their Slack name if we don't already have it.
614 if (exists($slack_userid_to_slack_name{$userid})) {
615 $slack_name = $slack_userid_to_slack_name{$userid};
617 $slack_userid_to_slack_name{$userid} = $slack_name;
618 $slack_name = get_slack_name($ua, $userid);
619 push @slack_mapping_updates, {
620 range => "Slack-mapping!B$write_row:B$write_row",
621 values => [ [ $slack_name ]]
623 $slack_userid_to_slack_name{$userid} = $slack_name;
626 if (exists($seen_names{sort_key($slack_name)})) {
627 # The name exists exactly, once or more, so it's a direct match and we ignore any fuzz.
628 $slack_userid_to_real_name{$userid} = $slack_name;
629 push @slack_mapping_updates, {
630 range => "Slack-mapping!C$write_row:C$write_row",
631 values => [ [ $slack_name ]]
634 # Do a search through all the available names in the sheet to find an obvious(ish) match.
636 my $main_sheet_rows = $main_sheet_json->{'sheets'}[0]{'data'}[0]{'rowData'};
637 for my $row (@$main_sheet_rows) {
638 for my $val (@{$row->{'values'}}) {
639 my $name = get_spreadsheet_name($val);
640 if (defined($name) && matches_name($slack_name, $name)) {
641 push @candidates, $name;
645 if ($#candidates == -1) {
646 skv_log("$slack_name ($userid) er pƄmeldt pƄ Slack, men fant ikke et regneark-navn for dem.");
647 possibly_nag_user($dbh, $ua, $userid, $invitation_ts, undef, \%slack_userid_to_slack_name);
648 } elsif ($#candidates == 0) {
649 my $name = $candidates[0];
650 $slack_userid_to_real_name{$userid} = $name;
651 push @slack_mapping_updates, {
652 range => "Slack-mapping!C$write_row:C$write_row",
653 values => [ [ $name ]]
656 skv_log("$slack_name ($userid) er pƄmeldt pƄ Slack, men hadde flere fuzzy-matcher; vet ikke hvilket regneark-navn som skal brukes.");
660 if (scalar @slack_mapping_updates > 0) {
662 valueInputOption => 'USER_ENTERED',
663 data => \@slack_mapping_updates
665 $start = [Time::HiRes::gettimeofday];
666 $response = $ua->post(
667 'https://sheets.googleapis.com/v4/spreadsheets/' . $config::sheet_id . '/values:batchUpdate?key=' . $config::gsheets_api_key,
668 Content => JSON::XS::encode_json($update),
669 Content_type => 'application/json;charset=UTF-8',
670 Authorization => 'Bearer ' . $token
672 log_timing($start, "/spreadsheets/values:batchUpdate");
673 die $response->decoded_content if (!$response->is_success);
676 # Now that we have Slack names, we can log double-reacters.
677 for my $userid (keys %double) {
678 my $name = best_name_for_log($userid, \%slack_userid_to_real_name, \%slack_userid_to_slack_name);
679 skv_log("$name er pƄmeldt flere steder pƄ Slack; vet ikke hvilken som skal brukes.");
682 # ...and possibly send welcome messages to remind them of groups.
683 for my $userid (@attending_userids) {
684 my $real_name = $slack_userid_to_real_name{$userid};
685 next if (!defined($real_name));
686 my $group = $assignments{$real_name};
687 next if (!defined($group));
688 possibly_nag_user($dbh, $ua, $userid, $invitation_ts, $group, \%slack_userid_to_slack_name);
691 # Find the list of names to mark yellow.
692 my %want_colors = ();
693 my $main_sheet_rows = $main_sheet_json->{'sheets'}[0]{'data'}[0]{'rowData'};
694 for my $userid (@attending_userids) {
695 next if (!exists($slack_userid_to_real_name{$userid}));
696 my $slack_name = $slack_userid_to_slack_name{$userid};
697 my $real_name = $slack_userid_to_real_name{$userid};
699 # See if we can find them in the spreadsheet.
700 if (!exists($seen_names{sort_key($real_name)})) {
701 # TODO: Perhaps move this logic further down, for consistency?
702 skv_log("$slack_name ($userid) er pƄmeldt pƄ Slack, og er mappet til $real_name, men var ikke i noen gruppe.");
704 my $seen = $seen_names{sort_key($real_name)};
705 if (scalar @$seen >= 2) {
706 skv_log("$slack_name ($userid) er pƄmeldt pƄ Slack, men stƄr flere steder (se over); vet ikke hvilken celle som skal brukes.");
708 $want_colors{$seen->[0][0]} = $colors{$userid};
713 # Find the list of names we already marked yellow.
714 my %have_colors = ();
715 $dbh->{AutoCommit} = 0;
716 $dbh->do('SET TRANSACTION ISOLATION LEVEL SERIALIZABLE');
717 $q = $dbh->prepare('SELECT name,color FROM applied WHERE channel=? AND ts=?');
718 $q->execute($config::invitation_channel, $invitation_ts);
719 while (my $ref = $q->fetchrow_hashref) {
720 $have_colors{$ref->{'name'}} = $ref->{'color'};
723 my @diffs = find_diff($dbh, $invitation_ts, \%want_colors, \%have_colors, \%seen_names);
725 my @yellow_updates = ();
726 if (scalar @diffs > 0) {
727 # Now fill in the actual stuff.
728 for my $diff (@diffs) {
729 my $real_name = $diff->[0];
731 my $seen = $seen_names{sort_key($real_name)};
733 # We've already complained about these earlier, so just skip them silently.
734 next if (scalar @$seen > 1);
736 # See if we can find them in the spreadsheet.
737 die "Could not find $real_name" if (!defined($seen));
738 my $rowno = $seen->[0][1];
739 my $colno = $seen->[0][2];
740 push @yellow_updates, {
744 userEnteredFormat => $diff->[1]
747 fields => 'userEnteredFormat.backgroundColor',
750 startRowIndex => $rowno,
751 endRowIndex => $rowno + 1,
752 startColumnIndex => $colno,
753 endColumnIndex => $colno + 1
760 my @recent_changes = create_reaction_log($dbh, $invitation_ts, \%slack_userid_to_real_name, \%slack_userid_to_slack_name);
761 push @yellow_updates, {
763 rows => \@recent_changes,
764 fields => 'userEnteredValue.stringValue',
766 sheetId => $config::log_tab_id,
768 endRowIndex => 4 + scalar @recent_changes,
769 startColumnIndex => 0,
775 my @recent_moves = create_move_log($dbh, $invitation_ts, $prev_invitation_ts);
776 push @yellow_updates, {
778 rows => \@recent_moves,
779 fields => 'userEnteredValue.stringValue',
781 sheetId => $config::log_tab_id,
783 endRowIndex => 4 + scalar @recent_moves,
784 startColumnIndex => 1,
790 # Push the final set of updates (including the log).
792 push @yellow_updates, serialize_skv_log_to_sheet();
793 sheet_batch_update($ua, $token, \@yellow_updates);
796 my $elapsed = Time::HiRes::tv_interval($total_start);
797 printf "Tok %.0f ms.\n", 1e3 * $elapsed;
800 my $dbh = db_connect() or die;
801 if ($#ARGV >= 0 && $ARGV[0] eq '--daemon') {
802 # Start with a single, forced run.
806 while (!defined($dbh)) {
807 print STDERR "Database connection lost, reconnecting...\n";
811 my $s = IO::Select->new($dbh->{pg_socket});
812 my @ready = $s->can_read(10.0);
813 my @exceptions = $s->has_exception(0.0);
815 if (scalar @exceptions > 0) {
820 if (scalar @ready > 0) {
822 $dbh->{AutoCommit} = 1;
827 warn "Died with: $@";