Skip to content

Commit 98cb45d

Browse files
authored
Merge pull request #71 from spacedriveapp/feat/slack-identity-caching
feat(slack): cache user identities and resolve channel names
2 parents 1971fc3 + ffcd567 commit 98cb45d

9 files changed

Lines changed: 120 additions & 18 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ interface/dist/
88

99
.idea
1010
list/
11+
agents/

docs/content/docs/(messaging)/slack-setup.mdx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,13 @@ In your Slack app settings, go to **OAuth & Permissions** → scroll to **Bot To
3737
| `reactions:write` | Thinking indicator |
3838
| `reactions:read` | Read reactions |
3939
| `channels:history` | Read message history in public channels |
40+
| `channels:read` | Read public channel metadata (for channel names) |
4041
| `groups:history` | Same, for private channels |
42+
| `groups:read` | Read private channel metadata (for channel names) |
4143
| `im:history` | Same, for DMs |
44+
| `im:read` | Read DM metadata (for conversation names) |
4245
| `mpim:history` | Same, for group DMs |
46+
| `mpim:read` | Read group DM metadata (for conversation names) |
4347
| `users:read` | Resolve display names |
4448
| `files:write` | Upload file attachments |
4549
| `files:read` | Read file metadata |

interface/src/api/client.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export interface InboundMessageEvent {
2828
type: "inbound_message";
2929
agent_id: string;
3030
channel_id: string;
31+
sender_name?: string | null;
3132
sender_id: string;
3233
text: string;
3334
}

