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 ($ua, $token, $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 return ($title, $sheet_id);
273 return (undef, undef);
276 # Make a mapping of lowercase name -> list of [canonical name, row number, column number]
277 sub find_where_each_name_is {
281 my $rows = $json->{'sheets'}[0]{'data'}[0]{'rowData'};
283 for my $row (@$rows) {
285 for my $val (@{$row->{'values'}}) {
286 my $name = get_spreadsheet_name($val);
287 if (defined($name)) {
288 push @{$seen_names{sort_key($name)}}, [$name, $rowno, $colno];
298 sub best_name_for_log {
299 my ($userid, $slack_userid_to_real_name, $slack_userid_to_slack_name) = @_;
300 if (exists($slack_userid_to_real_name->{$userid})) {
301 return $slack_userid_to_real_name->{$userid};
302 } elsif (exists($slack_userid_to_slack_name->{$userid})) {
303 return $slack_userid_to_slack_name->{$userid} . ' (fant ikke regneark-navn)';
305 # Should only happen if we didn't see the initial reaction_add, only reaction_remove.
306 # (TODO: Is the comment above true anymore, now that we use this from multiple contexts?)
307 return $userid . ' (fant ikke Slack-navn)';
311 # Add the reaction log. (This only takes into account the last change
312 # for each user; earlier ones are irrelevant and don't count. But it
313 # doesn't deduplicate across reactions. Meh.)
314 sub create_reaction_log {
315 my ($dbh, $invitation_ts, $slack_userid_to_real_name, $slack_userid_to_slack_name) = @_;
317 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');
318 $q->execute($config::invitation_channel, $invitation_ts);
319 my @recent_changes = ();
320 while (my $ref = $q->fetchrow_hashref) {
321 my $msg = $ref->{'event_ts'};
322 if ($ref->{'event_type'} eq 'reaction_added') {
327 if ($ref->{'reaction'} eq 'open_mouth') {
329 } elsif ($ref->{'reaction'} eq 'blue_heart') {
335 $msg .= best_name_for_log($ref->{'userid'}, $slack_userid_to_real_name, $slack_userid_to_slack_name);
336 push @recent_changes, { values => [{ userEnteredValue => { stringValue => $msg } }] };
338 while (scalar @recent_changes < 50) {
339 push @recent_changes, { values => [{ userEnteredValue => { stringValue => '' } }] };
341 return @recent_changes;
344 sub create_move_log {
345 my ($dbh, $invitation_ts, $prev_invitation_ts) = @_;
346 my $q = $dbh->prepare(<<"EOF");
348 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
349 FROM ( SELECT * FROM current_group_membership_history WHERE ts=? ) g_old
350 FULL OUTER JOIN ( SELECT * FROM current_group_membership_history WHERE ts=? ) g_new USING (name)
352 g_new.group_name IS DISTINCT FROM g_old.group_name
353 AND g_new.group_name IS NOT NULL
354 ORDER BY g_new.change_seen DESC, name
357 $q->execute($prev_invitation_ts, $invitation_ts);
358 my @recent_moves = ();
359 while (my $ref = $q->fetchrow_hashref) {
360 my $name = $ref->{'name'};
361 my $old_group = $ref->{'old_group'};
362 my $new_group = $ref->{'new_group'};
364 my $msg = $ref->{'change_seen'} . " ";
365 if (!defined($old_group)) {
366 $msg .= "$name, (ny lĆøper) ā $new_group";
368 $msg .= "$name, $old_group ā $new_group";
370 push @recent_moves, { values => [{ userEnteredValue => { stringValue => $msg } }] };
372 while (scalar @recent_moves < 50) {
373 push @recent_moves, { values => [{ userEnteredValue => { stringValue => '' } }] };
375 return @recent_moves;
378 # Also applies the diff to the database (a bit ugly).
380 my ($dbh, $invitation_ts, $want_colors, $have_colors, $seen_names) = @_;
383 for my $real_name (keys %$want_colors) {
384 my $wc = $want_colors->{$real_name};
385 if (exists($have_colors->{$real_name})) {
386 if ($have_colors->{$real_name} eq $wc) {
390 skv_log("Markerer at $real_name har byttet treningssted.");
392 $real_name, { backgroundColor => $rgb{$wc} }
394 $dbh->do('UPDATE applied SET color=? WHERE channel=? AND ts=? AND name=?', undef,
395 $wc, $config::invitation_channel, $invitation_ts, $real_name);
397 skv_log("Markerer at $real_name skal pƄ trening.");
399 $real_name, { backgroundColor => $rgb{$wc} }
401 $dbh->do('INSERT INTO applied (channel, ts, name, color) VALUES (?, ?, ?, ?)', undef,
402 $config::invitation_channel, $invitation_ts, $real_name, $wc);
405 for my $real_name (keys %$have_colors) {
406 next if (exists($want_colors->{$real_name}));
407 if (!exists($seen_names->{sort_key($real_name)})) {
408 # TODO: This can somehow come if we try to add someone who's not in the sheet, too?
409 skv_log("Ćnsket Ć„ fjerne at $real_name skulle pĆ„ trening, men de var ikke i regnearket lenger.");
410 } elsif (scalar @{$seen_names->{sort_key($real_name)}} > 1) {
413 skv_log("Fjerner at $real_name skal pƄ trening.");
415 $real_name, { backgroundColor => $rgb{white} }
417 $dbh->do('DELETE FROM applied WHERE channel=? AND ts=? AND name=?', undef,
418 $config::invitation_channel, $invitation_ts, $real_name);
424 sub possibly_nag_user {
425 my ($dbh, $ua, $userid, $invitation_ts, $group, $slack_userid_to_slack_name) = @_;
427 my $slack_name = $slack_userid_to_slack_name->{$userid};
429 # See if we've nagged this user before.
430 my $q = $dbh->prepare('SELECT * FROM users_nagged WHERE userid=? AND ts=?');
431 $q->execute($userid, $invitation_ts);
432 if (defined($q->fetchrow_hashref)) {
437 if (!defined($group)) {
438 $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!";
439 skv_log("Sender Slack-melding til $slack_name ($userid) for Ć„ spĆørre om gruppe.");
440 } elsif ($group eq '(flere grupper)') {
441 $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!";
442 skv_log("Sender Slack-melding til $slack_name ($userid) for Ć„ spĆørre om gruppe.");
444 $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!";
445 skv_log("Sender Slack-melding om at $slack_name ($userid) er i gruppe $group.");
449 channel => $config::invitation_channel,
453 my $start = [Time::HiRes::gettimeofday];
454 my $response = $ua->post(
455 'https://slack.com/api/chat.postEphemeral',
456 Content => JSON::XS::encode_json($content),
457 Content_type => 'application/json;charset=UTF-8',
458 Authorization => 'Bearer ' . $config::slack_oauth_token
460 log_timing($start, 'chat.postEphemeral');
461 die $response->status_line if !$response->is_success;
462 my $msg_json = JSON::XS::decode_json($response->decoded_content);
463 die "Something went wrong: " . $response->decoded_content if (!defined($msg_json) || !$msg_json->{'ok'});
465 # Mark that we've sent the message, so it won't happen again.
466 $dbh->do('INSERT INTO users_nagged (userid, ts, last_nag) VALUES (?, ?, CURRENT_TIMESTAMP)', undef, $userid, $invitation_ts);
470 my $dbh = DBI->connect("dbi:Pg:dbname=$config::dbname;host=127.0.0.1", $config::dbuser, $config::dbpass, {RaiseError => 1})
471 or warn "Could not connect to Postgres: " . DBI->errstr;
472 if (!defined($dbh)) {
475 $dbh->do('LISTEN skvupdate') or return undef;
481 my $total_start = [Time::HiRes::gettimeofday];
484 skv_log("Siste sync startet: " . POSIX::ctime(time));
486 # Initialize the handles we need for communication.
487 my $ua = LWP::UserAgent->new('SKVidarLang/1.0');
488 my $token = get_oauth_bearer_token($dbh, $ua);
490 # Find the newest message, what it is linked to, and what was the one before it (for group diffing).
491 # TODO: Support more than one, and test better for errors here.
492 my $q = $dbh->prepare('select * from message_sheet_link where channel=? order by ts desc limit 2');
493 $q->execute($config::invitation_channel);
494 my $linkref = $q->fetchrow_hashref;
495 my $invitation_ts = $linkref->{'ts'};
496 my $wanted_sheet_title = $linkref->{'sheet_title'};
497 die "Could not get newest sheet title" if (!defined($wanted_sheet_title));
499 my ($tab_name, $tab_id) = get_spreadsheet_with_title($ua, $token, $wanted_sheet_title);
500 if (!defined($tab_name)) {
501 skv_log("Fant ikke noen fane med Ā«$wanted_sheet_titleĀ» i navnet; kan ikke synkronisere.\n");
502 sheet_batch_update($ua, $token, [ serialize_skv_log_to_sheet() ]);
506 # Store away the second-newest ID.
507 my $prev_invitation_ts = $q->fetchrow_hashref->{'ts'};
509 # Find everyone who are marked as attending on Slack (via reactions).
510 $q = $dbh->prepare('SELECT DISTINCT userid,reaction FROM current_reactions WHERE channel=? AND ts=? AND reaction IN (\'heart\', \'open_mouth\', \'blue_heart\')');
511 $q->execute($config::invitation_channel, $invitation_ts);
512 my @attending_userids = ();
515 while (my $ref = $q->fetchrow_hashref) {
516 my $userid = $ref->{'userid'};
517 push @attending_userids, $userid;
518 if ($ref->{'reaction'} eq 'blue_heart') {
519 if (exists($colors{$userid}) && $colors{$userid} eq 'yellow') {
520 $double{$userid} = 1;
522 $colors{$userid} = 'blue';
524 if (exists($colors{$userid}) && $colors{$userid} eq 'blue') {
525 $double{$userid} = 1;
527 $colors{$userid} = 'yellow';
531 # Remove double-attenders (we will log them as warnings further down).
532 @attending_userids = grep { !exists($double{$_}) } @attending_userids;
533 for my $userid (keys %double) {
534 delete $colors{$userid};
537 # Get the list of all people in the sheet (we're going to need them soon anyway).
538 my $start = [Time::HiRes::gettimeofday];
539 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',
540 Authorization => 'Bearer ' . $token,
541 Accept_Encoding => HTTP::Message::decodable
543 log_timing($start, "/spreadsheets/$tab_name");
545 my $main_sheet_json = JSON::XS::decode_json($response->decoded_content);
547 # Update the list of groups we've seen people in.
548 my %assignments = get_group_assignments($main_sheet_json);
549 update_assignment_db($dbh, $config::invitation_channel, $invitation_ts, \%assignments);
551 my %seen_names = find_where_each_name_is($main_sheet_json);
554 for my $name (sort keys %seen_names) {
555 my $seen = $seen_names{$name};
556 if (scalar @$seen >= 2) {
557 my $exemplar = $seen->[0][0];
558 skv_log("Duplikat: $exemplar (" . format_cell_names_for_seen($seen) . ")");
562 # Get our existing Slack->name mapping, from the sheets.
563 my %slack_userid_to_real_name = ();
564 my %slack_userid_to_slack_name = ();
565 my %slack_userid_to_row = ();
567 $start = [Time::HiRes::gettimeofday];
568 $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',
569 Authorization => 'Bearer ' . $token,
570 Accept_Encoding => HTTP::Message::decodable
572 log_timing($start, "/spreadsheets/Slack-mapping");
573 my $mapping_sheet_json = JSON::XS::decode_json($response->decoded_content);
574 my $mapping_sheet_rows = $mapping_sheet_json->{'sheets'}[0]{'data'}[0]{'rowData'};
576 for my $row (@$mapping_sheet_rows) {
577 my $slack_id = $row->{'values'}[0]{'userEnteredValue'}{'stringValue'};
578 my $slack_name = $row->{'values'}[1]{'userEnteredValue'}{'stringValue'};
579 my $real_name = get_spreadsheet_name($row->{'values'}[2]); # TODO support more
580 $slack_userid_to_row{$slack_id} = $cur_row++;
581 next if (!defined($slack_name));
582 $slack_userid_to_slack_name{$slack_id} = $slack_name;
583 next if (!defined($real_name));
584 $slack_userid_to_real_name{$slack_id} = $real_name;
587 # See which ones we don't have a mapping for, and look them up in Slack.
588 # TODO: Use an append call instead of $cur_row?
589 my @slack_mapping_updates = ();
590 for my $userid (@attending_userids) {
591 next if (exists($slack_userid_to_real_name{$userid}));
593 # Make sure they have a row in the spreadsheet.
595 if (exists($slack_userid_to_row{$userid})) {
596 $write_row = $slack_userid_to_row{$userid};
598 $write_row = $cur_row++;
599 $slack_userid_to_row{$userid} = $write_row;
600 push @slack_mapping_updates, {
601 range => "Slack-mapping!A$write_row:A$write_row",
602 values => [ [ $userid ]]
606 # Fetch their Slack name if we don't already have it.
608 if (exists($slack_userid_to_slack_name{$userid})) {
609 $slack_name = $slack_userid_to_slack_name{$userid};
611 $slack_userid_to_slack_name{$userid} = $slack_name;
612 $slack_name = get_slack_name($ua, $userid);
613 push @slack_mapping_updates, {
614 range => "Slack-mapping!B$write_row:B$write_row",
615 values => [ [ $slack_name ]]
617 $slack_userid_to_slack_name{$userid} = $slack_name;
620 if (exists($seen_names{sort_key($slack_name)})) {
621 # The name exists exactly, once or more, so it's a direct match and we ignore any fuzz.
622 $slack_userid_to_real_name{$userid} = $slack_name;
623 push @slack_mapping_updates, {
624 range => "Slack-mapping!C$write_row:C$write_row",
625 values => [ [ $slack_name ]]
628 # Do a search through all the available names in the sheet to find an obvious(ish) match.
630 my $main_sheet_rows = $main_sheet_json->{'sheets'}[0]{'data'}[0]{'rowData'};
631 for my $row (@$main_sheet_rows) {
632 for my $val (@{$row->{'values'}}) {
633 my $name = get_spreadsheet_name($val);
634 if (defined($name) && matches_name($slack_name, $name)) {
635 push @candidates, $name;
639 if ($#candidates == -1) {
640 skv_log("$slack_name ($userid) er pƄmeldt pƄ Slack, men fant ikke et regneark-navn for dem.");
641 possibly_nag_user($dbh, $ua, $userid, $invitation_ts, undef, \%slack_userid_to_slack_name);
642 } elsif ($#candidates == 0) {
643 my $name = $candidates[0];
644 $slack_userid_to_real_name{$userid} = $name;
645 push @slack_mapping_updates, {
646 range => "Slack-mapping!C$write_row:C$write_row",
647 values => [ [ $name ]]
650 skv_log("$slack_name ($userid) er pƄmeldt pƄ Slack, men hadde flere fuzzy-matcher; vet ikke hvilket regneark-navn som skal brukes.");
654 if (scalar @slack_mapping_updates > 0) {
656 valueInputOption => 'USER_ENTERED',
657 data => \@slack_mapping_updates
659 $start = [Time::HiRes::gettimeofday];
660 $response = $ua->post(
661 'https://sheets.googleapis.com/v4/spreadsheets/' . $config::sheet_id . '/values:batchUpdate?key=' . $config::gsheets_api_key,
662 Content => JSON::XS::encode_json($update),
663 Content_type => 'application/json;charset=UTF-8',
664 Authorization => 'Bearer ' . $token
666 log_timing($start, "/spreadsheets/values:batchUpdate");
667 die $response->decoded_content if (!$response->is_success);
670 # Now that we have Slack names, we can log double-reacters.
671 for my $userid (keys %double) {
672 my $name = best_name_for_log($userid, \%slack_userid_to_real_name, \%slack_userid_to_slack_name);
673 skv_log("$name er pƄmeldt flere steder pƄ Slack; vet ikke hvilken som skal brukes.");
676 # ...and possibly send welcome messages to remind them of groups.
677 for my $userid (@attending_userids) {
678 my $real_name = $slack_userid_to_real_name{$userid};
679 next if (!defined($real_name));
680 my $group = $assignments{$real_name};
681 next if (!defined($group));
682 possibly_nag_user($dbh, $ua, $userid, $invitation_ts, $group, \%slack_userid_to_slack_name);
685 # Find the list of names to mark yellow.
686 my %want_colors = ();
687 my $main_sheet_rows = $main_sheet_json->{'sheets'}[0]{'data'}[0]{'rowData'};
688 for my $userid (@attending_userids) {
689 next if (!exists($slack_userid_to_real_name{$userid}));
690 my $slack_name = $slack_userid_to_slack_name{$userid};
691 my $real_name = $slack_userid_to_real_name{$userid};
693 # See if we can find them in the spreadsheet.
694 if (!exists($seen_names{sort_key($real_name)})) {
695 # TODO: Perhaps move this logic further down, for consistency?
696 skv_log("$slack_name ($userid) er pƄmeldt pƄ Slack, og er mappet til $real_name, men var ikke i noen gruppe.");
698 my $seen = $seen_names{sort_key($real_name)};
699 if (scalar @$seen >= 2) {
700 skv_log("$slack_name ($userid) er pƄmeldt pƄ Slack, men stƄr flere steder (se over); vet ikke hvilken celle som skal brukes.");
702 $want_colors{$seen->[0][0]} = $colors{$userid};
707 # Find the list of names we already marked yellow.
708 my %have_colors = ();
709 $dbh->{AutoCommit} = 0;
710 $dbh->do('SET TRANSACTION ISOLATION LEVEL SERIALIZABLE');
711 $q = $dbh->prepare('SELECT name,color FROM applied WHERE channel=? AND ts=?');
712 $q->execute($config::invitation_channel, $invitation_ts);
713 while (my $ref = $q->fetchrow_hashref) {
714 $have_colors{$ref->{'name'}} = $ref->{'color'};
717 my @diffs = find_diff($dbh, $invitation_ts, \%want_colors, \%have_colors, \%seen_names);
719 my @yellow_updates = ();
720 if (scalar @diffs > 0) {
721 # Now fill in the actual stuff.
722 for my $diff (@diffs) {
723 my $real_name = $diff->[0];
725 my $seen = $seen_names{sort_key($real_name)};
727 # We've already complained about these earlier, so just skip them silently.
728 next if (scalar @$seen > 1);
730 # See if we can find them in the spreadsheet.
731 die "Could not find $real_name" if (!defined($seen));
732 my $rowno = $seen->[0][1];
733 my $colno = $seen->[0][2];
734 push @yellow_updates, {
738 userEnteredFormat => $diff->[1]
741 fields => 'userEnteredFormat.backgroundColor',
744 startRowIndex => $rowno,
745 endRowIndex => $rowno + 1,
746 startColumnIndex => $colno,
747 endColumnIndex => $colno + 1
754 my @recent_changes = create_reaction_log($dbh, $invitation_ts, \%slack_userid_to_real_name, \%slack_userid_to_slack_name);
755 push @yellow_updates, {
757 rows => \@recent_changes,
758 fields => 'userEnteredValue.stringValue',
760 sheetId => $config::log_tab_id,
762 endRowIndex => 4 + scalar @recent_changes,
763 startColumnIndex => 0,
769 my @recent_moves = create_move_log($dbh, $invitation_ts, $prev_invitation_ts);
770 push @yellow_updates, {
772 rows => \@recent_moves,
773 fields => 'userEnteredValue.stringValue',
775 sheetId => $config::log_tab_id,
777 endRowIndex => 4 + scalar @recent_moves,
778 startColumnIndex => 1,
784 # Push the final set of updates (including the log).
786 push @yellow_updates, serialize_skv_log_to_sheet();
787 sheet_batch_update($ua, $token, \@yellow_updates);
790 my $elapsed = Time::HiRes::tv_interval($total_start);
791 printf "Tok %.0f ms.\n", 1e3 * $elapsed;
794 my $dbh = db_connect() or die;
795 if ($#ARGV >= 0 && $ARGV[0] eq '--daemon') {
796 # Start with a single, forced run.
800 while (!defined($dbh)) {
801 print STDERR "Database connection lost, reconnecting...\n";
805 my $s = IO::Select->new($dbh->{pg_socket});
806 my @ready = $s->can_read(10.0);
807 my @exceptions = $s->has_exception(0.0);
809 if (scalar @exceptions > 0) {
814 if (scalar @ready > 0) {
816 $dbh->{AutoCommit} = 1;
821 warn "Died with: $@";