]> git.sesse.net Git - skvidarsync/blobdiff - bin/sync.pl
Make do with only one commit per sync.
[skvidarsync] / bin / sync.pl
index 339880396dcdc60ec8a0c40bc3c33ffeec0bd1af..8ca281d6c195591afc22e62b7c0043a3b7b675d0 100644 (file)
@@ -8,18 +8,22 @@ use LWP::UserAgent;
 use DBI;
 use POSIX;
 use Time::HiRes;
+use IO::Select;
+use Unicode::Collate;
+use IO::Socket::SSL;
 binmode STDOUT, ':utf8';
 binmode STDERR, ':utf8';
 use utf8;
 
-# TODO:
-# - detect moves between groups
-# - make the sheet message more in-your-face (ephemeral message)
-
 require '../include/config.pm';
 
-my $dbh;
+my $global_ctx = IO::Socket::SSL::SSL_Context->new(
+       SSL_session_cache_size => 100,  # Probably overkill.
+);
+IO::Socket::SSL::set_default_context($global_ctx);
+
 my @log = ();
+my $uca = Unicode::Collate->new(level => 1);
 
 my %rgb = (
        yellow => {
@@ -42,9 +46,27 @@ my %rgb = (
        }
 );
 
+sub log_timing {
+       my ($start, $msg) = @_;
+       my $elapsed = Time::HiRes::tv_interval($start);
+       printf "%s: %.0f ms.\n", $msg, 1e3 * $elapsed;
+}
+
+sub sort_key {
+       my $m = shift;
+       return $uca->getSortKey($m);
+}
+
 sub get_oauth_bearer_token {
-       my $ua = shift;
+       my ($dbh, $ua) = @_;
        my $now = time();
+
+       # See if the database already has a token we could use, that doesn't expire in a while.
+       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);
+       if (defined($ref->{'token'})) {
+               return $ref->{'token'};
+       }
+
        my $jwt = JSON::XS::encode_json({
                "iss" => $config::jwt_key->{'client_email'},
                "scope" => "https://www.googleapis.com/auth/spreadsheets",
@@ -53,10 +75,15 @@ sub get_oauth_bearer_token {
                "iat" => $now,
        });
        my $jws_token = Crypt::JWT::encode_jwt(payload=>$jwt, alg=>'RS256', key=>\$config::jwt_key->{'private_key'});
+       my $start = [Time::HiRes::gettimeofday];
        my $response = $ua->post('https://www.googleapis.com/oauth2/v4/token', [
                'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
                'assertion' => $jws_token ]);
-       return JSON::XS::decode_json($response->decoded_content)->{'access_token'};
+       log_timing($start, '/oauth2/v4/token');
+       my $token = JSON::XS::decode_json($response->decoded_content)->{'access_token'};
+       $dbh->do('INSERT INTO oauth_tokens (token, acquired, expiry) VALUES (?, TIMESTAMPTZ \'1970-01-01\' + ? * INTERVAL \'1 second\', TIMESTAMPTZ \'1970-01-01\' + ? * INTERVAL \'1 second\')',
+               undef, $token, $now, $now + 1800);
+       return $token;
 }
 
 sub get_slack_name {
@@ -64,7 +91,9 @@ sub get_slack_name {
        my $req = HTTP::Request->new('GET', 'https://slack.com/api/users.info?user=' . $userid, [
               'Authorization' => 'Bearer ' . $config::slack_oauth_token
        ]);
+       my $start = [Time::HiRes::gettimeofday];
        my $response = $ua->request($req);
+       log_timing($start, '/users.info');
        die $response->status_line if !$response->is_success;
 
        my $user_json = JSON::XS::decode_json($response->decoded_content);
@@ -90,19 +119,22 @@ sub get_spreadsheet_name {
 }
 
 sub matches_name {
-       my ($slack_name, $spreadsheet_name) = @_;
-       if (lc($slack_name) eq lc($spreadsheet_name)) {
-               return 1;
-       }
+       my ($slack_name, $spreadsheet_name, $ap) = @_;
 
-       my @ap = split /\s+/, $slack_name;
-       my @bp = split /\s+/, $spreadsheet_name;
-       if (scalar @ap >= 2 && scalar @bp >= 2 && lc($ap[0]) eq lc($bp[0])) {
+       # No need to check for an exact match; we already did that through $seen_names.
+       # if (sort_key($slack_name) eq sort_key($spreadsheet_name)) {
+       #       return 1;
+       # }
+
+       # @ap is precalculated by the caller.
+       # my @ap = map { sort_key($_) } split /\s+/, $slack_name;
+       my @bp = map { sort_key($_) } split /\s+/, $spreadsheet_name;
+       if (scalar @$ap >= 2 && scalar @bp >= 2 && $ap->[0] eq $bp[0]) {
                # First name matches, try to match some surname
                my $found = 0;
-               for my $ai (1..$#ap) {
+               for my $ai (1..(scalar @$ap)) {
                        for my $bi (1..$#bp) {
-                               $found = 1 if (lc($ap[$ai]) eq lc($bp[$bi]));
+                               $found = 1 if ($ap->[$ai] eq $bp[$bi]);
                        }
                }
                if ($found) {
@@ -116,7 +148,7 @@ sub matches_name {
 
 sub format_cell_names_for_seen {
        my $seen = shift;
-       my @cells = map { chr(ord('A') + $_->[2]) . $_->[1] } @$seen;
+       my @cells = map { chr(ord('A') + $_->[2]) . ($_->[1] + 1) } @$seen;
        return join(', ', @cells);
 }
 
@@ -151,22 +183,90 @@ sub sheet_batch_update {
        my $update = {
                requests => \@requests
        };
+       my $start = [Time::HiRes::gettimeofday];
        my $response = $ua->post(
                'https://sheets.googleapis.com/v4/spreadsheets/' . $config::sheet_id . ':batchUpdate?key=' . $config::gsheets_api_key,
                Content => JSON::XS::encode_json($update),
                Content_type => 'application/json;charset=UTF-8',
                Authorization => 'Bearer ' . $token
        );
+       log_timing($start, '/spreadsheets/values:batchUpdate');
        die $response->decoded_content if !$response->is_success;
 }
 
+sub get_group_assignments {
+       my $json = shift;
+
+       my %assignments = ();
+       my $rows = $json->{'data'}[0]{'rowData'};
+       my @curr_groups = ();
+       for my $row (@$rows) {
+               my $col = 0;
+               for my $val (@{$row->{'values'}}) {
+                       ++$col;
+                       my $contents = $val->{'userEnteredValue'}{'stringValue'};
+                       next if !defined($contents);
+                       if ($contents =~ /Gruppe /) {
+                               @curr_groups = ();
+                               last;
+                       }
+                       next if $contents =~ /^VL:/;
+                       next if $contents =~ /^LT\b/;
+                       next if $contents =~ /^400m/;
+                       next if $contents =~ /^546m/;
+                       if ($contents =~ /^(G\d\.\d)/ || $contents =~ /^(Nye løpere.*)/) {
+                               $curr_groups[$col] = $1;
+                       } else {
+                               my $name = get_spreadsheet_name($val);
+                               next if (!defined($name));
+                               my $group = $curr_groups[$col] // $curr_groups[$col - 1];
+                               # print $group, " ", $name, "\n";
+                               if (exists($assignments{$name})) {
+                                       $assignments{$name} = "(flere grupper)";
+                               } else {
+                                       $assignments{$name} = $group;
+                               }
+                       }
+               }
+       }
+       return %assignments;
+}
+
+sub update_assignment_db {
+       my ($dbh, $channel, $ts, $assignments) = @_;
+
+       my %db_assignments = ();
+       my $q = $dbh->prepare('SELECT name,group_name FROM current_group_membership_history WHERE channel=? AND ts=?');
+       $q->execute($channel, $ts);
+       while (my $ref = $q->fetchrow_hashref) {
+               if (defined($ref->{'group_name'})) {
+                       $db_assignments{$ref->{'name'}} = $ref->{'group_name'};
+               }
+       }
+
+       $q = $dbh->prepare('INSERT INTO group_membership_history (channel, ts, name, change_seen, group_name) VALUES (?, ?, ?, CURRENT_TIMESTAMP, ?)');
+       for my $name (keys %$assignments) {
+               if (!exists($db_assignments{$name}) || $db_assignments{$name} ne $assignments->{$name}) {
+                       $q->execute($channel, $ts, $name, $assignments->{$name});
+               }
+       }
+       for my $name (keys %db_assignments) {
+               if (!exists($assignments->{$name})) {
+                       $q->execute($channel, $ts, $name, undef);
+               }
+       }
+}
+
 sub get_spreadsheet_with_title {
-       my ($ua, $token, $wanted_sheet_title) = @_;
+       my ($dbh, $ua, $token, $invitation_ts, $wanted_sheet_title) = @_;
 
        # See if we have any spreadsheets that match this title.
+       my $start = [Time::HiRes::gettimeofday];
        my $response = $ua->get('https://sheets.googleapis.com/v4/spreadsheets/' . $config::sheet_id . '?key=' . $config::gsheets_api_key . '&fields=sheets/properties',
-               Authorization => 'Bearer ' . $token
+               Authorization => 'Bearer ' . $token,
+               Accept_Encoding => HTTP::Message::decodable
        );
+       log_timing($start, '/spreadsheets/properties');
        my $sheets_json = JSON::XS::decode_json($response->decoded_content);
        my ($tab_name, $tab_id);
        for my $sheet (@{$sheets_json->{'sheets'}}) {
@@ -174,6 +274,8 @@ sub get_spreadsheet_with_title {
                my $sheet_id = $sheet->{'properties'}{'sheetId'};
                if ($title =~ /\Q$wanted_sheet_title\E/) {
                        # skv_log("Synkroniserer ($config::invitation_channel, $invitation_ts) mot arket “$title” (fane-ID $sheet_id).");
+                       $dbh->do('UPDATE message_sheet_link SET tab_name=?, tab_id=? WHERE channel=? AND ts=?',
+                               undef, $title, $sheet_id, $config::invitation_channel, $invitation_ts);
                        return ($title, $sheet_id);
                }
        }
@@ -185,14 +287,14 @@ sub find_where_each_name_is {
        my $json = shift;
 
        my %seen_names = ();
-       my $rows = $json->{'sheets'}[0]{'data'}[0]{'rowData'};
+       my $rows = $json->{'data'}[0]{'rowData'};
        my $rowno = 3;
        for my $row (@$rows) {
                my $colno = 0;
                for my $val (@{$row->{'values'}}) {
                        my $name = get_spreadsheet_name($val);
                        if (defined($name)) {
-                               push @{$seen_names{lc $name}}, [$name, $rowno, $colno];
+                               push @{$seen_names{sort_key($name)}}, [$name, $rowno, $colno];
                        }
                        ++$colno;
                }
@@ -248,6 +350,40 @@ sub create_reaction_log {
        return @recent_changes;
 }
 
+sub create_move_log {
+        my ($dbh, $invitation_ts, $prev_invitation_ts) = @_;
+        my $q = $dbh->prepare(<<"EOF");
+SELECT
+  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
+FROM ( SELECT * FROM current_group_membership_history WHERE ts=? ) g_old
+  FULL OUTER JOIN ( SELECT * FROM current_group_membership_history WHERE ts=? ) g_new USING (name)
+WHERE
+  g_new.group_name IS DISTINCT FROM g_old.group_name
+  AND g_new.group_name IS NOT NULL
+ORDER BY g_new.change_seen DESC, name
+LIMIT 50
+EOF
+       $q->execute($prev_invitation_ts, $invitation_ts);
+       my @recent_moves = ();
+       while (my $ref = $q->fetchrow_hashref) {
+               my $name = $ref->{'name'};
+               my $old_group = $ref->{'old_group'};
+               my $new_group = $ref->{'new_group'};
+
+               my $msg = $ref->{'change_seen'} . " ";
+               if (!defined($old_group)) {
+                       $msg .= "$name, (ny løper) → $new_group";
+               } else {
+                       $msg .= "$name, $old_group → $new_group";
+               }
+               push @recent_moves, { values => [{ userEnteredValue => { stringValue => $msg } }] };
+       }
+       while (scalar @recent_moves < 50) {
+               push @recent_moves, { values => [{ userEnteredValue => { stringValue => '' } }] };
+       }
+       return @recent_moves;
+}
+
 # Also applies the diff to the database (a bit ugly).
 sub find_diff {
        my ($dbh, $invitation_ts, $want_colors, $have_colors, $seen_names) = @_;
@@ -277,10 +413,10 @@ sub find_diff {
        }
        for my $real_name (keys %$have_colors) {
                next if (exists($want_colors->{$real_name}));
-               if (!exists($seen_names->{lc $real_name})) {
+               if (!exists($seen_names->{sort_key($real_name)})) {
                        # TODO: This can somehow come if we try to add someone who's not in the sheet, too?
                        skv_log("Ønsket å fjerne at $real_name skulle på trening, men de var ikke i regnearket lenger.");
-               } elsif (scalar @{$seen_names->{lc $real_name}} > 1) {
+               } elsif (scalar @{$seen_names->{sort_key($real_name)}} > 1) {
                        # Don't touch them.
                } else {
                        skv_log("Fjerner at $real_name skal på trening.");
@@ -295,69 +431,94 @@ sub find_diff {
 }
 
 sub possibly_nag_user {
-       my ($dbh, $ua, $userid) = @_;
+       my ($dbh, $ua, $userid, $invitation_ts, $group, $slack_userid_to_slack_name) = @_;
+
+       my $slack_name = $slack_userid_to_slack_name->{$userid};
 
        # See if we've nagged this user before.
-       my $q = $dbh->prepare('SELECT * FROM users_nagged WHERE userid=?');
-       $q->execute($userid);
+       my $q = $dbh->prepare('SELECT * FROM users_nagged WHERE userid=? AND ts=?');
+       $q->execute($userid, $invitation_ts);
        if (defined($q->fetchrow_hashref)) {
                return;
        }
 
-       skv_log("Sender melding til $userid for å spørre om gruppe.");
+       my $msg;
+       if (!defined($group)) {
+               $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!";
+               skv_log("Sender Slack-melding til $slack_name ($userid) for å spørre om gruppe.");
+       } elsif ($group eq '(flere grupper)') {
+               $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!";
+               skv_log("Sender Slack-melding til $slack_name ($userid) for å spørre om gruppe.");
+       } else {
+               $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!";
+               skv_log("Sender Slack-melding om at $slack_name ($userid) er i gruppe $group.");
+       }
+
+       my $content = {
+               channel => $config::invitation_channel,
+               user => $userid,
+               text => $msg
+       };
+       my $start = [Time::HiRes::gettimeofday];
        my $response = $ua->post(
-               'https://slack.com/api/conversations.open',
-               Content => JSON::XS::encode_json({ users => [ $userid ] }),
+               'https://slack.com/api/chat.postEphemeral',
+               Content => JSON::XS::encode_json($content),
                Content_type => 'application/json;charset=UTF-8',
                Authorization => 'Bearer ' . $config::slack_oauth_token
        );
+       log_timing($start, 'chat.postEphemeral');
        die $response->status_line if !$response->is_success;
-
-       my $im_json = JSON::XS::decode_json($response->decoded_content);
-       die "Something went wrong: " . $response->decoded_content if (!defined($im_json) || !$im_json->{'ok'});
-       my $channel_id = $im_json->{'channel'}{'id'};
-
-       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!";
-
-       $response = $ua->post(
-               'https://slack.com/api/chat.postMessage',
-               Content => JSON::XS::encode_json({ channel => $channel_id, text => $msg }),
-               Content_type => 'application/json;charset=UTF-8',
-               Authorization => 'Bearer ' . $config::slack_oauth_token
-       );
        my $msg_json = JSON::XS::decode_json($response->decoded_content);
        die "Something went wrong: " . $response->decoded_content if (!defined($msg_json) || !$msg_json->{'ok'});
 
        # Mark that we've sent the message, so it won't happen again.
-       $dbh->do('INSERT INTO users_nagged (userid, last_nag) VALUES (?, CURRENT_TIMESTAMP)', undef, $userid);
+       $dbh->do('INSERT INTO users_nagged (userid, ts, last_nag) VALUES (?, ?, CURRENT_TIMESTAMP)', undef, $userid, $invitation_ts);
+}
+
+sub db_connect {
+       my $dbh = DBI->connect("dbi:Pg:dbname=$config::dbname;host=127.0.0.1", $config::dbuser, $config::dbpass, {RaiseError => 1})
+               or warn "Could not connect to Postgres: " . DBI->errstr;
+       if (!defined($dbh)) {
+               return undef;
+       }
+       $dbh->{AutoCommit} = 0;
+       $dbh->do('LISTEN skvupdate') or return undef;
+       return $dbh;
 }
 
 sub run {
-       my $start = [Time::HiRes::gettimeofday];
+       my ($dbh, $ua) = @_;
+       my $total_start = [Time::HiRes::gettimeofday];
 
        @log = ();
        skv_log("Siste sync startet: " . POSIX::ctime(time));
 
-       # Initialize the handles we need for communication.
-       $dbh = DBI->connect("dbi:Pg:dbname=$config::dbname;host=127.0.0.1", $config::dbuser, $config::dbpass, {RaiseError => 1})
-               or die "Could not connect to Postgres: " . DBI->errstr;
-       my $ua = LWP::UserAgent->new('SKVidarLang/1.0');
-       my $token = get_oauth_bearer_token($ua);
+       # For the logic on the “applied” table below.
+       $dbh->do('SET TRANSACTION ISOLATION LEVEL SERIALIZABLE');
 
-       # Find the newest message, and what it is linked to.
+       my $token = get_oauth_bearer_token($dbh, $ua);
+
+       # Find the newest message, what it is linked to, and what was the one before it (for group diffing).
        # TODO: Support more than one, and test better for errors here.
-       my $q = $dbh->prepare('select * from message_sheet_link where channel=? order by ts desc limit 1');
+       my $q = $dbh->prepare('select * from message_sheet_link where channel=? order by ts desc limit 2');
        $q->execute($config::invitation_channel);
        my $linkref = $q->fetchrow_hashref;
        my $invitation_ts = $linkref->{'ts'};
        my $wanted_sheet_title = $linkref->{'sheet_title'};
        die "Could not get newest sheet title" if (!defined($wanted_sheet_title));
-
-       my ($tab_name, $tab_id) = get_spreadsheet_with_title($ua, $token, $wanted_sheet_title);
-       if (!defined($tab_name)) {
-               skv_log("Fant ikke noen fane med «$wanted_sheet_title» i navnet; kan ikke synkronisere.\n");
-               sheet_batch_update($ua, $token, [ serialize_skv_log_to_sheet() ]);
-               die;
+       my $tab_name = $linkref->{'tab_name'};
+       my $tab_id = $linkref->{'tab_id'};
+
+       # Store away the second-newest ID.
+       my $prev_invitation_ts = $q->fetchrow_hashref->{'ts'};
+
+       if (!defined($tab_name) || !defined($tab_id)) {
+               ($tab_name, $tab_id) = get_spreadsheet_with_title($dbh, $ua, $token, $invitation_ts, $wanted_sheet_title);
+               if (!defined($tab_name)) {
+                       skv_log("Fant ikke noen fane med «$wanted_sheet_title» i navnet; kan ikke synkronisere.\n");
+                       sheet_batch_update($ua, $token, [ serialize_skv_log_to_sheet() ]);
+                       die;
+               }
        }
 
        # Find everyone who are marked as attending on Slack (via reactions).
@@ -388,13 +549,26 @@ sub run {
                delete $colors{$userid};
        }
 
-       # Get the list of all people in the sheet (we're going to need them soon anyway).
-       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',
-               Authorization => 'Bearer ' . $token
+       # Get the list of all people in the sheet (we're going to need them soon).
+       # Also get the Slack mapping when we're doing an API request anyway.
+       my $start = [Time::HiRes::gettimeofday];
+       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',
+               Authorization => 'Bearer ' . $token,
+               Accept_Encoding => HTTP::Message::decodable
        );
-       my $main_sheet_json = JSON::XS::decode_json($response->decoded_content);
+       log_timing($start, "/spreadsheets/");
+
+       my $sheets_json = JSON::XS::decode_json($response->decoded_content);
+       my $main_sheet_json = $sheets_json->{'sheets'}[0];
+       my $mapping_sheet_json = $sheets_json->{'sheets'}[1];
 
+       # Update the list of groups we've seen people in.
+       my %assignments = get_group_assignments($main_sheet_json);
+       update_assignment_db($dbh, $config::invitation_channel, $invitation_ts, \%assignments);
+
+       $start = [Time::HiRes::gettimeofday];
        my %seen_names = find_where_each_name_is($main_sheet_json);
+       log_timing($start, "Making sort key reverse mapping");
 
        # Find duplicates.
        for my $name (sort keys %seen_names) {
@@ -409,11 +583,8 @@ sub run {
        my %slack_userid_to_real_name = ();
        my %slack_userid_to_slack_name = ();
        my %slack_userid_to_row = ();
-       $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',
-               Authorization => 'Bearer ' . $token
-       );
-       my $mapping_sheet_json = JSON::XS::decode_json($response->decoded_content);
-       my $mapping_sheet_rows = $mapping_sheet_json->{'sheets'}[0]{'data'}[0]{'rowData'};
+
+       my $mapping_sheet_rows = $mapping_sheet_json->{'data'}[0]{'rowData'};
        my $cur_row = 5;
        for my $row (@$mapping_sheet_rows) {
                my $slack_id = $row->{'values'}[0]{'userEnteredValue'}{'stringValue'};
@@ -459,7 +630,7 @@ sub run {
                        $slack_userid_to_slack_name{$userid} = $slack_name;
                }
 
-               if (exists($seen_names{lc $slack_name})) {
+               if (exists($seen_names{sort_key($slack_name)})) {
                        # The name exists exactly, once or more, so it's a direct match and we ignore any fuzz.
                        $slack_userid_to_real_name{$userid} = $slack_name;
                        push @slack_mapping_updates, {
@@ -469,18 +640,21 @@ sub run {
                } else {
                        # Do a search through all the available names in the sheet to find an obvious(ish) match.
                        my @candidates = ();
-                       my $main_sheet_rows = $main_sheet_json->{'sheets'}[0]{'data'}[0]{'rowData'};
+                       my $main_sheet_rows = $main_sheet_json->{'data'}[0]{'rowData'};
+                       $start = [Time::HiRes::gettimeofday];
+                       my @ap = map { sort_key($_) } split /\s+/, $slack_name;  # Precalc for matches_name().
                        for my $row (@$main_sheet_rows) {
                                for my $val (@{$row->{'values'}}) {
                                        my $name = get_spreadsheet_name($val);
-                                       if (defined($name) && matches_name($slack_name, $name)) {
+                                       if (defined($name) && matches_name($slack_name, $name, \@ap)) {
                                                push @candidates, $name;
                                        }
                                }
                        }
+                       log_timing($start, "Fuzzy-searching for Slack name “$slack_name”");
                        if ($#candidates == -1) {
                                skv_log("$slack_name ($userid) er påmeldt på Slack, men fant ikke et regneark-navn for dem.");
-                               possibly_nag_user($dbh, $ua, $userid);
+                               possibly_nag_user($dbh, $ua, $userid, $invitation_ts, undef, \%slack_userid_to_slack_name);
                        } elsif ($#candidates == 0) {
                                my $name = $candidates[0];
                                $slack_userid_to_real_name{$userid} = $name;
@@ -493,17 +667,21 @@ sub run {
                        }
                }
        }
-       my $update = {
-               valueInputOption => 'USER_ENTERED',
-               data => \@slack_mapping_updates
-       };
-       $response = $ua->post(
-               'https://sheets.googleapis.com/v4/spreadsheets/' . $config::sheet_id . '/values:batchUpdate?key=' . $config::gsheets_api_key,
-               Content => JSON::XS::encode_json($update),
-               Content_type => 'application/json;charset=UTF-8',
-               Authorization => 'Bearer ' . $token
-       );
-       die $response->decoded_content if (!$response->is_success);
+       if (scalar @slack_mapping_updates > 0) {
+               my $update = {
+                       valueInputOption => 'USER_ENTERED',
+                       data => \@slack_mapping_updates
+               };
+               $start = [Time::HiRes::gettimeofday];
+               $response = $ua->post(
+                       'https://sheets.googleapis.com/v4/spreadsheets/' . $config::sheet_id . '/values:batchUpdate?key=' . $config::gsheets_api_key,
+                       Content => JSON::XS::encode_json($update),
+                       Content_type => 'application/json;charset=UTF-8',
+                       Authorization => 'Bearer ' . $token
+               );
+               log_timing($start, "/spreadsheets/values:batchUpdate");
+               die $response->decoded_content if (!$response->is_success);
+       }
 
        # Now that we have Slack names, we can log double-reacters.
        for my $userid (keys %double) {
@@ -511,20 +689,29 @@ sub run {
                skv_log("$name er påmeldt flere steder på Slack; vet ikke hvilken som skal brukes.");
        }
 
+       # ...and possibly send welcome messages to remind them of groups.
+       for my $userid (@attending_userids) {
+               my $real_name = $slack_userid_to_real_name{$userid};
+               next if (!defined($real_name));
+               my $group = $assignments{$real_name};
+               next if (!defined($group));
+               possibly_nag_user($dbh, $ua, $userid, $invitation_ts, $group, \%slack_userid_to_slack_name);
+       }
+
        # Find the list of names to mark yellow.
        my %want_colors = ();
-       my $main_sheet_rows = $main_sheet_json->{'sheets'}[0]{'data'}[0]{'rowData'};
+       my $main_sheet_rows = $main_sheet_json->{'data'}[0]{'rowData'};
        for my $userid (@attending_userids) {
                next if (!exists($slack_userid_to_real_name{$userid}));
                my $slack_name = $slack_userid_to_slack_name{$userid};
                my $real_name = $slack_userid_to_real_name{$userid};
 
                # See if we can find them in the spreadsheet.
-               if (!exists($seen_names{lc $real_name})) {
+               if (!exists($seen_names{sort_key($real_name)})) {
                        # TODO: Perhaps move this logic further down, for consistency?
                        skv_log("$slack_name ($userid) er påmeldt på Slack, og er mappet til $real_name, men var ikke i noen gruppe.");
                } else {
-                       my $seen = $seen_names{lc $real_name};
+                       my $seen = $seen_names{sort_key($real_name)};
                        if (scalar @$seen >= 2) {
                                skv_log("$slack_name ($userid) er påmeldt på Slack, men står flere steder (se over); vet ikke hvilken celle som skal brukes.");
                        } else {
@@ -535,8 +722,6 @@ sub run {
 
        # Find the list of names we already marked yellow.
        my %have_colors = ();
-       $dbh->{AutoCommit} = 0;
-       $dbh->do('SET TRANSACTION ISOLATION LEVEL SERIALIZABLE');
        $q = $dbh->prepare('SELECT name,color FROM applied WHERE channel=? AND ts=?');
        $q->execute($config::invitation_channel, $invitation_ts);
        while (my $ref = $q->fetchrow_hashref) {
@@ -551,7 +736,7 @@ sub run {
                for my $diff (@diffs) {
                        my $real_name = $diff->[0];
 
-                       my $seen = $seen_names{lc $real_name};
+                       my $seen = $seen_names{sort_key($real_name)};
 
                        # We've already complained about these earlier, so just skip them silently.
                        next if (scalar @$seen > 1);
@@ -595,37 +780,67 @@ sub run {
                }
        };
 
+       my @recent_moves = create_move_log($dbh, $invitation_ts, $prev_invitation_ts);
+       push @yellow_updates, {
+               updateCells => {
+                       rows => \@recent_moves,
+                       fields => 'userEnteredValue.stringValue',
+                       range => {
+                               sheetId => $config::log_tab_id,
+                               startRowIndex => 4,
+                               endRowIndex => 4 + scalar @recent_moves,
+                               startColumnIndex => 1,
+                               endColumnIndex => 2
+                       }
+               }
+       };
+
        # Push the final set of updates (including the log).
        skv_log("Ferdig.");
        push @yellow_updates, serialize_skv_log_to_sheet();
        sheet_batch_update($ua, $token, \@yellow_updates);
        $dbh->commit;
 
-       my $elapsed = Time::HiRes::tv_interval($start);
+       my $elapsed = Time::HiRes::tv_interval($total_start);
        printf "Tok %.0f ms.\n", 1e3 * $elapsed;
 }
 
+# Initialize the handles we need for communication.
+my $dbh = db_connect() or die;
+my $ua = LWP::UserAgent->new(agent => 'SKVidarLang/1.0', keep_alive => 50);
 if ($#ARGV >= 0 && $ARGV[0] eq '--daemon') {
        # Start with a single, forced run.
-       unlink("/srv/skvidar-slack.sesse.net/marker");
-       run();
+       run($dbh, $ua);
 
        while (1) {
-               if (!unlink("/srv/skvidar-slack.sesse.net/marker")) {
-                       unless ($!{ENOENT}) {
-                               warn "/srv/skvidar-slack.sesse.net/marker: $!";
-                       }
+               while (!defined($dbh)) {
+                       print STDERR "Database connection lost, reconnecting...\n";
                        sleep 1;
+                       $dbh = db_connect();
+               }
+               my $s = IO::Select->new($dbh->{pg_socket});
+               my @ready = $s->can_read(10.0);
+               my @exceptions = $s->has_exception(0.0);
+
+               if (scalar @exceptions > 0) {
+                       $dbh->disconnect;
+                       $dbh = undef;
                        next;
                }
-               eval {
-                       run();
-               };
-               if ($@) {
-                       warn "Died with: $@";
+               if (scalar @ready > 0) {  
+                       eval {
+                               run($dbh, $ua);
+                       };
+                       if ($@) {
+                               warn "Died with: $@";
+                               $dbh = undef;
+                       }
                }
-               $dbh->disconnect;
+       }
+} elsif ($#ARGV >= 0 && $ARGV[0] eq '--benchmark') {
+       for my $i (0..9) {
+               run($dbh, $ua);
        }
 } else {
-       run();
+       run($dbh, $ua);
 }