From e4d5b26f48f0df2663ef2ba83aab322d54ee3cae Mon Sep 17 00:00:00 2001 From: Ricardo Signes Date: Wed, 19 Aug 2026 12:14:44 -0400 Subject: [PATCH 1/6] Slack: don't return undef from ->username for unknown users The "silly fallback" in ->username only fired when we had no users hash at all, which is nearly never. The common case is a users hash that was loaded before the user in question existed: then we'd return undef, and whoever called us would warn about an uninitialized value. These two, for example: Use of uninitialized value in string eq at Synergy/Channel/Slack.pm line 173. Use of uninitialized value in concatenation (.) at Synergy/Channel/Slack.pm line 262. Checking the entry rather than the whole hash also stops us from autovivifying an empty, nameless user into the cache on every miss. While we're here, route the two ->users->{$id}{name} lookups in Channel::Slack through ->username, so they get the same treatment instead of dying on an undef hashref before the users load. Co-Authored-By: Claude --- lib/Synergy/Channel/Slack.pm | 4 ++-- lib/Synergy/External/Slack.pm | 10 ++++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/lib/Synergy/Channel/Slack.pm b/lib/Synergy/Channel/Slack.pm index aaeb3942..258edd4f 100644 --- a/lib/Synergy/Channel/Slack.pm +++ b/lib/Synergy/Channel/Slack.pm @@ -509,7 +509,7 @@ sub _uri_from_event ($self, $event) { sub describe_event ($self, $event) { my $who = $event->from_user ? $event->from_user->username - : $self->slack->users->{$event->from_address}{name}; + : $self->slack->username($event->from_address); my $channel_id = $event->transport_data->{channel}; @@ -535,7 +535,7 @@ sub describe_event_concise ($self, $event) { sub describe_conversation ($self, $event) { my $who = $event->from_user ? $event->from_user->username - : $self->slack->users->{$event->from_address}{name}; + : $self->slack->username($event->from_address); my $slack_event = $event->transport_data; diff --git a/lib/Synergy/External/Slack.pm b/lib/Synergy/External/Slack.pm index f2c9dfc0..b36a2524 100644 --- a/lib/Synergy/External/Slack.pm +++ b/lib/Synergy/External/Slack.pm @@ -500,8 +500,14 @@ sub username ($self, $id) { # recently flew very close to the sun. In a perfect world, we'd make it # possible to sequence on this, but it isn't. So we have this silly # fallback… -- rjbs, 2021-12-21 - return $users->{$id}->{name} if $users; - return ""; + # + # …and it wasn't enough, because it only fired when we had *no* users at + # all. Much more often, we have a users hash that predates the user we're + # asking about, and then we'd return undef, and warn. Also, we must not + # deref $users->{$id} without checking, or we autovivify a nameless user + # into the cache on every miss. -- rjbs, 2026-08-19 + return "" unless $users && $users->{$id}; + return $users->{$id}{name} // ""; } sub dm_channel_for_user ($self, $user, $channel) { From 928198098f222c17e63f91d2d958dd0c3d65d7d6 Mon Sep 17 00:00:00 2001 From: Ricardo Signes Date: Wed, 19 Aug 2026 12:16:33 -0400 Subject: [PATCH 2/6] Slack: split "make sure it's loaded" from "load it again" In 0be3774b, the load_X methods became async and grew an early return when their data was already loaded, so that ->readiness would be cheap to call more than once. That also, quietly, turned every *reload* call site into a no-op: * "reload slack users" reported success and did nothing at all * user_status_for stopped picking up fresh Slack statuses * group_conversation_name stopped noticing new group chats Since then, the only way to learn about a user who joined the workspace after we started up has been to restart Synergy, which is how we end up warning about undefined usernames for days. Now load_X means "make sure we have X" and reload_X means "go get X again, right now", and the call sites above use the latter. The reload command also waits for the reload to finish before saying it's done, rather than dropping the future on the floor. Co-Authored-By: Claude --- lib/Synergy/Channel/Slack.pm | 2 +- lib/Synergy/External/Slack.pm | 18 +++++++++++++++++- lib/Synergy/Reactor/SlackID.pm | 10 +++++----- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/lib/Synergy/Channel/Slack.pm b/lib/Synergy/Channel/Slack.pm index 258edd4f..810d5ffa 100644 --- a/lib/Synergy/Channel/Slack.pm +++ b/lib/Synergy/Channel/Slack.pm @@ -554,7 +554,7 @@ sub describe_conversation ($self, $event) { } sub user_status_for ($self, $event, $user) { - $self->slack->load_users->get; + $self->slack->reload_users->get; my $ident = $user->identity_for($self->name); return unless $ident; diff --git a/lib/Synergy/External/Slack.pm b/lib/Synergy/External/Slack.pm index b36a2524..11bb8f94 100644 --- a/lib/Synergy/External/Slack.pm +++ b/lib/Synergy/External/Slack.pm @@ -562,9 +562,16 @@ sub readiness ($self) { ); } +# Each of these things has a load_X, meaning "make sure we have X", and a +# reload_X, meaning "go get X again, right now". Only the load_X form is +# cheap to call over and over, and only the reload_X form will ever notice +# that the Slack workspace has changed since we started up. -- rjbs, 2026-08-19 async sub load_users ($self) { return if $self->_has_users; + return await $self->reload_users; +} +async sub reload_users ($self) { my $http_res = await $self->api_call('users.list', { presence => 0, }); @@ -585,7 +592,10 @@ async sub load_users ($self) { async sub load_channels ($self) { return if $self->_has_channels; + return await $self->reload_channels; +} +async sub reload_channels ($self) { my $http_res = await $self->api_call('conversations.list', { exclude_archived => 'true', types => 'public_channel', @@ -605,7 +615,10 @@ async sub load_channels ($self) { async sub load_group_conversations ($self) { return if $self->_has_group_conversations; + return await $self->reload_group_conversations; +} +async sub reload_group_conversations ($self) { my $http_res = await $self->api_call('conversations.list', { types => 'mpim,private_channel', form_encoded => 1, @@ -627,7 +640,7 @@ sub group_conversation_name ($self, $id) { unless ($conversation = $self->group_conversations->{$id}) { # A new group chat materialized perhaps? - $self->load_group_conversations->get(); + $self->reload_group_conversations->get(); $conversation = $self->group_conversations->{$id}; } @@ -639,7 +652,10 @@ sub group_conversation_name ($self, $id) { async sub load_dm_channels ($self) { return if $self->_has_dm_channels; + return await $self->reload_dm_channels; +} +async sub reload_dm_channels ($self) { my $http_res = await $self->api_call('conversations.list', { exclude_archived => 'true', types => 'im', diff --git a/lib/Synergy/Reactor/SlackID.pm b/lib/Synergy/Reactor/SlackID.pm index 89c22ac4..60109bea 100644 --- a/lib/Synergy/Reactor/SlackID.pm +++ b/lib/Synergy/Reactor/SlackID.pm @@ -66,14 +66,14 @@ responder reload_slack => { } if ($what eq 'users') { - $channel->slack->load_users; - $channel->slack->load_dm_channels; - return $event->reply('Slack users reloaded'); + await $channel->slack->reload_users; + await $channel->slack->reload_dm_channels; + return await $event->reply('Slack users reloaded'); } if ($what eq 'channels') { - $channel->slack->load_channels; - return $event->reply('Slack channels reloaded'); + await $channel->slack->reload_channels; + return await $event->reply('Slack channels reloaded'); } return $event->reply_error("Sorry, I didn't understand your reload command."); From 7161121020f9cf9b3707f4b7d08cbd4e9dba558b Mon Sep 17 00:00:00 2001 From: Ricardo Signes Date: Wed, 19 Aug 2026 12:18:14 -0400 Subject: [PATCH 3/6] Slack: never install a failed API response into our caches load_users did not look at the HTTP status or at Slack's own "ok" field. If users.list said no -- rate limiting, a token problem, a bad gateway -- then $res->{members} was undef, %users was empty, and we cached an empty hashref, logged "Slack users loaded", and then failed to name a single user for the rest of the process's life. Because the cache was now "loaded", nothing would ever try again. Everything that populates a cache now goes through ->_api_data, which fails the future unless Slack really did say yes, so a bad response leaves the previous cache in place instead of replacing it with nonsense. An empty user list is treated as a failure too, since we are ourselves a member of the workspace. Also, "my $me = $users{...}; $me->{name} = ..." silently autovivified a throwaway hashref if we weren't in the list we just fetched, so our own name went uncoerced and nobody heard about it. Now we log it. The two callers that reload from synchronous code degrade to the cache they already have, rather than throwing, if the reload fails. Co-Authored-By: Claude --- lib/Synergy/Channel/Slack.pm | 7 ++++- lib/Synergy/External/Slack.pm | 56 +++++++++++++++++++++++++---------- 2 files changed, 47 insertions(+), 16 deletions(-) diff --git a/lib/Synergy/Channel/Slack.pm b/lib/Synergy/Channel/Slack.pm index 810d5ffa..6df62651 100644 --- a/lib/Synergy/Channel/Slack.pm +++ b/lib/Synergy/Channel/Slack.pm @@ -554,7 +554,12 @@ sub describe_conversation ($self, $event) { } sub user_status_for ($self, $event, $user) { - $self->slack->reload_users->get; + # We reload here because we want fresh status text, not because we doubt our + # user cache. If the reload fails, the cache we already have is much better + # than an exception. -- rjbs, 2026-08-19 + unless (eval { $self->slack->reload_users->get; 1 }) { + $Logger->log("error reloading Slack users: $@"); + } my $ident = $user->identity_for($self->name); return unless $ident; diff --git a/lib/Synergy/External/Slack.pm b/lib/Synergy/External/Slack.pm index 11bb8f94..d305c3f5 100644 --- a/lib/Synergy/External/Slack.pm +++ b/lib/Synergy/External/Slack.pm @@ -555,6 +555,29 @@ sub dm_channel_for_address ($self, $slack_id) { return $channel_id; } +# Slack will tell us "no" in a bunch of different ways: an HTTP error, or a +# 200 response with ok=false in it, or (when it's really unhappy) a body that +# isn't JSON at all. However it does it, the important thing is that we don't +# install the resulting nonsense into one of our caches, because we'll then +# believe it until we're restarted. -- rjbs, 2026-08-19 +async sub _api_data ($self, $method, $arg = {}) { + my $http_res = await $self->api_call($method, $arg); + + my $data = eval { decode_json($http_res->decoded_content(charset => undef)) }; + + unless ($data) { + die sprintf "%s failed: couldn't decode response (HTTP status %s)\n", + $method, $http_res->code; + } + + unless ($data->{ok}) { + die sprintf "%s failed: %s (HTTP status %s)\n", + $method, ($data->{error} // 'unknown error'), $http_res->code; + } + + return $data; +} + sub readiness ($self) { Future->needs_all( map {; my $m = "load_$_"; $self->$m } @@ -572,18 +595,24 @@ async sub load_users ($self) { } async sub reload_users ($self) { - my $http_res = await $self->api_call('users.list', { - presence => 0, - }); + my $res = await $self->_api_data('users.list', { presence => 0 }); - my $res = decode_json($http_res->decoded_content(charset => undef)); my %users = map { $_->{id} => $_ } $res->{members}->@*; + # An empty user list is not a thing that can happen in a workspace that + # contains, at the very least, us. If we get one, something has gone wrong + # in a way Slack didn't admit to, and installing it would leave us unable to + # name anybody at all. -- rjbs, 2026-08-19 + die "users.list failed: no users in the response\n" unless %users; + # See comment in _register_slack_rtm: here, we coerce our username to be # our ->own_name, because decode_slack_formatting converts @U12345 into # usernames. -- michael, 2019-06-04 - my $me = $users{ $self->own_id }; - $me->{name} = $self->own_name; + if (my $me = $users{ $self->own_id }) { + $me->{name} = $self->own_name; + } else { + $Logger->log([ "we're missing from our own users.list; own id is %s", $self->own_id ]); + } $self->_set_users(\%users); $Logger->log("Slack users loaded"); @@ -596,14 +625,13 @@ async sub load_channels ($self) { } async sub reload_channels ($self) { - my $http_res = await $self->api_call('conversations.list', { + my $res = await $self->_api_data('conversations.list', { exclude_archived => 'true', types => 'public_channel', limit => 200, form_encoded => 1, }); - my $res = decode_json($http_res->decoded_content(charset => undef)); $self->_set_channels({ map { $_->{id}, $_ } $res->{channels}->@* }); @@ -619,13 +647,11 @@ async sub load_group_conversations ($self) { } async sub reload_group_conversations ($self) { - my $http_res = await $self->api_call('conversations.list', { + my $res = await $self->_api_data('conversations.list', { types => 'mpim,private_channel', form_encoded => 1, }); - my $res = decode_json($http_res->decoded_content(charset => undef)); - $self->_set_group_conversations({ map { $_->{id}, $_ } $res->{channels}->@* }); @@ -640,7 +666,9 @@ sub group_conversation_name ($self, $id) { unless ($conversation = $self->group_conversations->{$id}) { # A new group chat materialized perhaps? - $self->reload_group_conversations->get(); + unless (eval { $self->reload_group_conversations->get; 1 }) { + $Logger->log("error reloading Slack group conversations: $@"); + } $conversation = $self->group_conversations->{$id}; } @@ -656,14 +684,12 @@ async sub load_dm_channels ($self) { } async sub reload_dm_channels ($self) { - my $http_res = await $self->api_call('conversations.list', { + my $res = await $self->_api_data('conversations.list', { exclude_archived => 'true', types => 'im', form_encoded => 1, }); - my $res = decode_json($http_res->decoded_content(charset => undef)); - $self->_set_dm_channels({ map { $_->{user}, $_->{id} } $res->{ims}->@* }); From f61e3c794b2107a0e62c4d5663f9b65de0676db9 Mon Sep 17 00:00:00 2001 From: Ricardo Signes Date: Wed, 19 Aug 2026 12:20:39 -0400 Subject: [PATCH 4/6] Slack: page through the whole list, not just the first page users.list, like every "list" method in the Slack API, is paginated: it hands you a page and a cursor, and if you ignore the cursor it looks exactly like a complete answer. We ignored the cursor everywhere. Once the workspace outgrew one page, the members on the pages we never asked for became people we could never name, no matter how often we reloaded. conversations.list was worse off, since it was asked for a hard limit of 200 public channels and then had its cursor ignored too. Now all four loads go through ->_api_data_pages, which follows the cursor (up to a sanity limit of 20 pages) and dies rather than quietly returning nothing if the response doesn't have the key we asked for. It also sends these calls form-encoded, which is what the "list" methods actually want; users.list was the only one being sent as JSON. While here: conversations.list returns its results in "channels", even for types=im, so reload_dm_channels was reading a key ("ims", from the long-deprecated im.list) that isn't there. Co-Authored-By: Claude --- lib/Synergy/External/Slack.pm | 88 +++++++++++++++++++++++++++-------- 1 file changed, 68 insertions(+), 20 deletions(-) diff --git a/lib/Synergy/External/Slack.pm b/lib/Synergy/External/Slack.pm index d305c3f5..927cbc02 100644 --- a/lib/Synergy/External/Slack.pm +++ b/lib/Synergy/External/Slack.pm @@ -578,6 +578,50 @@ async sub _api_data ($self, $method, $arg = {}) { return $data; } +# Every "list" method in the Slack API is paginated, and will hand us one page +# and let us go on believing that was all of them. Once the workspace outgrew +# a single page, everyone we never fetched became someone we could never name. +# -- rjbs, 2026-08-19 +async sub _api_data_pages ($self, $method, $arg, $key) { + my @items; + my $cursor; + + # Slack shouldn't hand us cursors forever, but if it does, we would rather + # have a partial list than an infinite loop. + my $max_pages = 20; + + for my $page (1 .. $max_pages) { + my $res = await $self->_api_data($method, { + limit => 200, + %$arg, + defined_kv(cursor => $cursor), + form_encoded => 1, + }); + + unless ($res->{$key}) { + die "$method failed: no '$key' in the response\n"; + } + + push @items, $res->{$key}->@*; + + $cursor = $res->{response_metadata}{next_cursor}; + undef $cursor unless defined $cursor && length $cursor; + + unless ($cursor) { + $Logger->log_debug([ "%s: fetched %s %s in %s page(s)", + $method, 0+@items, $key, $page ]); + return @items; + } + } + + $Logger->log([ + "%s: giving up after %s pages, but Slack says there are more %s", + $method, $max_pages, $key, + ]); + + return @items; +} + sub readiness ($self) { Future->needs_all( map {; my $m = "load_$_"; $self->$m } @@ -595,9 +639,13 @@ async sub load_users ($self) { } async sub reload_users ($self) { - my $res = await $self->_api_data('users.list', { presence => 0 }); + my @members = await $self->_api_data_pages( + 'users.list', + { presence => 0 }, + 'members', + ); - my %users = map { $_->{id} => $_ } $res->{members}->@*; + my %users = map { $_->{id} => $_ } @members; # An empty user list is not a thing that can happen in a workspace that # contains, at the very least, us. If we get one, something has gone wrong @@ -625,15 +673,14 @@ async sub load_channels ($self) { } async sub reload_channels ($self) { - my $res = await $self->_api_data('conversations.list', { - exclude_archived => 'true', - types => 'public_channel', - limit => 200, - form_encoded => 1, - }); + my @channels = await $self->_api_data_pages( + 'conversations.list', + { exclude_archived => 'true', types => 'public_channel' }, + 'channels', + ); $self->_set_channels({ - map { $_->{id}, $_ } $res->{channels}->@* + map { $_->{id}, $_ } @channels }); $Logger->log("Slack channels loaded"); @@ -647,13 +694,14 @@ async sub load_group_conversations ($self) { } async sub reload_group_conversations ($self) { - my $res = await $self->_api_data('conversations.list', { - types => 'mpim,private_channel', - form_encoded => 1, - }); + my @conversations = await $self->_api_data_pages( + 'conversations.list', + { types => 'mpim,private_channel' }, + 'channels', + ); $self->_set_group_conversations({ - map { $_->{id}, $_ } $res->{channels}->@* + map { $_->{id}, $_ } @conversations }); $Logger->log("Slack group conversations loaded"); @@ -684,14 +732,14 @@ async sub load_dm_channels ($self) { } async sub reload_dm_channels ($self) { - my $res = await $self->_api_data('conversations.list', { - exclude_archived => 'true', - types => 'im', - form_encoded => 1, - }); + my @ims = await $self->_api_data_pages( + 'conversations.list', + { exclude_archived => 'true', types => 'im' }, + 'channels', + ); $self->_set_dm_channels({ - map { $_->{user}, $_->{id} } $res->{ims}->@* + map { $_->{user}, $_->{id} } @ims }); $Logger->log("Slack dm channels loaded"); From f58139b2f70795c06c5303e15d0b9f7e0881ab17 Mon Sep 17 00:00:00 2001 From: Ricardo Signes Date: Wed, 19 Aug 2026 12:21:06 -0400 Subject: [PATCH 5/6] Slack: stop pretending we preload DM channels The dm_channels attribute has a non-lazy default of {}, so it's built during construction, so its predicate has been true since before we ever connected, so load_dm_channels has returned early every single time it was called. It has never once run. (This is also why nobody noticed it was reading the wrong key out of the response.) That's fine, as it turns out: dm_channel_for_address opens DM channels one at a time and remembers them, which is how every DM channel we've ever used got found. So rather than add a startup API call we've never needed -- and a new way for startup to fail -- drop the dead load_dm_channels, take dm_channels out of ->readiness, and say in a comment how this cache really works. reload_dm_channels stays: "reload slack users" uses it to throw away whatever we've accumulated and start over. Co-Authored-By: Claude --- lib/Synergy/External/Slack.pm | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/lib/Synergy/External/Slack.pm b/lib/Synergy/External/Slack.pm index 927cbc02..2cac7c59 100644 --- a/lib/Synergy/External/Slack.pm +++ b/lib/Synergy/External/Slack.pm @@ -71,12 +71,15 @@ has _channels_by_name => ( }, ); +# Unlike the other caches, this one starts out empty and fills in as we go: +# dm_channel_for_address opens (and remembers) DM channels one at a time, as we +# need them. There's also reload_dm_channels, to get them all at once, but we +# don't do that at startup, because we've never needed to. -- rjbs, 2026-08-19 has dm_channels => ( is => 'ro', isa => 'HashRef', traits => [ 'Hash' ], writer => '_set_dm_channels', - predicate => '_has_dm_channels', default => sub { {} }, handles => { dm_channel_for => 'get', @@ -625,7 +628,7 @@ async sub _api_data_pages ($self, $method, $arg, $key) { sub readiness ($self) { Future->needs_all( map {; my $m = "load_$_"; $self->$m } - qw( users channels group_conversations dm_channels ) + qw( users channels group_conversations ) ); } @@ -726,11 +729,6 @@ sub group_conversation_name ($self, $id) { return $conversation->{name} || 'group'; } -async sub load_dm_channels ($self) { - return if $self->_has_dm_channels; - return await $self->reload_dm_channels; -} - async sub reload_dm_channels ($self) { my @ims = await $self->_api_data_pages( 'conversations.list', From 335070cd4bbbffdcaed3125018bea02d16559543 Mon Sep 17 00:00:00 2001 From: Ricardo Signes Date: Wed, 19 Aug 2026 12:23:22 -0400 Subject: [PATCH 6/6] Slack: learn about users we didn't know at startup Two ways, now, that a user id we've never seen can become a name without waiting for a restart: First, we pay attention to the team_join and user_change events on the RTM connection, which is Slack telling us exactly what we want to know. Second, when ->username is asked about an id that isn't in the cache, it asks users.info about that id in the background and answers correctly the next time. It can't wait for the answer, because it's called from places like the s///e that rewrites <@U123ABC> in message text, so this time around the caller still gets "". We ask only once per id: many of the ids we see -- apps, and people from other workspaces we share channels with -- are not users we will ever be able to look up, and there's no sense asking about them over and over. Also, ->username no longer uses an undefined id as a hash key, which warned in its own right. Co-Authored-By: Claude --- lib/Synergy/External/Slack.pm | 72 ++++++++++++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/lib/Synergy/External/Slack.pm b/lib/Synergy/External/Slack.pm index 2cac7c59..8cb55d60 100644 --- a/lib/Synergy/External/Slack.pm +++ b/lib/Synergy/External/Slack.pm @@ -234,6 +234,16 @@ sub send_frame ($self, $frame) { } sub handle_frame ($self, $slack_event) { + # These are the only way we'll ever hear about somebody who joined, or + # changed their name, after we started up. Without them, we'd go on calling + # them "" until the next restart. -- rjbs, 2026-08-19 + my $type = $slack_event->{type} // ''; + + if ($type eq 'team_join' or $type eq 'user_change') { + $self->_update_user($slack_event->{user}); + return; + } + return unless my $reply_to = $slack_event->{reply_to}; # Cancel the timeout, then mark the future done with the decoded frame @@ -495,7 +505,63 @@ sub _form_encoded_api_call ($self, $url, $arg, %extra) { )); } +has _unknown_user_ids => ( + is => 'ro', + isa => 'HashRef', + lazy => 1, + default => sub { {} }, +); + +# The user cache is a hash of what users.list told us, keyed on Slack user id, +# with the same members Slack sends: id, name, profile, and so on. +sub _update_user ($self, $user) { + return unless $user && $user->{id}; + + # If we haven't loaded the users yet, we'll get this one when we do. + return unless $self->_has_users; + + # See the comment in reload_users about why we do this. + $user->{name} = $self->own_name + if $self->own_id && $user->{id} eq $self->own_id; + + $self->users->{ $user->{id} } = $user; + delete $self->_unknown_user_ids->{ $user->{id} }; + + $Logger->log([ "updated Slack user %s (%s)", $user->{id}, ($user->{name} // "no name") ]); + + return; +} + +# We can't turn an unknown user id into a name without asking Slack, we can't +# ask Slack without waiting, and ->username gets called from places that can't +# wait, like an s///e over the text of a message. So: ask in the background, +# and be right the next time somebody asks us. We only ask once per id, +# because plenty of the ids we'll see -- apps, and people from other +# workspaces we share a channel with -- are not users we can ever look up. +# -- rjbs, 2026-08-19 +sub _start_learning_about_user ($self, $id) { + return if $self->_unknown_user_ids->{$id}++; + + $Logger->log([ "asking Slack about unknown user %s", $id ]); + + my $f = $self->_api_data('users.info', { user => $id, form_encoded => 1 }) + ->then(sub ($res) { + $self->_update_user($res->{user}); + return Future->done; + }) + ->else(sub (@error) { + $Logger->log([ "couldn't look up Slack user %s: %s", $id, ($error[0] // "unknown error") ]); + return Future->done; + }); + + $f->retain; + + return; +} + sub username ($self, $id) { + return '' unless defined $id; + my $users = $self->users; # This stinks! Sometimes we try to decode an event before we have finished @@ -509,7 +575,11 @@ sub username ($self, $id) { # asking about, and then we'd return undef, and warn. Also, we must not # deref $users->{$id} without checking, or we autovivify a nameless user # into the cache on every miss. -- rjbs, 2026-08-19 - return "" unless $users && $users->{$id}; + unless ($users && $users->{$id}) { + $self->_start_learning_about_user($id) if $users; + return ""; + } + return $users->{$id}{name} // ""; }