11 binmode STDOUT, ':utf8';
12 binmode STDERR, ':utf8';
15 require '../include/config.pm';
41 sub get_oauth_bearer_token {
44 my $jwt = JSON::XS::encode_json({
45 "iss" => $config::jwt_key->{'client_email'},
46 "scope" => "https://www.googleapis.com/auth/spreadsheets",
47 "aud" => "https://www.googleapis.com/oauth2/v4/token",
51 my $jws_token = Crypt::JWT::encode_jwt(payload=>$jwt, alg=>'RS256', key=>\$config::jwt_key->{'private_key'});
52 my $response = $ua->post('https://www.googleapis.com/oauth2/v4/token', [
53 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
54 'assertion' => $jws_token ]);
55 return JSON::XS::decode_json($response->decoded_content)->{'access_token'};
59 my ($ua, $userid) = @_;
60 my $req = HTTP::Request->new('GET', 'https://slack.com/api/users.info?user=' . $userid, [
61 'Authorization' => 'Bearer ' . $config::slack_oauth_token
63 my $response = $ua->request($req);
64 die $response->status_line if !$response->is_success;
66 my $user_json = JSON::XS::decode_json($response->decoded_content);
67 die "Something went wrong: " . $response->decoded_content if (!defined($user_json) || !$user_json->{'ok'});
69 return $user_json->{'user'}{'real_name'};
72 sub get_spreadsheet_name {
74 my $name = $cell->{'userEnteredValue'}{'stringValue'};
75 return undef if (!defined($name));
76 return undef if ($name =~ /^G[1-4]\.[1-5]/);
81 $name =~ s/G\d\.\d?\??//;
89 my ($slack_name, $spreadsheet_name) = @_;
90 if (lc($slack_name) eq lc($spreadsheet_name)) {
94 my @ap = split /\s+/, $slack_name;
95 my @bp = split /\s+/, $spreadsheet_name;
96 if (scalar @ap >= 2 && scalar @bp >= 2 && lc($ap[0]) eq lc($bp[0])) {
97 # First name matches, try to match some surname
99 for my $ai (1..$#ap) {
100 for my $bi (1..$#bp) {
101 $found = 1 if (lc($ap[$ai]) eq lc($bp[$bi]));
105 skv_log("Fuzzy-matchet $slack_name -> $spreadsheet_name.");
113 sub format_cell_names_for_seen {
115 my @cells = map { chr(ord('A') + $_->[2]) . ($_->[1] + 1) } @$seen;
116 return join(', ', @cells);
121 print STDERR "$msg\n";
125 sub serialize_skv_log_to_sheet {
130 userEnteredValue => { stringValue => join("\n", @log) }
133 fields => 'userEnteredValue.stringValue',
135 sheetId => $config::log_tab_id,
138 startColumnIndex => 0,
145 sub sheet_batch_update {
146 my ($ua, $token, @requests) = @_;
148 requests => \@requests
150 my $response = $ua->post(
151 'https://sheets.googleapis.com/v4/spreadsheets/' . $config::sheet_id . ':batchUpdate?key=' . $config::gsheets_api_key,
152 Content => JSON::XS::encode_json($update),
153 Content_type => 'application/json;charset=UTF-8',
154 Authorization => 'Bearer ' . $token
156 die $response->decoded_content if !$response->is_success;
159 sub get_group_assignments {
162 my %assignments = ();
163 my $rows = $json->{'sheets'}[0]{'data'}[0]{'rowData'};
164 my @curr_groups = ();
165 for my $row (@$rows) {
167 for my $val (@{$row->{'values'}}) {
169 my $contents = $val->{'userEnteredValue'}{'stringValue'};
170 next if !defined($contents);
171 if ($contents =~ /Gruppe /) {
175 next if $contents =~ /^VL:/;
176 next if $contents =~ /^LT\b/;
177 next if $contents =~ /^400m/;
178 next if $contents =~ /^546m/;
179 if ($contents =~ /^(G\d\.\d)/ || $contents =~ /^(Nye lĆøpere.*)/) {
180 $curr_groups[$col] = $1;
182 my $name = get_spreadsheet_name($val);
183 next if (!defined($name));
184 my $group = $curr_groups[$col] // $curr_groups[$col - 1];
185 # print $group, " ", $name, "\n";
186 if (exists($assignments{$name})) {
187 $assignments{$name} = "(flere grupper)";
189 $assignments{$name} = $group;
197 sub update_assignment_db {
198 my ($dbh, $channel, $ts, $assignments) = @_;
200 local $dbh->{AutoCommit} = 0;
201 my %db_assignments = ();
202 my $q = $dbh->prepare('SELECT name,group_name FROM current_group_membership_history WHERE channel=? AND ts=?');
203 $q->execute($channel, $ts);
204 while (my $ref = $q->fetchrow_hashref) {
205 if (defined($ref->{'group_name'})) {
206 $db_assignments{$ref->{'name'}} = $ref->{'group_name'};
210 $q = $dbh->prepare('INSERT INTO group_membership_history (channel, ts, name, change_seen, group_name) VALUES (?, ?, ?, CURRENT_TIMESTAMP, ?)');
211 for my $name (keys %$assignments) {
212 if (!exists($db_assignments{$name}) || $db_assignments{$name} ne $assignments->{$name}) {
213 $q->execute($channel, $ts, $name, $assignments->{$name});
216 for my $name (keys %db_assignments) {
217 if (!exists($assignments->{$name})) {
218 $q->execute($channel, $ts, $name, undef);
224 sub get_spreadsheet_with_title {
225 my ($ua, $token, $wanted_sheet_title) = @_;
227 # See if we have any spreadsheets that match this title.
228 my $response = $ua->get('https://sheets.googleapis.com/v4/spreadsheets/' . $config::sheet_id . '?key=' . $config::gsheets_api_key . '&fields=sheets/properties',
229 Authorization => 'Bearer ' . $token
231 my $sheets_json = JSON::XS::decode_json($response->decoded_content);
232 my ($tab_name, $tab_id);
233 for my $sheet (@{$sheets_json->{'sheets'}}) {
234 my $title = $sheet->{'properties'}{'title'};
235 my $sheet_id = $sheet->{'properties'}{'sheetId'};
236 if ($title =~ /\Q$wanted_sheet_title\E/) {
237 # skv_log("Synkroniserer ($config::invitation_channel, $invitation_ts) mot arket ā$titleā (fane-ID $sheet_id).");
238 return ($title, $sheet_id);
241 return (undef, undef);
244 # Make a mapping of lowercase name -> list of [canonical name, row number, column number]
245 sub find_where_each_name_is {
249 my $rows = $json->{'sheets'}[0]{'data'}[0]{'rowData'};
251 for my $row (@$rows) {
253 for my $val (@{$row->{'values'}}) {
254 my $name = get_spreadsheet_name($val);
255 if (defined($name)) {
256 push @{$seen_names{lc $name}}, [$name, $rowno, $colno];
266 sub best_name_for_log {
267 my ($userid, $slack_userid_to_real_name, $slack_userid_to_slack_name) = @_;
268 if (exists($slack_userid_to_real_name->{$userid})) {
269 return $slack_userid_to_real_name->{$userid};
270 } elsif (exists($slack_userid_to_slack_name->{$userid})) {
271 return $slack_userid_to_slack_name->{$userid} . ' (fant ikke regneark-navn)';
273 # Should only happen if we didn't see the initial reaction_add, only reaction_remove.
274 # (TODO: Is the comment above true anymore, now that we use this from multiple contexts?)
275 return $userid . ' (fant ikke Slack-navn)';
279 # Add the reaction log. (This only takes into account the last change
280 # for each user; earlier ones are irrelevant and don't count. But it
281 # doesn't deduplicate across reactions. Meh.)
282 sub create_reaction_log {
283 my ($dbh, $invitation_ts, $slack_userid_to_real_name, $slack_userid_to_slack_name) = @_;
285 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');
286 $q->execute($config::invitation_channel, $invitation_ts);
287 my @recent_changes = ();
288 while (my $ref = $q->fetchrow_hashref) {
289 my $msg = $ref->{'event_ts'};
290 if ($ref->{'event_type'} eq 'reaction_added') {
295 if ($ref->{'reaction'} eq 'open_mouth') {
297 } elsif ($ref->{'reaction'} eq 'blue_heart') {
303 $msg .= best_name_for_log($ref->{'userid'}, $slack_userid_to_real_name, $slack_userid_to_slack_name);
304 push @recent_changes, { values => [{ userEnteredValue => { stringValue => $msg } }] };
306 while (scalar @recent_changes < 50) {
307 push @recent_changes, { values => [{ userEnteredValue => { stringValue => '' } }] };
309 return @recent_changes;
312 sub create_move_log {
313 my ($dbh, $invitation_ts, $prev_invitation_ts) = @_;
314 my $q = $dbh->prepare(<<"EOF");
316 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
317 FROM ( SELECT * FROM current_group_membership_history WHERE ts=? ) g_old
318 FULL OUTER JOIN ( SELECT * FROM current_group_membership_history WHERE ts=? ) g_new USING (name)
320 g_new.group_name IS DISTINCT FROM g_old.group_name
321 AND g_new.group_name IS NOT NULL
322 ORDER BY g_new.change_seen DESC, name
325 $q->execute($prev_invitation_ts, $invitation_ts);
326 my @recent_moves = ();
327 while (my $ref = $q->fetchrow_hashref) {
328 my $name = $ref->{'name'};
329 my $old_group = $ref->{'old_group'};
330 my $new_group = $ref->{'new_group'};
332 my $msg = $ref->{'change_seen'} . " ";
333 if (!defined($old_group)) {
334 $msg .= "$name, (ny lĆøper) ā $new_group";
336 $msg .= "$name, $old_group ā $new_group";
338 push @recent_moves, { values => [{ userEnteredValue => { stringValue => $msg } }] };
340 while (scalar @recent_moves < 50) {
341 push @recent_moves, { values => [{ userEnteredValue => { stringValue => '' } }] };
343 return @recent_moves;
346 # Also applies the diff to the database (a bit ugly).
348 my ($dbh, $invitation_ts, $want_colors, $have_colors, $seen_names) = @_;
351 for my $real_name (keys %$want_colors) {
352 my $wc = $want_colors->{$real_name};
353 if (exists($have_colors->{$real_name})) {
354 if ($have_colors->{$real_name} eq $wc) {
358 skv_log("Markerer at $real_name har byttet treningssted.");
360 $real_name, { backgroundColor => $rgb{$wc} }
362 $dbh->do('UPDATE applied SET color=? WHERE channel=? AND ts=? AND name=?', undef,
363 $wc, $config::invitation_channel, $invitation_ts, $real_name);
365 skv_log("Markerer at $real_name skal pƄ trening.");
367 $real_name, { backgroundColor => $rgb{$wc} }
369 $dbh->do('INSERT INTO applied (channel, ts, name, color) VALUES (?, ?, ?, ?)', undef,
370 $config::invitation_channel, $invitation_ts, $real_name, $wc);
373 for my $real_name (keys %$have_colors) {
374 next if (exists($want_colors->{$real_name}));
375 if (!exists($seen_names->{lc $real_name})) {
376 # TODO: This can somehow come if we try to add someone who's not in the sheet, too?
377 skv_log("Ćnsket Ć„ fjerne at $real_name skulle pĆ„ trening, men de var ikke i regnearket lenger.");
378 } elsif (scalar @{$seen_names->{lc $real_name}} > 1) {
381 skv_log("Fjerner at $real_name skal pƄ trening.");
383 $real_name, { backgroundColor => $rgb{white} }
385 $dbh->do('DELETE FROM applied WHERE channel=? AND ts=? AND name=?', undef,
386 $config::invitation_channel, $invitation_ts, $real_name);
392 sub possibly_nag_user {
393 my ($dbh, $ua, $userid) = @_;
395 # See if we've nagged this user before.
396 my $q = $dbh->prepare('SELECT * FROM users_nagged WHERE userid=?');
397 $q->execute($userid);
398 if (defined($q->fetchrow_hashref)) {
402 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!";
404 skv_log("Sender melding til $userid for Ć„ spĆørre om gruppe.");
406 channel => $config::invitation_channel,
410 my $response = $ua->post(
411 'https://slack.com/api/chat.postEphemeral',
412 Content => JSON::XS::encode_json($content),
413 Content_type => 'application/json;charset=UTF-8',
414 Authorization => 'Bearer ' . $config::slack_oauth_token
416 die $response->status_line if !$response->is_success;
417 my $msg_json = JSON::XS::decode_json($response->decoded_content);
418 die "Something went wrong: " . $response->decoded_content if (!defined($msg_json) || !$msg_json->{'ok'});
420 # Mark that we've sent the message, so it won't happen again.
421 $dbh->do('INSERT INTO users_nagged (userid, last_nag) VALUES (?, CURRENT_TIMESTAMP)', undef, $userid);
425 my $start = [Time::HiRes::gettimeofday];
428 skv_log("Siste sync startet: " . POSIX::ctime(time));
430 # Initialize the handles we need for communication.
431 $dbh = DBI->connect("dbi:Pg:dbname=$config::dbname;host=127.0.0.1", $config::dbuser, $config::dbpass, {RaiseError => 1})
432 or die "Could not connect to Postgres: " . DBI->errstr;
433 my $ua = LWP::UserAgent->new('SKVidarLang/1.0');
434 my $token = get_oauth_bearer_token($ua);
436 # Find the newest message, what it is linked to, and what was the one before it (for group diffing).
437 # TODO: Support more than one, and test better for errors here.
438 my $q = $dbh->prepare('select * from message_sheet_link where channel=? order by ts desc limit 2');
439 $q->execute($config::invitation_channel);
440 my $linkref = $q->fetchrow_hashref;
441 my $invitation_ts = $linkref->{'ts'};
442 my $wanted_sheet_title = $linkref->{'sheet_title'};
443 die "Could not get newest sheet title" if (!defined($wanted_sheet_title));
445 my ($tab_name, $tab_id) = get_spreadsheet_with_title($ua, $token, $wanted_sheet_title);
446 if (!defined($tab_name)) {
447 skv_log("Fant ikke noen fane med Ā«$wanted_sheet_titleĀ» i navnet; kan ikke synkronisere.\n");
448 sheet_batch_update($ua, $token, [ serialize_skv_log_to_sheet() ]);
452 # Store away the second-newest ID.
453 my $prev_invitation_ts = $q->fetchrow_hashref->{'ts'};
455 # Find everyone who are marked as attending on Slack (via reactions).
456 $q = $dbh->prepare('SELECT DISTINCT userid,reaction FROM current_reactions WHERE channel=? AND ts=? AND reaction IN (\'heart\', \'open_mouth\', \'blue_heart\')');
457 $q->execute($config::invitation_channel, $invitation_ts);
458 my @attending_userids = ();
461 while (my $ref = $q->fetchrow_hashref) {
462 my $userid = $ref->{'userid'};
463 push @attending_userids, $userid;
464 if ($ref->{'reaction'} eq 'blue_heart') {
465 if (exists($colors{$userid}) && $colors{$userid} eq 'yellow') {
466 $double{$userid} = 1;
468 $colors{$userid} = 'blue';
470 if (exists($colors{$userid}) && $colors{$userid} eq 'blue') {
471 $double{$userid} = 1;
473 $colors{$userid} = 'yellow';
477 # Remove double-attenders (we will log them as warnings further down).
478 @attending_userids = grep { !exists($double{$_}) } @attending_userids;
479 for my $userid (keys %double) {
480 delete $colors{$userid};
483 # Get the list of all people in the sheet (we're going to need them soon anyway).
484 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',
485 Authorization => 'Bearer ' . $token
487 my $main_sheet_json = JSON::XS::decode_json($response->decoded_content);
489 # Update the list of groups we've seen people in.
490 my %assignments = get_group_assignments($main_sheet_json);
491 update_assignment_db($dbh, $config::invitation_channel, $invitation_ts, \%assignments);
493 my %seen_names = find_where_each_name_is($main_sheet_json);
496 for my $name (sort keys %seen_names) {
497 my $seen = $seen_names{$name};
498 if (scalar @$seen >= 2) {
499 my $exemplar = $seen->[0][0];
500 skv_log("Duplikat: $exemplar (" . format_cell_names_for_seen($seen) . ")");
504 # Get our existing Slack->name mapping, from the sheets.
505 my %slack_userid_to_real_name = ();
506 my %slack_userid_to_slack_name = ();
507 my %slack_userid_to_row = ();
508 $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',
509 Authorization => 'Bearer ' . $token
511 my $mapping_sheet_json = JSON::XS::decode_json($response->decoded_content);
512 my $mapping_sheet_rows = $mapping_sheet_json->{'sheets'}[0]{'data'}[0]{'rowData'};
514 for my $row (@$mapping_sheet_rows) {
515 my $slack_id = $row->{'values'}[0]{'userEnteredValue'}{'stringValue'};
516 my $slack_name = $row->{'values'}[1]{'userEnteredValue'}{'stringValue'};
517 my $real_name = get_spreadsheet_name($row->{'values'}[2]); # TODO support more
518 $slack_userid_to_row{$slack_id} = $cur_row++;
519 next if (!defined($slack_name));
520 $slack_userid_to_slack_name{$slack_id} = $slack_name;
521 next if (!defined($real_name));
522 $slack_userid_to_real_name{$slack_id} = $real_name;
525 # See which ones we don't have a mapping for, and look them up in Slack.
526 # TODO: Use an append call instead of $cur_row?
527 my @slack_mapping_updates = ();
528 for my $userid (@attending_userids) {
529 next if (exists($slack_userid_to_real_name{$userid}));
531 # Make sure they have a row in the spreadsheet.
533 if (exists($slack_userid_to_row{$userid})) {
534 $write_row = $slack_userid_to_row{$userid};
536 $write_row = $cur_row++;
537 $slack_userid_to_row{$userid} = $write_row;
538 push @slack_mapping_updates, {
539 range => "Slack-mapping!A$write_row:A$write_row",
540 values => [ [ $userid ]]
544 # Fetch their Slack name if we don't already have it.
546 if (exists($slack_userid_to_slack_name{$userid})) {
547 $slack_name = $slack_userid_to_slack_name{$userid};
549 $slack_userid_to_slack_name{$userid} = $slack_name;
550 $slack_name = get_slack_name($ua, $userid);
551 push @slack_mapping_updates, {
552 range => "Slack-mapping!B$write_row:B$write_row",
553 values => [ [ $slack_name ]]
555 $slack_userid_to_slack_name{$userid} = $slack_name;
558 if (exists($seen_names{lc $slack_name})) {
559 # The name exists exactly, once or more, so it's a direct match and we ignore any fuzz.
560 $slack_userid_to_real_name{$userid} = $slack_name;
561 push @slack_mapping_updates, {
562 range => "Slack-mapping!C$write_row:C$write_row",
563 values => [ [ $slack_name ]]
566 # Do a search through all the available names in the sheet to find an obvious(ish) match.
568 my $main_sheet_rows = $main_sheet_json->{'sheets'}[0]{'data'}[0]{'rowData'};
569 for my $row (@$main_sheet_rows) {
570 for my $val (@{$row->{'values'}}) {
571 my $name = get_spreadsheet_name($val);
572 if (defined($name) && matches_name($slack_name, $name)) {
573 push @candidates, $name;
577 if ($#candidates == -1) {
578 skv_log("$slack_name ($userid) er pƄmeldt pƄ Slack, men fant ikke et regneark-navn for dem.");
579 possibly_nag_user($dbh, $ua, $userid);
580 } elsif ($#candidates == 0) {
581 my $name = $candidates[0];
582 $slack_userid_to_real_name{$userid} = $name;
583 push @slack_mapping_updates, {
584 range => "Slack-mapping!C$write_row:C$write_row",
585 values => [ [ $name ]]
588 skv_log("$slack_name ($userid) er pƄmeldt pƄ Slack, men hadde flere fuzzy-matcher; vet ikke hvilket regneark-navn som skal brukes.");
593 valueInputOption => 'USER_ENTERED',
594 data => \@slack_mapping_updates
596 $response = $ua->post(
597 'https://sheets.googleapis.com/v4/spreadsheets/' . $config::sheet_id . '/values:batchUpdate?key=' . $config::gsheets_api_key,
598 Content => JSON::XS::encode_json($update),
599 Content_type => 'application/json;charset=UTF-8',
600 Authorization => 'Bearer ' . $token
602 die $response->decoded_content if (!$response->is_success);
604 # Now that we have Slack names, we can log double-reacters.
605 for my $userid (keys %double) {
606 my $name = best_name_for_log($userid, \%slack_userid_to_real_name, \%slack_userid_to_slack_name);
607 skv_log("$name er pƄmeldt flere steder pƄ Slack; vet ikke hvilken som skal brukes.");
610 # Find the list of names to mark yellow.
611 my %want_colors = ();
612 my $main_sheet_rows = $main_sheet_json->{'sheets'}[0]{'data'}[0]{'rowData'};
613 for my $userid (@attending_userids) {
614 next if (!exists($slack_userid_to_real_name{$userid}));
615 my $slack_name = $slack_userid_to_slack_name{$userid};
616 my $real_name = $slack_userid_to_real_name{$userid};
618 # See if we can find them in the spreadsheet.
619 if (!exists($seen_names{lc $real_name})) {
620 # TODO: Perhaps move this logic further down, for consistency?
621 skv_log("$slack_name ($userid) er pƄmeldt pƄ Slack, og er mappet til $real_name, men var ikke i noen gruppe.");
623 my $seen = $seen_names{lc $real_name};
624 if (scalar @$seen >= 2) {
625 skv_log("$slack_name ($userid) er pƄmeldt pƄ Slack, men stƄr flere steder (se over); vet ikke hvilken celle som skal brukes.");
627 $want_colors{$seen->[0][0]} = $colors{$userid};
632 # Find the list of names we already marked yellow.
633 my %have_colors = ();
634 $dbh->{AutoCommit} = 0;
635 $dbh->do('SET TRANSACTION ISOLATION LEVEL SERIALIZABLE');
636 $q = $dbh->prepare('SELECT name,color FROM applied WHERE channel=? AND ts=?');
637 $q->execute($config::invitation_channel, $invitation_ts);
638 while (my $ref = $q->fetchrow_hashref) {
639 $have_colors{$ref->{'name'}} = $ref->{'color'};
642 my @diffs = find_diff($dbh, $invitation_ts, \%want_colors, \%have_colors, \%seen_names);
644 my @yellow_updates = ();
645 if (scalar @diffs > 0) {
646 # Now fill in the actual stuff.
647 for my $diff (@diffs) {
648 my $real_name = $diff->[0];
650 my $seen = $seen_names{lc $real_name};
652 # We've already complained about these earlier, so just skip them silently.
653 next if (scalar @$seen > 1);
655 # See if we can find them in the spreadsheet.
656 die "Could not find $real_name" if (!defined($seen));
657 my $rowno = $seen->[0][1];
658 my $colno = $seen->[0][2];
659 push @yellow_updates, {
663 userEnteredFormat => $diff->[1]
666 fields => 'userEnteredFormat.backgroundColor',
669 startRowIndex => $rowno,
670 endRowIndex => $rowno + 1,
671 startColumnIndex => $colno,
672 endColumnIndex => $colno + 1
679 my @recent_changes = create_reaction_log($dbh, $invitation_ts, \%slack_userid_to_real_name, \%slack_userid_to_slack_name);
680 push @yellow_updates, {
682 rows => \@recent_changes,
683 fields => 'userEnteredValue.stringValue',
685 sheetId => $config::log_tab_id,
687 endRowIndex => 4 + scalar @recent_changes,
688 startColumnIndex => 0,
694 my @recent_moves = create_move_log($dbh, $invitation_ts, $prev_invitation_ts);
695 push @yellow_updates, {
697 rows => \@recent_moves,
698 fields => 'userEnteredValue.stringValue',
700 sheetId => $config::log_tab_id,
702 endRowIndex => 4 + scalar @recent_moves,
703 startColumnIndex => 1,
709 # Push the final set of updates (including the log).
711 push @yellow_updates, serialize_skv_log_to_sheet();
712 sheet_batch_update($ua, $token, \@yellow_updates);
715 my $elapsed = Time::HiRes::tv_interval($start);
716 printf "Tok %.0f ms.\n", 1e3 * $elapsed;
719 if ($#ARGV >= 0 && $ARGV[0] eq '--daemon') {
720 # Start with a single, forced run.
721 unlink("/srv/skvidar-slack.sesse.net/marker");
725 if (!unlink("/srv/skvidar-slack.sesse.net/marker")) {
726 unless ($!{ENOENT}) {
727 warn "/srv/skvidar-slack.sesse.net/marker: $!";
736 warn "Died with: $@";