14 binmode STDOUT, ':utf8';
15 binmode STDERR, ':utf8';
18 require '../include/config.pm';
20 my $global_ctx = IO::Socket::SSL::SSL_Context->new(
21 SSL_session_cache_size => 100, # Probably overkill.
23 IO::Socket::SSL::set_default_context($global_ctx);
26 my $uca = Unicode::Collate->new(level => 1);
50 my ($start, $msg) = @_;
51 my $elapsed = Time::HiRes::tv_interval($start);
52 printf "%s: %.0f ms.\n", $msg, 1e3 * $elapsed;
57 return $uca->getSortKey($m);
60 sub get_oauth_bearer_token {
64 # See if the database already has a token we could use, that doesn't expire in a while.
65 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);
66 if (defined($ref->{'token'})) {
67 return $ref->{'token'};
70 my $jwt = JSON::XS::encode_json({
71 "iss" => $config::jwt_key->{'client_email'},
72 "scope" => "https://www.googleapis.com/auth/spreadsheets",
73 "aud" => "https://www.googleapis.com/oauth2/v4/token",
77 my $jws_token = Crypt::JWT::encode_jwt(payload=>$jwt, alg=>'RS256', key=>\$config::jwt_key->{'private_key'});
78 my $start = [Time::HiRes::gettimeofday];
79 my $response = $ua->post('https://www.googleapis.com/oauth2/v4/token', [
80 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
81 'assertion' => $jws_token ]);
82 log_timing($start, '/oauth2/v4/token');
83 my $token = JSON::XS::decode_json($response->decoded_content)->{'access_token'};
84 $dbh->do('INSERT INTO oauth_tokens (token, acquired, expiry) VALUES (?, TIMESTAMPTZ \'1970-01-01\' + ? * INTERVAL \'1 second\', TIMESTAMPTZ \'1970-01-01\' + ? * INTERVAL \'1 second\')',
85 undef, $token, $now, $now + 1800);
90 my ($ua, $userid) = @_;
91 my $req = HTTP::Request->new('GET', 'https://slack.com/api/users.info?user=' . $userid, [
92 'Authorization' => 'Bearer ' . $config::slack_oauth_token
94 my $start = [Time::HiRes::gettimeofday];
95 my $response = $ua->request($req);
96 log_timing($start, '/users.info');
97 die $response->status_line if !$response->is_success;
99 my $user_json = JSON::XS::decode_json($response->decoded_content);
100 die "Something went wrong: " . $response->decoded_content if (!defined($user_json) || !$user_json->{'ok'});
102 return $user_json->{'user'}{'real_name'};
105 sub get_spreadsheet_name {
107 my $name = $cell->{'userEnteredValue'}{'stringValue'};
108 return undef if (!defined($name));
109 return undef if ($name =~ /^G[1-4]\.[1-5]/);
111 $name =~ s/\(.*\)//g;
112 $name =~ s/\[.*\]//g;
114 $name =~ s/G\d\.\d?\??//;
122 my ($slack_name, $spreadsheet_name, $ap) = @_;
124 # No need to check for an exact match; we already did that through $seen_names.
125 # if (sort_key($slack_name) eq sort_key($spreadsheet_name)) {
129 # @ap is precalculated by the caller.
130 # my @ap = map { sort_key($_) } split /\s+/, $slack_name;
131 my @bp = map { sort_key($_) } split /\s+/, $spreadsheet_name;
132 if (scalar @$ap >= 2 && scalar @bp >= 2 && $ap->[0] eq $bp[0]) {
133 # First name matches, try to match some surname
135 for my $ai (1..(scalar @$ap)) {
136 for my $bi (1..$#bp) {
137 $found = 1 if ($ap->[$ai] eq $bp[$bi]);
141 skv_log("Fuzzy-matchet $slack_name -> $spreadsheet_name.");
149 sub format_cell_names_for_seen {
151 my @cells = map { chr(ord('A') + $_->[2]) . ($_->[1] + 1) } @$seen;
152 return join(', ', @cells);
157 print STDERR "$msg\n";
161 sub serialize_skv_log_to_sheet {
166 userEnteredValue => { stringValue => join("\n", @log) }
169 fields => 'userEnteredValue.stringValue',
171 sheetId => $config::log_tab_id,
174 startColumnIndex => 0,
181 sub sheet_batch_update {
182 my ($ua, $token, @requests) = @_;
184 requests => \@requests
186 my $start = [Time::HiRes::gettimeofday];
187 my $response = $ua->post(
188 'https://sheets.googleapis.com/v4/spreadsheets/' . $config::sheet_id . ':batchUpdate?key=' . $config::gsheets_api_key,
189 Content => JSON::XS::encode_json($update),
190 Content_type => 'application/json;charset=UTF-8',
191 Authorization => 'Bearer ' . $token
193 log_timing($start, '/spreadsheets/values:batchUpdate');
194 die $response->decoded_content if !$response->is_success;
197 sub get_group_assignments {
200 my %assignments = ();
201 my $rows = $json->{'data'}[0]{'rowData'};
202 my @curr_groups = ();
203 for my $row (@$rows) {
205 for my $val (@{$row->{'values'}}) {
207 my $contents = $val->{'userEnteredValue'}{'stringValue'};
208 next if !defined($contents);
209 if ($contents =~ /Gruppe /) {
213 next if $contents =~ /^VL:/;
214 next if $contents =~ /^LT\b/;
215 next if $contents =~ /^400m/;
216 next if $contents =~ /^546m/;
217 if ($contents =~ /^(G\d\.\d)/ || $contents =~ /^(Nye lĆøpere.*)/) {
218 $curr_groups[$col] = $1;
220 my $name = get_spreadsheet_name($val);
221 next if (!defined($name));
222 my $group = $curr_groups[$col] // $curr_groups[$col - 1];
223 # print $group, " ", $name, "\n";
224 if (exists($assignments{$name})) {
225 $assignments{$name} = "(flere grupper)";
227 $assignments{$name} = $group;
235 sub update_assignment_db {
236 my ($dbh, $channel, $ts, $assignments) = @_;
238 my %db_assignments = ();
239 my $q = $dbh->prepare('SELECT name,group_name FROM current_group_membership_history WHERE channel=? AND ts=?');
240 $q->execute($channel, $ts);
241 while (my $ref = $q->fetchrow_hashref) {
242 if (defined($ref->{'group_name'})) {
243 $db_assignments{$ref->{'name'}} = $ref->{'group_name'};
247 $q = $dbh->prepare('INSERT INTO group_membership_history (channel, ts, name, change_seen, group_name) VALUES (?, ?, ?, CURRENT_TIMESTAMP, ?)');
248 for my $name (keys %$assignments) {
249 if (!exists($db_assignments{$name}) || $db_assignments{$name} ne $assignments->{$name}) {
250 $q->execute($channel, $ts, $name, $assignments->{$name});
253 for my $name (keys %db_assignments) {
254 if (!exists($assignments->{$name})) {
255 $q->execute($channel, $ts, $name, undef);
260 sub get_spreadsheet_with_title {
261 my ($dbh, $ua, $token, $invitation_ts, $wanted_sheet_title) = @_;
263 # See if we have any spreadsheets that match this title.
264 my $start = [Time::HiRes::gettimeofday];
265 my $response = $ua->get('https://sheets.googleapis.com/v4/spreadsheets/' . $config::sheet_id . '?key=' . $config::gsheets_api_key . '&fields=sheets/properties',
266 Authorization => 'Bearer ' . $token,
267 Accept_Encoding => HTTP::Message::decodable
269 log_timing($start, '/spreadsheets/properties');
270 my $sheets_json = JSON::XS::decode_json($response->decoded_content);
271 my ($tab_name, $tab_id);
272 for my $sheet (@{$sheets_json->{'sheets'}}) {
273 my $title = $sheet->{'properties'}{'title'};
274 my $sheet_id = $sheet->{'properties'}{'sheetId'};
275 if ($title =~ /\Q$wanted_sheet_title\E/) {
276 # skv_log("Synkroniserer ($config::invitation_channel, $invitation_ts) mot arket ā$titleā (fane-ID $sheet_id).");
277 $dbh->do('UPDATE message_sheet_link SET tab_name=?, tab_id=? WHERE channel=? AND ts=?',
278 undef, $title, $sheet_id, $config::invitation_channel, $invitation_ts);
279 return ($title, $sheet_id);
282 return (undef, undef);
285 # Make a mapping of lowercase name -> list of [canonical name, row number, column number]
286 sub find_where_each_name_is {
290 my $rows = $json->{'data'}[0]{'rowData'};
292 for my $row (@$rows) {
294 for my $val (@{$row->{'values'}}) {
295 my $name = get_spreadsheet_name($val);
296 if (defined($name)) {
297 push @{$seen_names{sort_key($name)}}, [$name, $rowno, $colno];
307 sub best_name_for_log {
308 my ($userid, $slack_userid_to_real_name, $slack_userid_to_slack_name) = @_;
309 if (exists($slack_userid_to_real_name->{$userid})) {
310 return $slack_userid_to_real_name->{$userid};
311 } elsif (exists($slack_userid_to_slack_name->{$userid})) {
312 return $slack_userid_to_slack_name->{$userid} . ' (fant ikke regneark-navn)';
314 # Should only happen if we didn't see the initial reaction_add, only reaction_remove.
315 # (TODO: Is the comment above true anymore, now that we use this from multiple contexts?)
316 return $userid . ' (fant ikke Slack-navn)';
320 # Add the reaction log. (This only takes into account the last change
321 # for each user; earlier ones are irrelevant and don't count. But it
322 # doesn't deduplicate across reactions. Meh.)
323 sub create_reaction_log {
324 my ($dbh, $invitation_ts, $slack_userid_to_real_name, $slack_userid_to_slack_name) = @_;
326 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');
327 $q->execute($config::invitation_channel, $invitation_ts);
328 my @recent_changes = ();
329 while (my $ref = $q->fetchrow_hashref) {
330 my $msg = $ref->{'event_ts'};
331 if ($ref->{'event_type'} eq 'reaction_added') {
336 if ($ref->{'reaction'} eq 'open_mouth') {
338 } elsif ($ref->{'reaction'} eq 'blue_heart') {
344 $msg .= best_name_for_log($ref->{'userid'}, $slack_userid_to_real_name, $slack_userid_to_slack_name);
345 push @recent_changes, { values => [{ userEnteredValue => { stringValue => $msg } }] };
347 while (scalar @recent_changes < 50) {
348 push @recent_changes, { values => [{ userEnteredValue => { stringValue => '' } }] };
350 return @recent_changes;
353 sub create_move_log {
354 my ($dbh, $invitation_ts, $prev_invitation_ts) = @_;
355 my $q = $dbh->prepare(<<"EOF");
357 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
358 FROM ( SELECT * FROM current_group_membership_history WHERE ts=? ) g_old
359 FULL OUTER JOIN ( SELECT * FROM current_group_membership_history WHERE ts=? ) g_new USING (name)
361 g_new.group_name IS DISTINCT FROM g_old.group_name
362 AND g_new.group_name IS NOT NULL
363 ORDER BY g_new.change_seen DESC, name
366 $q->execute($prev_invitation_ts, $invitation_ts);
367 my @recent_moves = ();
368 while (my $ref = $q->fetchrow_hashref) {
369 my $name = $ref->{'name'};
370 my $old_group = $ref->{'old_group'};
371 my $new_group = $ref->{'new_group'};
373 my $msg = $ref->{'change_seen'} . " ";
374 if (!defined($old_group)) {
375 $msg .= "$name, (ny lĆøper) ā $new_group";
377 $msg .= "$name, $old_group ā $new_group";
379 push @recent_moves, { values => [{ userEnteredValue => { stringValue => $msg } }] };
381 while (scalar @recent_moves < 50) {
382 push @recent_moves, { values => [{ userEnteredValue => { stringValue => '' } }] };
384 return @recent_moves;
387 # Also applies the diff to the database (a bit ugly).
389 my ($dbh, $invitation_ts, $want_colors, $have_colors, $seen_names) = @_;
392 for my $real_name (keys %$want_colors) {
393 my $wc = $want_colors->{$real_name};
394 if (exists($have_colors->{$real_name})) {
395 if ($have_colors->{$real_name} eq $wc) {
399 skv_log("Markerer at $real_name har byttet treningssted.");
401 $real_name, { backgroundColor => $rgb{$wc} }
403 $dbh->do('UPDATE applied SET color=? WHERE channel=? AND ts=? AND name=?', undef,
404 $wc, $config::invitation_channel, $invitation_ts, $real_name);
406 skv_log("Markerer at $real_name skal pƄ trening.");
408 $real_name, { backgroundColor => $rgb{$wc} }
410 $dbh->do('INSERT INTO applied (channel, ts, name, color) VALUES (?, ?, ?, ?)', undef,
411 $config::invitation_channel, $invitation_ts, $real_name, $wc);
414 for my $real_name (keys %$have_colors) {
415 next if (exists($want_colors->{$real_name}));
416 if (!exists($seen_names->{sort_key($real_name)})) {
417 # TODO: This can somehow come if we try to add someone who's not in the sheet, too?
418 skv_log("Ćnsket Ć„ fjerne at $real_name skulle pĆ„ trening, men de var ikke i regnearket lenger.");
419 } elsif (scalar @{$seen_names->{sort_key($real_name)}} > 1) {
422 skv_log("Fjerner at $real_name skal pƄ trening.");
424 $real_name, { backgroundColor => $rgb{white} }
426 $dbh->do('DELETE FROM applied WHERE channel=? AND ts=? AND name=?', undef,
427 $config::invitation_channel, $invitation_ts, $real_name);
433 sub possibly_nag_user {
434 my ($dbh, $ua, $userid, $invitation_ts, $group, $slack_userid_to_slack_name) = @_;
436 my $slack_name = $slack_userid_to_slack_name->{$userid};
438 # See if we've nagged this user before.
439 my $q = $dbh->prepare('SELECT * FROM users_nagged WHERE userid=? AND ts=?');
440 $q->execute($userid, $invitation_ts);
441 if (defined($q->fetchrow_hashref)) {
446 if (!defined($group)) {
447 $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!";
448 skv_log("Sender Slack-melding til $slack_name ($userid) for Ć„ spĆørre om gruppe.");
449 } elsif ($group eq '(flere grupper)') {
450 $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!";
451 skv_log("Sender Slack-melding til $slack_name ($userid) for Ć„ spĆørre om gruppe.");
453 $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!";
454 skv_log("Sender Slack-melding om at $slack_name ($userid) er i gruppe $group.");
458 channel => $config::invitation_channel,
462 my $start = [Time::HiRes::gettimeofday];
463 my $response = $ua->post(
464 'https://slack.com/api/chat.postEphemeral',
465 Content => JSON::XS::encode_json($content),
466 Content_type => 'application/json;charset=UTF-8',
467 Authorization => 'Bearer ' . $config::slack_oauth_token
469 log_timing($start, 'chat.postEphemeral');
470 die $response->status_line if !$response->is_success;
471 my $msg_json = JSON::XS::decode_json($response->decoded_content);
472 die "Something went wrong: " . $response->decoded_content if (!defined($msg_json) || !$msg_json->{'ok'});
474 # Mark that we've sent the message, so it won't happen again.
475 $dbh->do('INSERT INTO users_nagged (userid, ts, last_nag) VALUES (?, ?, CURRENT_TIMESTAMP)', undef, $userid, $invitation_ts);
479 my $dbh = DBI->connect("dbi:Pg:dbname=$config::dbname;host=127.0.0.1", $config::dbuser, $config::dbpass, {RaiseError => 1})
480 or warn "Could not connect to Postgres: " . DBI->errstr;
481 if (!defined($dbh)) {
484 $dbh->{AutoCommit} = 0;
485 $dbh->do('LISTEN skvupdate') or return undef;
491 my $total_start = [Time::HiRes::gettimeofday];
494 skv_log("Siste sync startet: " . POSIX::ctime(time));
496 # For the logic on the āappliedā table below.
497 $dbh->do('SET TRANSACTION ISOLATION LEVEL SERIALIZABLE');
499 my $token = get_oauth_bearer_token($dbh, $ua);
501 # Find the newest message, what it is linked to, and what was the one before it (for group diffing).
502 # TODO: Support more than one, and test better for errors here.
503 my $q = $dbh->prepare('select * from message_sheet_link where channel=? order by ts desc limit 2');
504 $q->execute($config::invitation_channel);
505 my $linkref = $q->fetchrow_hashref;
506 my $invitation_ts = $linkref->{'ts'};
507 my $wanted_sheet_title = $linkref->{'sheet_title'};
508 die "Could not get newest sheet title" if (!defined($wanted_sheet_title));
509 my $tab_name = $linkref->{'tab_name'};
510 my $tab_id = $linkref->{'tab_id'};
512 # Store away the second-newest ID.
513 my $prev_invitation_ts = $q->fetchrow_hashref->{'ts'};
515 if (!defined($tab_name) || !defined($tab_id)) {
516 ($tab_name, $tab_id) = get_spreadsheet_with_title($dbh, $ua, $token, $invitation_ts, $wanted_sheet_title);
517 if (!defined($tab_name)) {
518 skv_log("Fant ikke noen fane med Ā«$wanted_sheet_titleĀ» i navnet; kan ikke synkronisere.\n");
519 sheet_batch_update($ua, $token, [ serialize_skv_log_to_sheet() ]);
524 # Find everyone who are marked as attending on Slack (via reactions).
525 $q = $dbh->prepare('SELECT DISTINCT userid,reaction FROM current_reactions WHERE channel=? AND ts=? AND reaction IN (\'heart\', \'open_mouth\', \'blue_heart\')');
526 $q->execute($config::invitation_channel, $invitation_ts);
527 my @attending_userids = ();
530 while (my $ref = $q->fetchrow_hashref) {
531 my $userid = $ref->{'userid'};
532 push @attending_userids, $userid;
533 if ($ref->{'reaction'} eq 'blue_heart') {
534 if (exists($colors{$userid}) && $colors{$userid} eq 'yellow') {
535 $double{$userid} = 1;
537 $colors{$userid} = 'blue';
539 if (exists($colors{$userid}) && $colors{$userid} eq 'blue') {
540 $double{$userid} = 1;
542 $colors{$userid} = 'yellow';
546 # Remove double-attenders (we will log them as warnings further down).
547 @attending_userids = grep { !exists($double{$_}) } @attending_userids;
548 for my $userid (keys %double) {
549 delete $colors{$userid};
552 # Get the list of all people in the sheet (we're going to need them soon).
553 # Also get the Slack mapping when we're doing an API request anyway.
554 my $start = [Time::HiRes::gettimeofday];
555 my $response = $ua->get('https://sheets.googleapis.com/v4/spreadsheets/' . $config::sheet_id . '?key=' . $config::gsheets_api_key . '&ranges=' . $tab_name . '!A4:Z5000&ranges=Slack-mapping!A5:C5000&fields=sheets/data/rowData/values/userEnteredValue',
556 Authorization => 'Bearer ' . $token,
557 Accept_Encoding => HTTP::Message::decodable
559 log_timing($start, "/spreadsheets/");
561 my $sheets_json = JSON::XS::decode_json($response->decoded_content);
562 my $main_sheet_json = $sheets_json->{'sheets'}[0];
563 my $mapping_sheet_json = $sheets_json->{'sheets'}[1];
565 # Update the list of groups we've seen people in.
566 my %assignments = get_group_assignments($main_sheet_json);
567 update_assignment_db($dbh, $config::invitation_channel, $invitation_ts, \%assignments);
569 $start = [Time::HiRes::gettimeofday];
570 my %seen_names = find_where_each_name_is($main_sheet_json);
571 log_timing($start, "Making sort key reverse mapping");
574 for my $name (sort keys %seen_names) {
575 my $seen = $seen_names{$name};
576 if (scalar @$seen >= 2) {
577 my $exemplar = $seen->[0][0];
578 skv_log("Duplikat: $exemplar (" . format_cell_names_for_seen($seen) . ")");
582 # Get our existing Slack->name mapping, from the sheets.
583 my %slack_userid_to_real_name = ();
584 my %slack_userid_to_slack_name = ();
585 my %slack_userid_to_row = ();
587 my $mapping_sheet_rows = $mapping_sheet_json->{'data'}[0]{'rowData'};
589 for my $row (@$mapping_sheet_rows) {
590 my $slack_id = $row->{'values'}[0]{'userEnteredValue'}{'stringValue'};
591 my $slack_name = $row->{'values'}[1]{'userEnteredValue'}{'stringValue'};
592 my $real_name = get_spreadsheet_name($row->{'values'}[2]); # TODO support more
593 $slack_userid_to_row{$slack_id} = $cur_row++;
594 next if (!defined($slack_name));
595 $slack_userid_to_slack_name{$slack_id} = $slack_name;
596 next if (!defined($real_name));
597 $slack_userid_to_real_name{$slack_id} = $real_name;
600 # See which ones we don't have a mapping for, and look them up in Slack.
601 # TODO: Use an append call instead of $cur_row?
602 my @slack_mapping_updates = ();
603 for my $userid (@attending_userids) {
604 next if (exists($slack_userid_to_real_name{$userid}));
606 # Make sure they have a row in the spreadsheet.
608 if (exists($slack_userid_to_row{$userid})) {
609 $write_row = $slack_userid_to_row{$userid};
611 $write_row = $cur_row++;
612 $slack_userid_to_row{$userid} = $write_row;
613 push @slack_mapping_updates, {
614 range => "Slack-mapping!A$write_row:A$write_row",
615 values => [ [ $userid ]]
619 # Fetch their Slack name if we don't already have it.
621 if (exists($slack_userid_to_slack_name{$userid})) {
622 $slack_name = $slack_userid_to_slack_name{$userid};
624 $slack_userid_to_slack_name{$userid} = $slack_name;
625 $slack_name = get_slack_name($ua, $userid);
626 push @slack_mapping_updates, {
627 range => "Slack-mapping!B$write_row:B$write_row",
628 values => [ [ $slack_name ]]
630 $slack_userid_to_slack_name{$userid} = $slack_name;
633 if (exists($seen_names{sort_key($slack_name)})) {
634 # The name exists exactly, once or more, so it's a direct match and we ignore any fuzz.
635 $slack_userid_to_real_name{$userid} = $slack_name;
636 push @slack_mapping_updates, {
637 range => "Slack-mapping!C$write_row:C$write_row",
638 values => [ [ $slack_name ]]
641 # Do a search through all the available names in the sheet to find an obvious(ish) match.
643 my $main_sheet_rows = $main_sheet_json->{'data'}[0]{'rowData'};
644 $start = [Time::HiRes::gettimeofday];
645 my @ap = map { sort_key($_) } split /\s+/, $slack_name; # Precalc for matches_name().
646 for my $row (@$main_sheet_rows) {
647 for my $val (@{$row->{'values'}}) {
648 my $name = get_spreadsheet_name($val);
649 if (defined($name) && matches_name($slack_name, $name, \@ap)) {
650 push @candidates, $name;
654 log_timing($start, "Fuzzy-searching for Slack name ā$slack_nameā");
655 if ($#candidates == -1) {
656 skv_log("$slack_name ($userid) er pƄmeldt pƄ Slack, men fant ikke et regneark-navn for dem.");
657 possibly_nag_user($dbh, $ua, $userid, $invitation_ts, undef, \%slack_userid_to_slack_name);
658 } elsif ($#candidates == 0) {
659 my $name = $candidates[0];
660 $slack_userid_to_real_name{$userid} = $name;
661 push @slack_mapping_updates, {
662 range => "Slack-mapping!C$write_row:C$write_row",
663 values => [ [ $name ]]
666 skv_log("$slack_name ($userid) er pƄmeldt pƄ Slack, men hadde flere fuzzy-matcher; vet ikke hvilket regneark-navn som skal brukes.");
670 if (scalar @slack_mapping_updates > 0) {
672 valueInputOption => 'USER_ENTERED',
673 data => \@slack_mapping_updates
675 $start = [Time::HiRes::gettimeofday];
676 $response = $ua->post(
677 'https://sheets.googleapis.com/v4/spreadsheets/' . $config::sheet_id . '/values:batchUpdate?key=' . $config::gsheets_api_key,
678 Content => JSON::XS::encode_json($update),
679 Content_type => 'application/json;charset=UTF-8',
680 Authorization => 'Bearer ' . $token
682 log_timing($start, "/spreadsheets/values:batchUpdate");
683 die $response->decoded_content if (!$response->is_success);
686 # Now that we have Slack names, we can log double-reacters.
687 for my $userid (keys %double) {
688 my $name = best_name_for_log($userid, \%slack_userid_to_real_name, \%slack_userid_to_slack_name);
689 skv_log("$name er pƄmeldt flere steder pƄ Slack; vet ikke hvilken som skal brukes.");
692 # ...and possibly send welcome messages to remind them of groups.
693 for my $userid (@attending_userids) {
694 my $real_name = $slack_userid_to_real_name{$userid};
695 next if (!defined($real_name));
696 my $group = $assignments{$real_name};
697 next if (!defined($group));
698 possibly_nag_user($dbh, $ua, $userid, $invitation_ts, $group, \%slack_userid_to_slack_name);
701 # Find the list of names to mark yellow.
702 my %want_colors = ();
703 my $main_sheet_rows = $main_sheet_json->{'data'}[0]{'rowData'};
704 for my $userid (@attending_userids) {
705 next if (!exists($slack_userid_to_real_name{$userid}));
706 my $slack_name = $slack_userid_to_slack_name{$userid};
707 my $real_name = $slack_userid_to_real_name{$userid};
709 # See if we can find them in the spreadsheet.
710 if (!exists($seen_names{sort_key($real_name)})) {
711 # TODO: Perhaps move this logic further down, for consistency?
712 skv_log("$slack_name ($userid) er pƄmeldt pƄ Slack, og er mappet til $real_name, men var ikke i noen gruppe.");
714 my $seen = $seen_names{sort_key($real_name)};
715 if (scalar @$seen >= 2) {
716 skv_log("$slack_name ($userid) er pƄmeldt pƄ Slack, men stƄr flere steder (se over); vet ikke hvilken celle som skal brukes.");
718 $want_colors{$seen->[0][0]} = $colors{$userid};
723 # Find the list of names we already marked yellow.
724 my %have_colors = ();
725 $q = $dbh->prepare('SELECT name,color FROM applied WHERE channel=? AND ts=?');
726 $q->execute($config::invitation_channel, $invitation_ts);
727 while (my $ref = $q->fetchrow_hashref) {
728 $have_colors{$ref->{'name'}} = $ref->{'color'};
731 my @diffs = find_diff($dbh, $invitation_ts, \%want_colors, \%have_colors, \%seen_names);
733 my @yellow_updates = ();
734 if (scalar @diffs > 0) {
735 # Now fill in the actual stuff.
736 for my $diff (@diffs) {
737 my $real_name = $diff->[0];
739 my $seen = $seen_names{sort_key($real_name)};
741 # We've already complained about these earlier, so just skip them silently.
742 next if (scalar @$seen > 1);
744 # See if we can find them in the spreadsheet.
745 die "Could not find $real_name" if (!defined($seen));
746 my $rowno = $seen->[0][1];
747 my $colno = $seen->[0][2];
748 push @yellow_updates, {
752 userEnteredFormat => $diff->[1]
755 fields => 'userEnteredFormat.backgroundColor',
758 startRowIndex => $rowno,
759 endRowIndex => $rowno + 1,
760 startColumnIndex => $colno,
761 endColumnIndex => $colno + 1
768 my @recent_changes = create_reaction_log($dbh, $invitation_ts, \%slack_userid_to_real_name, \%slack_userid_to_slack_name);
769 push @yellow_updates, {
771 rows => \@recent_changes,
772 fields => 'userEnteredValue.stringValue',
774 sheetId => $config::log_tab_id,
776 endRowIndex => 4 + scalar @recent_changes,
777 startColumnIndex => 0,
783 my @recent_moves = create_move_log($dbh, $invitation_ts, $prev_invitation_ts);
784 push @yellow_updates, {
786 rows => \@recent_moves,
787 fields => 'userEnteredValue.stringValue',
789 sheetId => $config::log_tab_id,
791 endRowIndex => 4 + scalar @recent_moves,
792 startColumnIndex => 1,
798 # Push the final set of updates (including the log).
800 push @yellow_updates, serialize_skv_log_to_sheet();
801 sheet_batch_update($ua, $token, \@yellow_updates);
804 my $elapsed = Time::HiRes::tv_interval($total_start);
805 printf "Tok %.0f ms.\n", 1e3 * $elapsed;
808 # Initialize the handles we need for communication.
809 my $dbh = db_connect() or die;
810 my $ua = LWP::UserAgent->new(agent => 'SKVidarLang/1.0', keep_alive => 50);
811 if ($#ARGV >= 0 && $ARGV[0] eq '--daemon') {
812 # Start with a single, forced run.
816 while (!defined($dbh)) {
817 print STDERR "Database connection lost, reconnecting...\n";
821 my $s = IO::Select->new($dbh->{pg_socket});
822 my @ready = $s->can_read(10.0);
823 my @exceptions = $s->has_exception(0.0);
825 if (scalar @exceptions > 0) {
830 if (scalar @ready > 0) {
835 warn "Died with: $@";
840 } elsif ($#ARGV >= 0 && $ARGV[0] eq '--benchmark') {