12 binmode STDOUT, ':utf8';
13 binmode STDERR, ':utf8';
16 require '../include/config.pm';
42 my ($start, $msg) = @_;
43 my $elapsed = Time::HiRes::tv_interval($start);
44 printf "%s: %.0f ms.\n", $msg, 1e3 * $elapsed;
47 sub get_oauth_bearer_token {
51 # See if the database already has a token we could use, that doesn't expire in a while.
52 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);
53 if (defined($ref->{'token'})) {
54 return $ref->{'token'};
57 my $jwt = JSON::XS::encode_json({
58 "iss" => $config::jwt_key->{'client_email'},
59 "scope" => "https://www.googleapis.com/auth/spreadsheets",
60 "aud" => "https://www.googleapis.com/oauth2/v4/token",
64 my $jws_token = Crypt::JWT::encode_jwt(payload=>$jwt, alg=>'RS256', key=>\$config::jwt_key->{'private_key'});
65 my $start = [Time::HiRes::gettimeofday];
66 my $response = $ua->post('https://www.googleapis.com/oauth2/v4/token', [
67 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
68 'assertion' => $jws_token ]);
69 log_timing($start, '/oauth2/v4/token');
70 my $token = JSON::XS::decode_json($response->decoded_content)->{'access_token'};
71 $dbh->do('INSERT INTO oauth_tokens (token, acquired, expiry) VALUES (?, TIMESTAMPTZ \'1970-01-01\' + ? * INTERVAL \'1 second\', TIMESTAMPTZ \'1970-01-01\' + ? * INTERVAL \'1 second\')',
72 undef, $token, $now, $now + 1800);
77 my ($ua, $userid) = @_;
78 my $req = HTTP::Request->new('GET', 'https://slack.com/api/users.info?user=' . $userid, [
79 'Authorization' => 'Bearer ' . $config::slack_oauth_token
81 my $start = [Time::HiRes::gettimeofday];
82 my $response = $ua->request($req);
83 log_timing($start, '/users.info');
84 die $response->status_line if !$response->is_success;
86 my $user_json = JSON::XS::decode_json($response->decoded_content);
87 die "Something went wrong: " . $response->decoded_content if (!defined($user_json) || !$user_json->{'ok'});
89 return $user_json->{'user'}{'real_name'};
92 sub get_spreadsheet_name {
94 my $name = $cell->{'userEnteredValue'}{'stringValue'};
95 return undef if (!defined($name));
96 return undef if ($name =~ /^G[1-4]\.[1-5]/);
101 $name =~ s/G\d\.\d?\??//;
109 my ($slack_name, $spreadsheet_name) = @_;
110 if (lc($slack_name) eq lc($spreadsheet_name)) {
114 my @ap = split /\s+/, $slack_name;
115 my @bp = split /\s+/, $spreadsheet_name;
116 if (scalar @ap >= 2 && scalar @bp >= 2 && lc($ap[0]) eq lc($bp[0])) {
117 # First name matches, try to match some surname
119 for my $ai (1..$#ap) {
120 for my $bi (1..$#bp) {
121 $found = 1 if (lc($ap[$ai]) eq lc($bp[$bi]));
125 skv_log("Fuzzy-matchet $slack_name -> $spreadsheet_name.");
133 sub format_cell_names_for_seen {
135 my @cells = map { chr(ord('A') + $_->[2]) . ($_->[1] + 1) } @$seen;
136 return join(', ', @cells);
141 print STDERR "$msg\n";
145 sub serialize_skv_log_to_sheet {
150 userEnteredValue => { stringValue => join("\n", @log) }
153 fields => 'userEnteredValue.stringValue',
155 sheetId => $config::log_tab_id,
158 startColumnIndex => 0,
165 sub sheet_batch_update {
166 my ($ua, $token, @requests) = @_;
168 requests => \@requests
170 my $start = [Time::HiRes::gettimeofday];
171 my $response = $ua->post(
172 'https://sheets.googleapis.com/v4/spreadsheets/' . $config::sheet_id . ':batchUpdate?key=' . $config::gsheets_api_key,
173 Content => JSON::XS::encode_json($update),
174 Content_type => 'application/json;charset=UTF-8',
175 Authorization => 'Bearer ' . $token
177 log_timing($start, '/spreadsheets/values:batchUpdate');
178 die $response->decoded_content if !$response->is_success;
181 sub get_group_assignments {
184 my %assignments = ();
185 my $rows = $json->{'sheets'}[0]{'data'}[0]{'rowData'};
186 my @curr_groups = ();
187 for my $row (@$rows) {
189 for my $val (@{$row->{'values'}}) {
191 my $contents = $val->{'userEnteredValue'}{'stringValue'};
192 next if !defined($contents);
193 if ($contents =~ /Gruppe /) {
197 next if $contents =~ /^VL:/;
198 next if $contents =~ /^LT\b/;
199 next if $contents =~ /^400m/;
200 next if $contents =~ /^546m/;
201 if ($contents =~ /^(G\d\.\d)/ || $contents =~ /^(Nye lĆøpere.*)/) {
202 $curr_groups[$col] = $1;
204 my $name = get_spreadsheet_name($val);
205 next if (!defined($name));
206 my $group = $curr_groups[$col] // $curr_groups[$col - 1];
207 # print $group, " ", $name, "\n";
208 if (exists($assignments{$name})) {
209 $assignments{$name} = "(flere grupper)";
211 $assignments{$name} = $group;
219 sub update_assignment_db {
220 my ($dbh, $channel, $ts, $assignments) = @_;
222 local $dbh->{AutoCommit} = 0;
223 my %db_assignments = ();
224 my $q = $dbh->prepare('SELECT name,group_name FROM current_group_membership_history WHERE channel=? AND ts=?');
225 $q->execute($channel, $ts);
226 while (my $ref = $q->fetchrow_hashref) {
227 if (defined($ref->{'group_name'})) {
228 $db_assignments{$ref->{'name'}} = $ref->{'group_name'};
232 $q = $dbh->prepare('INSERT INTO group_membership_history (channel, ts, name, change_seen, group_name) VALUES (?, ?, ?, CURRENT_TIMESTAMP, ?)');
233 for my $name (keys %$assignments) {
234 if (!exists($db_assignments{$name}) || $db_assignments{$name} ne $assignments->{$name}) {
235 $q->execute($channel, $ts, $name, $assignments->{$name});
238 for my $name (keys %db_assignments) {
239 if (!exists($assignments->{$name})) {
240 $q->execute($channel, $ts, $name, undef);
246 sub get_spreadsheet_with_title {
247 my ($ua, $token, $wanted_sheet_title) = @_;
249 # See if we have any spreadsheets that match this title.
250 my $start = [Time::HiRes::gettimeofday];
251 my $response = $ua->get('https://sheets.googleapis.com/v4/spreadsheets/' . $config::sheet_id . '?key=' . $config::gsheets_api_key . '&fields=sheets/properties',
252 Authorization => 'Bearer ' . $token,
253 Accept_Encoding => HTTP::Message::decodable
255 log_timing($start, '/spreadsheets/properties');
256 my $sheets_json = JSON::XS::decode_json($response->decoded_content);
257 my ($tab_name, $tab_id);
258 for my $sheet (@{$sheets_json->{'sheets'}}) {
259 my $title = $sheet->{'properties'}{'title'};
260 my $sheet_id = $sheet->{'properties'}{'sheetId'};
261 if ($title =~ /\Q$wanted_sheet_title\E/) {
262 # skv_log("Synkroniserer ($config::invitation_channel, $invitation_ts) mot arket ā$titleā (fane-ID $sheet_id).");
263 return ($title, $sheet_id);
266 return (undef, undef);
269 # Make a mapping of lowercase name -> list of [canonical name, row number, column number]
270 sub find_where_each_name_is {
274 my $rows = $json->{'sheets'}[0]{'data'}[0]{'rowData'};
276 for my $row (@$rows) {
278 for my $val (@{$row->{'values'}}) {
279 my $name = get_spreadsheet_name($val);
280 if (defined($name)) {
281 push @{$seen_names{lc $name}}, [$name, $rowno, $colno];
291 sub best_name_for_log {
292 my ($userid, $slack_userid_to_real_name, $slack_userid_to_slack_name) = @_;
293 if (exists($slack_userid_to_real_name->{$userid})) {
294 return $slack_userid_to_real_name->{$userid};
295 } elsif (exists($slack_userid_to_slack_name->{$userid})) {
296 return $slack_userid_to_slack_name->{$userid} . ' (fant ikke regneark-navn)';
298 # Should only happen if we didn't see the initial reaction_add, only reaction_remove.
299 # (TODO: Is the comment above true anymore, now that we use this from multiple contexts?)
300 return $userid . ' (fant ikke Slack-navn)';
304 # Add the reaction log. (This only takes into account the last change
305 # for each user; earlier ones are irrelevant and don't count. But it
306 # doesn't deduplicate across reactions. Meh.)
307 sub create_reaction_log {
308 my ($dbh, $invitation_ts, $slack_userid_to_real_name, $slack_userid_to_slack_name) = @_;
310 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');
311 $q->execute($config::invitation_channel, $invitation_ts);
312 my @recent_changes = ();
313 while (my $ref = $q->fetchrow_hashref) {
314 my $msg = $ref->{'event_ts'};
315 if ($ref->{'event_type'} eq 'reaction_added') {
320 if ($ref->{'reaction'} eq 'open_mouth') {
322 } elsif ($ref->{'reaction'} eq 'blue_heart') {
328 $msg .= best_name_for_log($ref->{'userid'}, $slack_userid_to_real_name, $slack_userid_to_slack_name);
329 push @recent_changes, { values => [{ userEnteredValue => { stringValue => $msg } }] };
331 while (scalar @recent_changes < 50) {
332 push @recent_changes, { values => [{ userEnteredValue => { stringValue => '' } }] };
334 return @recent_changes;
337 sub create_move_log {
338 my ($dbh, $invitation_ts, $prev_invitation_ts) = @_;
339 my $q = $dbh->prepare(<<"EOF");
341 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
342 FROM ( SELECT * FROM current_group_membership_history WHERE ts=? ) g_old
343 FULL OUTER JOIN ( SELECT * FROM current_group_membership_history WHERE ts=? ) g_new USING (name)
345 g_new.group_name IS DISTINCT FROM g_old.group_name
346 AND g_new.group_name IS NOT NULL
347 ORDER BY g_new.change_seen DESC, name
350 $q->execute($prev_invitation_ts, $invitation_ts);
351 my @recent_moves = ();
352 while (my $ref = $q->fetchrow_hashref) {
353 my $name = $ref->{'name'};
354 my $old_group = $ref->{'old_group'};
355 my $new_group = $ref->{'new_group'};
357 my $msg = $ref->{'change_seen'} . " ";
358 if (!defined($old_group)) {
359 $msg .= "$name, (ny lĆøper) ā $new_group";
361 $msg .= "$name, $old_group ā $new_group";
363 push @recent_moves, { values => [{ userEnteredValue => { stringValue => $msg } }] };
365 while (scalar @recent_moves < 50) {
366 push @recent_moves, { values => [{ userEnteredValue => { stringValue => '' } }] };
368 return @recent_moves;
371 # Also applies the diff to the database (a bit ugly).
373 my ($dbh, $invitation_ts, $want_colors, $have_colors, $seen_names) = @_;
376 for my $real_name (keys %$want_colors) {
377 my $wc = $want_colors->{$real_name};
378 if (exists($have_colors->{$real_name})) {
379 if ($have_colors->{$real_name} eq $wc) {
383 skv_log("Markerer at $real_name har byttet treningssted.");
385 $real_name, { backgroundColor => $rgb{$wc} }
387 $dbh->do('UPDATE applied SET color=? WHERE channel=? AND ts=? AND name=?', undef,
388 $wc, $config::invitation_channel, $invitation_ts, $real_name);
390 skv_log("Markerer at $real_name skal pƄ trening.");
392 $real_name, { backgroundColor => $rgb{$wc} }
394 $dbh->do('INSERT INTO applied (channel, ts, name, color) VALUES (?, ?, ?, ?)', undef,
395 $config::invitation_channel, $invitation_ts, $real_name, $wc);
398 for my $real_name (keys %$have_colors) {
399 next if (exists($want_colors->{$real_name}));
400 if (!exists($seen_names->{lc $real_name})) {
401 # TODO: This can somehow come if we try to add someone who's not in the sheet, too?
402 skv_log("Ćnsket Ć„ fjerne at $real_name skulle pĆ„ trening, men de var ikke i regnearket lenger.");
403 } elsif (scalar @{$seen_names->{lc $real_name}} > 1) {
406 skv_log("Fjerner at $real_name skal pƄ trening.");
408 $real_name, { backgroundColor => $rgb{white} }
410 $dbh->do('DELETE FROM applied WHERE channel=? AND ts=? AND name=?', undef,
411 $config::invitation_channel, $invitation_ts, $real_name);
417 sub possibly_nag_user {
418 my ($dbh, $ua, $userid) = @_;
420 # See if we've nagged this user before.
421 my $q = $dbh->prepare('SELECT * FROM users_nagged WHERE userid=?');
422 $q->execute($userid);
423 if (defined($q->fetchrow_hashref)) {
427 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!";
429 skv_log("Sender melding til $userid for Ć„ spĆørre om gruppe.");
431 channel => $config::invitation_channel,
435 my $start = [Time::HiRes::gettimeofday];
436 my $response = $ua->post(
437 'https://slack.com/api/chat.postEphemeral',
438 Content => JSON::XS::encode_json($content),
439 Content_type => 'application/json;charset=UTF-8',
440 Authorization => 'Bearer ' . $config::slack_oauth_token
442 log_timing($start, 'chat.postEphemeral');
443 die $response->status_line if !$response->is_success;
444 my $msg_json = JSON::XS::decode_json($response->decoded_content);
445 die "Something went wrong: " . $response->decoded_content if (!defined($msg_json) || !$msg_json->{'ok'});
447 # Mark that we've sent the message, so it won't happen again.
448 $dbh->do('INSERT INTO users_nagged (userid, last_nag) VALUES (?, CURRENT_TIMESTAMP)', undef, $userid);
452 my $dbh = DBI->connect("dbi:Pg:dbname=$config::dbname;host=127.0.0.1", $config::dbuser, $config::dbpass, {RaiseError => 1})
453 or warn "Could not connect to Postgres: " . DBI->errstr;
454 if (!defined($dbh)) {
457 $dbh->do('LISTEN skvupdate') or return undef;
463 my $total_start = [Time::HiRes::gettimeofday];
466 skv_log("Siste sync startet: " . POSIX::ctime(time));
468 # Initialize the handles we need for communication.
469 my $ua = LWP::UserAgent->new('SKVidarLang/1.0');
470 my $token = get_oauth_bearer_token($dbh, $ua);
472 # Find the newest message, what it is linked to, and what was the one before it (for group diffing).
473 # TODO: Support more than one, and test better for errors here.
474 my $q = $dbh->prepare('select * from message_sheet_link where channel=? order by ts desc limit 2');
475 $q->execute($config::invitation_channel);
476 my $linkref = $q->fetchrow_hashref;
477 my $invitation_ts = $linkref->{'ts'};
478 my $wanted_sheet_title = $linkref->{'sheet_title'};
479 die "Could not get newest sheet title" if (!defined($wanted_sheet_title));
481 my ($tab_name, $tab_id) = get_spreadsheet_with_title($ua, $token, $wanted_sheet_title);
482 if (!defined($tab_name)) {
483 skv_log("Fant ikke noen fane med Ā«$wanted_sheet_titleĀ» i navnet; kan ikke synkronisere.\n");
484 sheet_batch_update($ua, $token, [ serialize_skv_log_to_sheet() ]);
488 # Store away the second-newest ID.
489 my $prev_invitation_ts = $q->fetchrow_hashref->{'ts'};
491 # Find everyone who are marked as attending on Slack (via reactions).
492 $q = $dbh->prepare('SELECT DISTINCT userid,reaction FROM current_reactions WHERE channel=? AND ts=? AND reaction IN (\'heart\', \'open_mouth\', \'blue_heart\')');
493 $q->execute($config::invitation_channel, $invitation_ts);
494 my @attending_userids = ();
497 while (my $ref = $q->fetchrow_hashref) {
498 my $userid = $ref->{'userid'};
499 push @attending_userids, $userid;
500 if ($ref->{'reaction'} eq 'blue_heart') {
501 if (exists($colors{$userid}) && $colors{$userid} eq 'yellow') {
502 $double{$userid} = 1;
504 $colors{$userid} = 'blue';
506 if (exists($colors{$userid}) && $colors{$userid} eq 'blue') {
507 $double{$userid} = 1;
509 $colors{$userid} = 'yellow';
513 # Remove double-attenders (we will log them as warnings further down).
514 @attending_userids = grep { !exists($double{$_}) } @attending_userids;
515 for my $userid (keys %double) {
516 delete $colors{$userid};
519 # Get the list of all people in the sheet (we're going to need them soon anyway).
520 my $start = [Time::HiRes::gettimeofday];
521 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',
522 Authorization => 'Bearer ' . $token,
523 Accept_Encoding => HTTP::Message::decodable
525 log_timing($start, "/spreadsheets/$tab_name");
527 my $main_sheet_json = JSON::XS::decode_json($response->decoded_content);
529 # Update the list of groups we've seen people in.
530 my %assignments = get_group_assignments($main_sheet_json);
531 update_assignment_db($dbh, $config::invitation_channel, $invitation_ts, \%assignments);
533 my %seen_names = find_where_each_name_is($main_sheet_json);
536 for my $name (sort keys %seen_names) {
537 my $seen = $seen_names{$name};
538 if (scalar @$seen >= 2) {
539 my $exemplar = $seen->[0][0];
540 skv_log("Duplikat: $exemplar (" . format_cell_names_for_seen($seen) . ")");
544 # Get our existing Slack->name mapping, from the sheets.
545 my %slack_userid_to_real_name = ();
546 my %slack_userid_to_slack_name = ();
547 my %slack_userid_to_row = ();
549 $start = [Time::HiRes::gettimeofday];
550 $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',
551 Authorization => 'Bearer ' . $token,
552 Accept_Encoding => HTTP::Message::decodable
554 log_timing($start, "/spreadsheets/Slack-mapping");
555 my $mapping_sheet_json = JSON::XS::decode_json($response->decoded_content);
556 my $mapping_sheet_rows = $mapping_sheet_json->{'sheets'}[0]{'data'}[0]{'rowData'};
558 for my $row (@$mapping_sheet_rows) {
559 my $slack_id = $row->{'values'}[0]{'userEnteredValue'}{'stringValue'};
560 my $slack_name = $row->{'values'}[1]{'userEnteredValue'}{'stringValue'};
561 my $real_name = get_spreadsheet_name($row->{'values'}[2]); # TODO support more
562 $slack_userid_to_row{$slack_id} = $cur_row++;
563 next if (!defined($slack_name));
564 $slack_userid_to_slack_name{$slack_id} = $slack_name;
565 next if (!defined($real_name));
566 $slack_userid_to_real_name{$slack_id} = $real_name;
569 # See which ones we don't have a mapping for, and look them up in Slack.
570 # TODO: Use an append call instead of $cur_row?
571 my @slack_mapping_updates = ();
572 for my $userid (@attending_userids) {
573 next if (exists($slack_userid_to_real_name{$userid}));
575 # Make sure they have a row in the spreadsheet.
577 if (exists($slack_userid_to_row{$userid})) {
578 $write_row = $slack_userid_to_row{$userid};
580 $write_row = $cur_row++;
581 $slack_userid_to_row{$userid} = $write_row;
582 push @slack_mapping_updates, {
583 range => "Slack-mapping!A$write_row:A$write_row",
584 values => [ [ $userid ]]
588 # Fetch their Slack name if we don't already have it.
590 if (exists($slack_userid_to_slack_name{$userid})) {
591 $slack_name = $slack_userid_to_slack_name{$userid};
593 $slack_userid_to_slack_name{$userid} = $slack_name;
594 $slack_name = get_slack_name($ua, $userid);
595 push @slack_mapping_updates, {
596 range => "Slack-mapping!B$write_row:B$write_row",
597 values => [ [ $slack_name ]]
599 $slack_userid_to_slack_name{$userid} = $slack_name;
602 if (exists($seen_names{lc $slack_name})) {
603 # The name exists exactly, once or more, so it's a direct match and we ignore any fuzz.
604 $slack_userid_to_real_name{$userid} = $slack_name;
605 push @slack_mapping_updates, {
606 range => "Slack-mapping!C$write_row:C$write_row",
607 values => [ [ $slack_name ]]
610 # Do a search through all the available names in the sheet to find an obvious(ish) match.
612 my $main_sheet_rows = $main_sheet_json->{'sheets'}[0]{'data'}[0]{'rowData'};
613 for my $row (@$main_sheet_rows) {
614 for my $val (@{$row->{'values'}}) {
615 my $name = get_spreadsheet_name($val);
616 if (defined($name) && matches_name($slack_name, $name)) {
617 push @candidates, $name;
621 if ($#candidates == -1) {
622 skv_log("$slack_name ($userid) er pƄmeldt pƄ Slack, men fant ikke et regneark-navn for dem.");
623 possibly_nag_user($dbh, $ua, $userid);
624 } elsif ($#candidates == 0) {
625 my $name = $candidates[0];
626 $slack_userid_to_real_name{$userid} = $name;
627 push @slack_mapping_updates, {
628 range => "Slack-mapping!C$write_row:C$write_row",
629 values => [ [ $name ]]
632 skv_log("$slack_name ($userid) er pƄmeldt pƄ Slack, men hadde flere fuzzy-matcher; vet ikke hvilket regneark-navn som skal brukes.");
636 if (scalar @slack_mapping_updates > 0) {
638 valueInputOption => 'USER_ENTERED',
639 data => \@slack_mapping_updates
641 $start = [Time::HiRes::gettimeofday];
642 $response = $ua->post(
643 'https://sheets.googleapis.com/v4/spreadsheets/' . $config::sheet_id . '/values:batchUpdate?key=' . $config::gsheets_api_key,
644 Content => JSON::XS::encode_json($update),
645 Content_type => 'application/json;charset=UTF-8',
646 Authorization => 'Bearer ' . $token
648 log_timing($start, "/spreadsheets/values:batchUpdate");
649 die $response->decoded_content if (!$response->is_success);
652 # Now that we have Slack names, we can log double-reacters.
653 for my $userid (keys %double) {
654 my $name = best_name_for_log($userid, \%slack_userid_to_real_name, \%slack_userid_to_slack_name);
655 skv_log("$name er pƄmeldt flere steder pƄ Slack; vet ikke hvilken som skal brukes.");
658 # Find the list of names to mark yellow.
659 my %want_colors = ();
660 my $main_sheet_rows = $main_sheet_json->{'sheets'}[0]{'data'}[0]{'rowData'};
661 for my $userid (@attending_userids) {
662 next if (!exists($slack_userid_to_real_name{$userid}));
663 my $slack_name = $slack_userid_to_slack_name{$userid};
664 my $real_name = $slack_userid_to_real_name{$userid};
666 # See if we can find them in the spreadsheet.
667 if (!exists($seen_names{lc $real_name})) {
668 # TODO: Perhaps move this logic further down, for consistency?
669 skv_log("$slack_name ($userid) er pƄmeldt pƄ Slack, og er mappet til $real_name, men var ikke i noen gruppe.");
671 my $seen = $seen_names{lc $real_name};
672 if (scalar @$seen >= 2) {
673 skv_log("$slack_name ($userid) er pƄmeldt pƄ Slack, men stƄr flere steder (se over); vet ikke hvilken celle som skal brukes.");
675 $want_colors{$seen->[0][0]} = $colors{$userid};
680 # Find the list of names we already marked yellow.
681 my %have_colors = ();
682 $dbh->{AutoCommit} = 0;
683 $dbh->do('SET TRANSACTION ISOLATION LEVEL SERIALIZABLE');
684 $q = $dbh->prepare('SELECT name,color FROM applied WHERE channel=? AND ts=?');
685 $q->execute($config::invitation_channel, $invitation_ts);
686 while (my $ref = $q->fetchrow_hashref) {
687 $have_colors{$ref->{'name'}} = $ref->{'color'};
690 my @diffs = find_diff($dbh, $invitation_ts, \%want_colors, \%have_colors, \%seen_names);
692 my @yellow_updates = ();
693 if (scalar @diffs > 0) {
694 # Now fill in the actual stuff.
695 for my $diff (@diffs) {
696 my $real_name = $diff->[0];
698 my $seen = $seen_names{lc $real_name};
700 # We've already complained about these earlier, so just skip them silently.
701 next if (scalar @$seen > 1);
703 # See if we can find them in the spreadsheet.
704 die "Could not find $real_name" if (!defined($seen));
705 my $rowno = $seen->[0][1];
706 my $colno = $seen->[0][2];
707 push @yellow_updates, {
711 userEnteredFormat => $diff->[1]
714 fields => 'userEnteredFormat.backgroundColor',
717 startRowIndex => $rowno,
718 endRowIndex => $rowno + 1,
719 startColumnIndex => $colno,
720 endColumnIndex => $colno + 1
727 my @recent_changes = create_reaction_log($dbh, $invitation_ts, \%slack_userid_to_real_name, \%slack_userid_to_slack_name);
728 push @yellow_updates, {
730 rows => \@recent_changes,
731 fields => 'userEnteredValue.stringValue',
733 sheetId => $config::log_tab_id,
735 endRowIndex => 4 + scalar @recent_changes,
736 startColumnIndex => 0,
742 my @recent_moves = create_move_log($dbh, $invitation_ts, $prev_invitation_ts);
743 push @yellow_updates, {
745 rows => \@recent_moves,
746 fields => 'userEnteredValue.stringValue',
748 sheetId => $config::log_tab_id,
750 endRowIndex => 4 + scalar @recent_moves,
751 startColumnIndex => 1,
757 # Push the final set of updates (including the log).
759 push @yellow_updates, serialize_skv_log_to_sheet();
760 sheet_batch_update($ua, $token, \@yellow_updates);
763 my $elapsed = Time::HiRes::tv_interval($total_start);
764 printf "Tok %.0f ms.\n", 1e3 * $elapsed;
767 my $dbh = db_connect() or die;
768 if ($#ARGV >= 0 && $ARGV[0] eq '--daemon') {
769 # Start with a single, forced run.
773 while (!defined($dbh)) {
774 print STDERR "Database connection lost, reconnecting...\n";
778 my $s = IO::Select->new($dbh->{pg_socket});
779 my @ready = $s->can_read(10.0);
780 my @exceptions = $s->has_exception(0.0);
782 if (scalar @exceptions > 0) {
787 if (scalar @ready > 0) {
789 $dbh->{AutoCommit} = 1;
794 warn "Died with: $@";