use crate::message::ExternalActor;
use crate::typed_id::SessionId;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Participant {
pub actor: ExternalActor,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub first_seen_at: Option<chrono::DateTime<chrono::Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ThreadContext {
pub thread_ref: String,
pub platform: String,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub platform_metadata: HashMap<String, String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub participants: HashMap<String, Participant>,
}
impl ThreadContext {
pub fn new(thread_ref: impl Into<String>, platform: impl Into<String>) -> Self {
Self {
thread_ref: thread_ref.into(),
platform: platform.into(),
platform_metadata: HashMap::new(),
participants: HashMap::new(),
}
}
pub fn track_participant(&mut self, actor: &ExternalActor) -> bool {
use std::collections::hash_map::Entry;
match self.participants.entry(actor.actor_id.clone()) {
Entry::Vacant(entry) => {
entry.insert(Participant {
actor: actor.clone(),
first_seen_at: Some(chrono::Utc::now()),
role: None,
});
true
}
Entry::Occupied(mut entry) => {
if actor.actor_name != entry.get().actor.actor_name {
entry.get_mut().actor.actor_name = actor.actor_name.clone();
}
false
}
}
}
pub fn participant_count(&self) -> usize {
self.participants.len()
}
pub fn participants_summary(&self) -> String {
if self.participants.is_empty() {
return String::new();
}
let mut names: Vec<String> = self
.participants
.values()
.map(|p| p.actor.display_label().to_string())
.collect();
names.sort();
format!("Thread participants: {}", names.join(", "))
}
}
#[derive(Debug, Clone)]
pub struct InboundChannelEvent {
pub actor: ExternalActor,
pub text: String,
pub attachments: Vec<InboundAttachment>,
pub dedup_key: String,
pub thread_ref: Option<String>,
pub routing_metadata: HashMap<String, String>,
}
#[derive(Debug, Clone)]
pub enum InboundAttachment {
Image {
url: String,
alt_text: Option<String>,
},
FileDescription {
name: String,
mime_type: Option<String>,
},
}
#[derive(Debug, Clone)]
pub struct OutboundChannelMessage {
pub session_id: SessionId,
pub text: String,
pub thread_ref: String,
pub is_progress_report: bool,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum ChannelReplyMode {
#[default]
AllMessages,
ReportProgressOnly,
}
#[async_trait]
pub trait ChannelDeliveryAdapter: Send + Sync {
fn platform(&self) -> &str;
async fn deliver(
&self,
message: &OutboundChannelMessage,
context: &DeliveryContext,
) -> DeliveryResult;
async fn send_ack(
&self,
thread_ref: &str,
text: &str,
context: &DeliveryContext,
) -> DeliveryResult;
fn format_progress_report(
&self,
report: &crate::progress_reporting::ProgressReportPayload,
) -> String;
}
#[derive(Clone)]
pub struct DeliveryContext {
pub auth_token: String,
pub channel_id: String,
pub thread_ref: String,
pub reply_mode: ChannelReplyMode,
pub extra: HashMap<String, String>,
}
impl std::fmt::Debug for DeliveryContext {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DeliveryContext")
.field("auth_token", &"[REDACTED]")
.field("channel_id", &self.channel_id)
.field("thread_ref", &self.thread_ref)
.field("reply_mode", &self.reply_mode)
.field("extra", &self.extra)
.finish()
}
}
#[derive(Debug)]
pub enum DeliveryResult {
Ok,
TransientError(String),
PermanentError(String),
}
pub fn build_session_routing_tag(
platform: &str,
strategy: &SessionRoutingStrategy,
metadata: &HashMap<String, String>,
) -> Option<String> {
match strategy {
SessionRoutingStrategy::PerThread => metadata
.get("thread_ref")
.map(|t| format!("{}:thread:{}", platform, t)),
SessionRoutingStrategy::PerChannel => metadata
.get("channel_id")
.map(|c| format!("{}:channel:{}", platform, c)),
SessionRoutingStrategy::PerUser => metadata
.get("user_id")
.map(|u| format!("{}:user:{}", platform, u)),
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum SessionRoutingStrategy {
#[default]
PerThread,
PerChannel,
PerUser,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_thread_context_track_participant() {
let mut ctx = ThreadContext::new("1234.5678", "slack");
let actor = ExternalActor {
actor_id: "U001".into(),
actor_name: Some("Alice".into()),
source: "slack".into(),
metadata: None,
};
assert!(ctx.track_participant(&actor));
assert!(!ctx.track_participant(&actor)); assert_eq!(ctx.participant_count(), 1);
}
#[test]
fn test_thread_context_updates_display_name() {
let mut ctx = ThreadContext::new("thread_1", "slack");
let actor_v1 = ExternalActor {
actor_id: "U001".into(),
actor_name: Some("Alice".into()),
source: "slack".into(),
metadata: None,
};
let actor_v2 = ExternalActor {
actor_id: "U001".into(),
actor_name: Some("Alice B.".into()),
source: "slack".into(),
metadata: None,
};
ctx.track_participant(&actor_v1);
ctx.track_participant(&actor_v2);
let p = ctx.participants.get("U001").unwrap();
assert_eq!(p.actor.actor_name.as_deref(), Some("Alice B."));
}
#[test]
fn test_thread_context_participants_summary() {
let mut ctx = ThreadContext::new("thread_1", "discord");
assert_eq!(ctx.participants_summary(), "");
ctx.track_participant(&ExternalActor {
actor_id: "U001".into(),
actor_name: Some("Alice".into()),
source: "discord".into(),
metadata: None,
});
ctx.track_participant(&ExternalActor {
actor_id: "U002".into(),
actor_name: None,
source: "discord".into(),
metadata: None,
});
let summary = ctx.participants_summary();
assert!(summary.contains("Alice"));
assert!(summary.contains("U002")); }
#[test]
fn test_build_session_routing_tag_per_thread() {
let mut meta = HashMap::new();
meta.insert("thread_ref".into(), "1234.5678".into());
let tag = build_session_routing_tag("slack", &SessionRoutingStrategy::PerThread, &meta);
assert_eq!(tag, Some("slack:thread:1234.5678".into()));
}
#[test]
fn test_build_session_routing_tag_per_channel() {
let mut meta = HashMap::new();
meta.insert("channel_id".into(), "C0123".into());
let tag = build_session_routing_tag("discord", &SessionRoutingStrategy::PerChannel, &meta);
assert_eq!(tag, Some("discord:channel:C0123".into()));
}
#[test]
fn test_build_session_routing_tag_per_user() {
let mut meta = HashMap::new();
meta.insert("user_id".into(), "U999".into());
let tag = build_session_routing_tag("teams", &SessionRoutingStrategy::PerUser, &meta);
assert_eq!(tag, Some("teams:user:U999".into()));
}
#[test]
fn test_build_session_routing_tag_missing_metadata() {
let meta = HashMap::new();
let tag = build_session_routing_tag("slack", &SessionRoutingStrategy::PerThread, &meta);
assert_eq!(tag, None);
}
#[test]
fn test_channel_reply_mode_default() {
assert_eq!(ChannelReplyMode::default(), ChannelReplyMode::AllMessages);
}
#[test]
fn test_channel_reply_mode_serde_roundtrip() {
let json = serde_json::to_string(&ChannelReplyMode::ReportProgressOnly).unwrap();
assert_eq!(json, r#""report_progress_only""#);
let parsed: ChannelReplyMode = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, ChannelReplyMode::ReportProgressOnly);
}
#[test]
fn test_session_routing_strategy_default() {
assert_eq!(
SessionRoutingStrategy::default(),
SessionRoutingStrategy::PerThread
);
}
#[test]
fn test_session_routing_strategy_serde() {
let json = serde_json::to_string(&SessionRoutingStrategy::PerChannel).unwrap();
assert_eq!(json, r#""per_channel""#);
let parsed: SessionRoutingStrategy = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, SessionRoutingStrategy::PerChannel);
}
}