interface/src/hooks/useChannelLiveState.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ export function useChannelLiveState(channels: ChannelInfo[]) {
189189
type: "message",
190190
id: `in-${Date.now()}-${crypto.randomUUID()}`,
191191
role: "user",
192-
sender_name: event.sender_id,
192+
sender_name: event.sender_name ?? event.sender_id,
193193
sender_id: event.sender_id,
194194
content: event.text,
195195
created_at: new Date().toISOString(),

src/api/state.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ pub enum ApiEvent {
9999
InboundMessage {
100100
agent_id: String,
101101
channel_id: String,
102+
sender_name: Option<String>,
102103
sender_id: String,
103104
text: String,
104105
},

src/conversation/channels.rs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ impl ChannelStore {
3939
let pool = self.pool.clone();
4040
let channel_id = channel_id.to_string();
4141
let platform = extract_platform(&channel_id);
42-
let display_name = extract_display_name(&platform, metadata);
42+
let display_name = extract_display_name(&platform, &channel_id, metadata);
4343
let platform_meta = extract_platform_meta(&platform, metadata);
4444

4545
tokio::spawn(async move {
@@ -195,6 +195,7 @@ fn extract_platform(channel_id: &str) -> String {
195195
/// Pull the best display name from inbound message metadata.
196196
fn extract_display_name(
197197
platform: &str,
198+
channel_id: &str,
198199
metadata: &HashMap<String, serde_json::Value>,
199200
) -> Option<String> {
200201
match platform {
@@ -205,7 +206,13 @@ fn extract_display_name(
205206
"slack" => metadata
206207
.get("slack_channel_name")
207208
.and_then(|v| v.as_str())
208-
.map(|s| s.to_string()),
209+
.map(|name| {
210+
if channel_id.contains(":D") || name.starts_with("dm-") {
211+
name.to_string()
212+
} else {
213+
format!("#{name}")
214+
}
215+
}),
209216
"telegram" => metadata
210217
.get("display_name")
211218
.and_then(|v| v.as_str())

src/main.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -881,9 +881,17 @@ async fn run(
881881
*active.latest_message.write().await = message.clone();
882882

883883
// Emit inbound message to SSE clients
884+
let sender_name = message.formatted_author.clone().or_else(|| {
885+
message
886+
.metadata
887+
.get("sender_display_name")
888+
.and_then(|v| v.as_str())
889+
.map(|s| s.to_string())
890+
});
884891
api_state.event_tx.send(spacebot::api::ApiEvent::InboundMessage {
885892
agent_id: agent_id.to_string(),
886893
channel_id: conversation_id.clone(),
894+
sender_name,
887895
sender_id: message.sender_id.clone(),
888896
text: message.content.to_string(),
889897
}).ok();

src/messaging/slack.rs

Lines changed: 93 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ struct SlackAdapterState {
4141
/// Maps slash command string (e.g. `"/ask"`) → agent_id.
4242
/// Built once at start() from the config; read-only afterwards.
4343
commands: Arc<HashMap<String, String>>,
44+
/// Cache of resolved user identities to avoid repeated `users.info` API calls.
45+
user_identity_cache: Arc<RwLock<HashMap<String, SlackUserIdentity>>>,
46+
/// Cache of resolved channel names to avoid repeated `conversations.info` API calls.
47+
channel_name_cache: Arc<RwLock<HashMap<String, String>>>,
4448
}
4549

4650
#[derive(Debug, Clone)]
@@ -202,6 +206,8 @@ async fn handle_message_event(
202206
msg_event.sender.user.as_ref(),
203207
&client,
204208
&adapter_state.bot_token,
209+
&adapter_state.user_identity_cache,
210+
&adapter_state.channel_name_cache,
205211
)
206212
.await;
207213

@@ -282,6 +288,8 @@ async fn handle_app_mention_event(
282288
Some(&slack_uid),
283289
&client,
284290
&adapter_state.bot_token,
291+
&adapter_state.user_identity_cache,
292+
&adapter_state.channel_name_cache,
285293
)
286294
.await;
287295

@@ -652,6 +660,8 @@ impl Messaging for SlackAdapter {
652660
bot_token: self.bot_token.clone(),
653661
bot_user_id,
654662
commands: self.commands.clone(),
663+
user_identity_cache: Arc::new(RwLock::new(HashMap::new())),
664+
channel_name_cache: Arc::new(RwLock::new(HashMap::new())),
655665
});
656666

657667
let callbacks = SlackSocketModeListenerCallbacks::new()
@@ -1104,11 +1114,10 @@ impl Messaging for SlackAdapter {
11041114
let author = if is_bot {
11051115
"bot".to_string()
11061116
} else if let Some(uid) = user_id {
1107-
let display_name = user_identity_by_id
1117+
user_identity_by_id
11081118
.get(&uid)
11091119
.map(|i| i.display_name.clone())
1110-
.unwrap_or_else(|| uid.clone());
1111-
format!("{display_name} (<@{uid}>)")
1120+
.unwrap_or_else(|| uid.clone())
11121121
} else {
11131122
"unknown".to_string()
11141123
};
@@ -1241,6 +1250,8 @@ async fn build_metadata_and_author(
12411250
slack_user_id: Option<&SlackUserId>,
12421251
client: &Arc<SlackHyperClient>,
12431252
bot_token: &str,
1253+
user_identity_cache: &Arc<RwLock<HashMap<String, SlackUserIdentity>>>,
1254+
channel_name_cache: &Arc<RwLock<HashMap<String, String>>>,
12441255
) -> (HashMap<String, serde_json::Value>, Option<String>) {
12451256
let mut metadata = HashMap::new();
12461257

@@ -1276,26 +1287,96 @@ async fn build_metadata_and_author(
12761287
);
12771288
}
12781289

1279-
// Resolve display name and username
1290+
let token = SlackApiToken::new(SlackApiTokenValue(bot_token.to_string()));
1291+
let session = client.open_session(&token);
1292+
1293+
// Resolve channel name via cache or conversations.info API.
1294+
if let Some(name) = channel_name_cache.read().await.get(channel_id).cloned() {
1295+
metadata.insert(
1296+
"slack_channel_name".into(),
1297+
serde_json::Value::String(name),
1298+
);
1299+
} else {
1300+
match session
1301+
.conversations_info(
1302+
&SlackApiConversationsInfoRequest::new(SlackChannelId(channel_id.to_string())),
1303+
)
1304+
.await
1305+
{
1306+
Ok(channel_info) => {
1307+
if let Some(name) = channel_info.channel.name {
1308+
channel_name_cache
1309+
.write()
1310+
.await
1311+
.insert(channel_id.to_string(), name.clone());
1312+
metadata.insert(
1313+
"slack_channel_name".into(),
1314+
serde_json::Value::String(name),
1315+
);
1316+
}
1317+
}
1318+
// DM channels (D-prefixed) don't support conversations.info in all cases
1319+
Err(error) if !channel_id.starts_with('D') => {
1320+
tracing::warn!(
1321+
%error,
1322+
channel_id = %channel_id,
1323+
"failed to resolve Slack channel name; verify channels:read scope"
1324+
);
1325+
}
1326+
Err(_) => {}
1327+
}
1328+
}
1329+
1330+
// Resolve user identity via cache or users.info API.
12801331
let mut formatted_author = user_id.map(|u| u.to_string());
12811332

12821333
if let Some(uid) = slack_user_id {
1283-
let token = SlackApiToken::new(SlackApiTokenValue(bot_token.to_string()));
1284-
let session = client.open_session(&token);
1285-
if let Ok(user_info) = session
1334+
let cached = user_identity_cache.read().await.get(&uid.0).cloned();
1335+
let identity = if let Some(identity) = cached {
1336+
Some(identity)
1337+
} else if let Ok(user_info) = session
12861338
.users_info(&SlackApiUsersInfoRequest::new(uid.clone()))
12871339
.await
12881340
{
12891341
let identity = resolve_slack_user_identity(&user_info.user, &uid.0);
1290-
let display_with_mention = format!("{} (<@{}>)", identity.display_name, uid.0);
1342+
user_identity_cache
1343+
.write()
1344+
.await
1345+
.insert(uid.0.clone(), identity.clone());
1346+
Some(identity)
1347+
} else {
1348+
None
1349+
};
1350+
1351+
if let Some(identity) = identity {
12911352
metadata.insert(
12921353
"sender_display_name".into(),
1293-
serde_json::Value::String(display_with_mention.clone()),
1354+
serde_json::Value::String(identity.display_name.clone()),
1355+
);
1356+
metadata.insert(
1357+
"slack_user_mention".into(),
1358+
serde_json::Value::String(format!("<@{}>", uid.0)),
12941359
);
1295-
if let Some(name) = identity.username {
1296-
metadata.insert("sender_username".into(), serde_json::Value::String(name));
1360+
if let Some(ref name) = identity.username {
1361+
metadata.insert(
1362+
"sender_username".into(),
1363+
serde_json::Value::String(name.clone()),
1364+
);
12971365
}
1298-
formatted_author = Some(display_with_mention);
1366+
formatted_author = Some(identity.display_name.clone());
1367+
}
1368+
}
1369+
1370+
// For DMs without a resolved channel name, use the sender's display name.
1371+
if channel_id.starts_with('D') && !metadata.contains_key("slack_channel_name") {
1372+
if let Some(display_name) = metadata
1373+
.get("sender_display_name")
1374+
.and_then(|v| v.as_str())
1375+
{
1376+
metadata.insert(
1377+
"slack_channel_name".into(),
1378+
serde_json::Value::String(format!("dm-{display_name}")),
1379+
);
12991380
}
13001381
}
13011382

src/tools/reply.rs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,12 +108,11 @@ async fn convert_mentions(
108108
if let (Some(name), Some(id), Some(meta_str)) =
109109
(&msg.sender_name, &msg.sender_id, &msg.metadata)
110110
{
111-
// Parse metadata JSON to get clean display name (without mention syntax)
111+
// Parse metadata JSON to get clean display name
112112
if let Ok(meta) = serde_json::from_str::<HashMap<String, serde_json::Value>>(meta_str) {
113113
if let Some(display_name) = meta.get("sender_display_name").and_then(|v| v.as_str())
114114
{
115-
// For Slack (from PR #43), sender_display_name includes mention: "Name (<@ID>)"
116-
// Extract just the name part
115+
// Older rows may include mention syntax "Name (<@ID>)"; strip it.
117116
let clean_name = display_name.split(" (<@").next().unwrap_or(display_name);
118117
name_to_id.insert(clean_name.to_string(), id.clone());
119118
}

0 commit comments

Comments
 (0)