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 mut actor = ExternalActor {
actor_id: "U001".into(),
actor_name: Some("Alice".into()),
source: "slack".into(),
metadata: Some(HashMap::from([("team".into(), "T1".into())])),
};
assert!(ctx.track_participant(&actor));
let participant = &ctx.participants["U001"];
assert_eq!(participant.actor, actor);
assert!(participant.first_seen_at.is_some());
assert!(!ctx.track_participant(&actor));
assert_eq!(ctx.participant_count(), 1);
let first_seen = chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap();
let participant = ctx.participants.get_mut("U001").unwrap();
participant.first_seen_at = Some(first_seen);
participant.role = Some("owner".into());
actor.actor_name = Some("Alice B.".into());
assert!(!ctx.track_participant(&actor));
assert_eq!(ctx.participant_count(), 1);
assert_eq!(
ctx.participants["U001"],
Participant {
actor,
first_seen_at: Some(first_seen),
role: Some("owner".into()),
}
);
}
#[test]
fn test_thread_context_participants_summary() {
let mut ctx = ThreadContext::new("thread_1", "discord");
assert_eq!(ctx.participants_summary(), "");
for (actor_id, name) in [
("U003", Some("Zoe")),
("U002", None),
("U001", Some("Alice")),
] {
ctx.track_participant(&ExternalActor {
actor_id: actor_id.into(),
actor_name: name.map(str::to_string),
source: "discord".into(),
metadata: None,
});
}
assert_eq!(
ctx.participants_summary(),
"Thread participants: Alice, U002, Zoe"
);
}
#[test]
fn test_build_session_routing_tags() {
let metadata = HashMap::from([
("thread_ref".into(), "1234.5678".into()),
("channel_id".into(), "C0123".into()),
("user_id".into(), "U999".into()),
]);
for (strategy, platform, key, expected) in [
(
SessionRoutingStrategy::PerThread,
"slack",
"thread_ref",
"slack:thread:1234.5678",
),
(
SessionRoutingStrategy::PerChannel,
"discord",
"channel_id",
"discord:channel:C0123",
),
(
SessionRoutingStrategy::PerUser,
"teams",
"user_id",
"teams:user:U999",
),
] {
assert_eq!(
build_session_routing_tag(platform, &strategy, &metadata).as_deref(),
Some(expected)
);
let mut missing = metadata.clone();
missing.remove(key);
assert_eq!(
build_session_routing_tag(platform, &strategy, &missing),
None
);
assert_eq!(
build_session_routing_tag(platform, &strategy, &HashMap::new()),
None
);
}
}
#[test]
fn test_channel_reply_mode_wire_contract() {
assert_eq!(ChannelReplyMode::default(), ChannelReplyMode::AllMessages);
for (mode, wire) in [
(ChannelReplyMode::AllMessages, "\"all_messages\""),
(
ChannelReplyMode::ReportProgressOnly,
"\"report_progress_only\"",
),
] {
assert_eq!(serde_json::to_string(&mode).unwrap(), wire);
assert_eq!(
serde_json::from_str::<ChannelReplyMode>(wire).unwrap(),
mode
);
}
}
#[test]
fn test_session_routing_strategy_wire_contract() {
assert_eq!(
SessionRoutingStrategy::default(),
SessionRoutingStrategy::PerThread
);
for (strategy, wire) in [
(SessionRoutingStrategy::PerThread, "\"per_thread\""),
(SessionRoutingStrategy::PerChannel, "\"per_channel\""),
(SessionRoutingStrategy::PerUser, "\"per_user\""),
] {
assert_eq!(serde_json::to_string(&strategy).unwrap(), wire);
assert_eq!(
serde_json::from_str::<SessionRoutingStrategy>(wire).unwrap(),
strategy
);
}
}
}