feat(mattermost): add thread_replies config and typing indicator
Add two Mattermost channel enhancements: 1. thread_replies config option (default: false) - When false, replies go to the channel root instead of threading. - When true, replies thread on the original post. - Existing thread replies always stay in-thread regardless of setting. 2. Typing indicator (start_typing/stop_typing) - Implements the Channel trait's typing methods for Mattermost. - Fires POST /api/v4/users/me/typing every 4s in a background task. - Supports parent_id for threaded typing indicators. - Aborts cleanly on stop_typing via JoinHandle. Updated all MattermostChannel::new call sites (start_channels, scheduler) and added 9 unit tests covering thread routing and edge cases.
This commit is contained in:
parent
41c3e62dad
commit
58120b1c69
4 changed files with 143 additions and 13 deletions
|
|
@ -9,7 +9,12 @@ pub struct MattermostChannel {
|
||||||
bot_token: String,
|
bot_token: String,
|
||||||
channel_id: Option<String>,
|
channel_id: Option<String>,
|
||||||
allowed_users: Vec<String>,
|
allowed_users: Vec<String>,
|
||||||
|
/// When true, replies thread on the original post's root_id.
|
||||||
|
/// When false (default), replies go to the channel root.
|
||||||
|
thread_replies: bool,
|
||||||
client: reqwest::Client,
|
client: reqwest::Client,
|
||||||
|
/// Handle for the background typing-indicator loop (aborted on stop_typing).
|
||||||
|
typing_handle: std::sync::Mutex<Option<tokio::task::JoinHandle<()>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MattermostChannel {
|
impl MattermostChannel {
|
||||||
|
|
@ -18,6 +23,7 @@ impl MattermostChannel {
|
||||||
bot_token: String,
|
bot_token: String,
|
||||||
channel_id: Option<String>,
|
channel_id: Option<String>,
|
||||||
allowed_users: Vec<String>,
|
allowed_users: Vec<String>,
|
||||||
|
thread_replies: bool,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
// Ensure base_url doesn't have a trailing slash for consistent path joining
|
// Ensure base_url doesn't have a trailing slash for consistent path joining
|
||||||
let base_url = base_url.trim_end_matches('/').to_string();
|
let base_url = base_url.trim_end_matches('/').to_string();
|
||||||
|
|
@ -26,7 +32,9 @@ impl MattermostChannel {
|
||||||
bot_token,
|
bot_token,
|
||||||
channel_id,
|
channel_id,
|
||||||
allowed_users,
|
allowed_users,
|
||||||
|
thread_replies,
|
||||||
client: reqwest::Client::new(),
|
client: reqwest::Client::new(),
|
||||||
|
typing_handle: std::sync::Mutex::new(None),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -177,6 +185,61 @@ impl Channel for MattermostChannel {
|
||||||
.map(|r| r.status().is_success())
|
.map(|r| r.status().is_success())
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn start_typing(&self, recipient: &str) -> Result<()> {
|
||||||
|
// Cancel any existing typing loop before starting a new one.
|
||||||
|
self.stop_typing(recipient).await?;
|
||||||
|
|
||||||
|
let client = self.client.clone();
|
||||||
|
let token = self.bot_token.clone();
|
||||||
|
let base_url = self.base_url.clone();
|
||||||
|
|
||||||
|
// recipient is "channel_id" or "channel_id:root_id"
|
||||||
|
let channel_id = recipient.split(':').next().unwrap_or(recipient).to_string();
|
||||||
|
let parent_id = recipient.split(':').nth(1).map(String::from);
|
||||||
|
|
||||||
|
let handle = tokio::spawn(async move {
|
||||||
|
let url = format!("{base_url}/api/v4/users/me/typing");
|
||||||
|
loop {
|
||||||
|
let mut body = serde_json::json!({ "channel_id": channel_id });
|
||||||
|
if let Some(ref pid) = parent_id {
|
||||||
|
body.as_object_mut()
|
||||||
|
.unwrap()
|
||||||
|
.insert("parent_id".to_string(), serde_json::json!(pid));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(r) = client
|
||||||
|
.post(&url)
|
||||||
|
.bearer_auth(&token)
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
if !r.status().is_success() {
|
||||||
|
tracing::debug!(status = %r.status(), "Mattermost typing indicator failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mattermost typing events expire after ~6s; re-fire every 4s.
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(4)).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Ok(mut guard) = self.typing_handle.lock() {
|
||||||
|
*guard = Some(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stop_typing(&self, _recipient: &str) -> Result<()> {
|
||||||
|
if let Ok(mut guard) = self.typing_handle.lock() {
|
||||||
|
if let Some(handle) = guard.take() {
|
||||||
|
handle.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MattermostChannel {
|
impl MattermostChannel {
|
||||||
|
|
@ -202,15 +265,16 @@ impl MattermostChannel {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If it's a thread, include root_id in reply_to so we reply in the same thread
|
// Reply routing depends on thread_replies config:
|
||||||
let reply_target = if root_id.is_empty() {
|
// - Existing thread (root_id set): always stay in the thread.
|
||||||
// Or if it's a top-level message that WE want to start a thread on,
|
// - Top-level post + thread_replies=true: thread on the original post.
|
||||||
// the next reply will use THIS post's ID as root_id.
|
// - Top-level post + thread_replies=false: reply at channel level.
|
||||||
// But for now, we follow Mattermost's 'reply' convention where
|
let reply_target = if !root_id.is_empty() {
|
||||||
// replying to a post uses its ID as root_id.
|
format!("{}:{}", channel_id, root_id)
|
||||||
|
} else if self.thread_replies {
|
||||||
format!("{}:{}", channel_id, id)
|
format!("{}:{}", channel_id, id)
|
||||||
} else {
|
} else {
|
||||||
format!("{}:{}", channel_id, root_id)
|
channel_id.to_string()
|
||||||
};
|
};
|
||||||
|
|
||||||
Some(ChannelMessage {
|
Some(ChannelMessage {
|
||||||
|
|
@ -237,19 +301,22 @@ mod tests {
|
||||||
"token".into(),
|
"token".into(),
|
||||||
None,
|
None,
|
||||||
vec![],
|
vec![],
|
||||||
|
false,
|
||||||
);
|
);
|
||||||
assert_eq!(ch.base_url, "https://mm.example.com");
|
assert_eq!(ch.base_url, "https://mm.example.com");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mattermost_allowlist_wildcard() {
|
fn mattermost_allowlist_wildcard() {
|
||||||
let ch = MattermostChannel::new("url".into(), "token".into(), None, vec!["*".into()]);
|
let ch =
|
||||||
|
MattermostChannel::new("url".into(), "token".into(), None, vec!["*".into()], false);
|
||||||
assert!(ch.is_user_allowed("any-id"));
|
assert!(ch.is_user_allowed("any-id"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mattermost_parse_post_basic() {
|
fn mattermost_parse_post_basic() {
|
||||||
let ch = MattermostChannel::new("url".into(), "token".into(), None, vec!["*".into()]);
|
let ch =
|
||||||
|
MattermostChannel::new("url".into(), "token".into(), None, vec!["*".into()], false);
|
||||||
let post = json!({
|
let post = json!({
|
||||||
"id": "post123",
|
"id": "post123",
|
||||||
"user_id": "user456",
|
"user_id": "user456",
|
||||||
|
|
@ -263,12 +330,30 @@ mod tests {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(msg.sender, "user456");
|
assert_eq!(msg.sender, "user456");
|
||||||
assert_eq!(msg.content, "hello world");
|
assert_eq!(msg.content, "hello world");
|
||||||
assert_eq!(msg.reply_target, "chan789:post123"); // Threads on the post
|
assert_eq!(msg.reply_target, "chan789"); // Channel-level reply (thread_replies=false)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mattermost_parse_post_thread_replies_enabled() {
|
||||||
|
let ch = MattermostChannel::new("url".into(), "token".into(), None, vec!["*".into()], true);
|
||||||
|
let post = json!({
|
||||||
|
"id": "post123",
|
||||||
|
"user_id": "user456",
|
||||||
|
"message": "hello world",
|
||||||
|
"create_at": 1_600_000_000_000_i64,
|
||||||
|
"root_id": ""
|
||||||
|
});
|
||||||
|
|
||||||
|
let msg = ch
|
||||||
|
.parse_mattermost_post(&post, "bot123", 1_500_000_000_000_i64, "chan789")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(msg.reply_target, "chan789:post123"); // Threaded reply
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mattermost_parse_post_thread() {
|
fn mattermost_parse_post_thread() {
|
||||||
let ch = MattermostChannel::new("url".into(), "token".into(), None, vec!["*".into()]);
|
let ch =
|
||||||
|
MattermostChannel::new("url".into(), "token".into(), None, vec!["*".into()], false);
|
||||||
let post = json!({
|
let post = json!({
|
||||||
"id": "post123",
|
"id": "post123",
|
||||||
"user_id": "user456",
|
"user_id": "user456",
|
||||||
|
|
@ -285,7 +370,8 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mattermost_parse_post_ignore_self() {
|
fn mattermost_parse_post_ignore_self() {
|
||||||
let ch = MattermostChannel::new("url".into(), "token".into(), None, vec!["*".into()]);
|
let ch =
|
||||||
|
MattermostChannel::new("url".into(), "token".into(), None, vec!["*".into()], false);
|
||||||
let post = json!({
|
let post = json!({
|
||||||
"id": "post123",
|
"id": "post123",
|
||||||
"user_id": "bot123",
|
"user_id": "bot123",
|
||||||
|
|
@ -299,7 +385,8 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mattermost_parse_post_ignore_old() {
|
fn mattermost_parse_post_ignore_old() {
|
||||||
let ch = MattermostChannel::new("url".into(), "token".into(), None, vec!["*".into()]);
|
let ch =
|
||||||
|
MattermostChannel::new("url".into(), "token".into(), None, vec!["*".into()], false);
|
||||||
let post = json!({
|
let post = json!({
|
||||||
"id": "post123",
|
"id": "post123",
|
||||||
"user_id": "user456",
|
"user_id": "user456",
|
||||||
|
|
@ -310,4 +397,41 @@ mod tests {
|
||||||
let msg = ch.parse_mattermost_post(&post, "bot123", 1_500_000_000_000_i64, "chan789");
|
let msg = ch.parse_mattermost_post(&post, "bot123", 1_500_000_000_000_i64, "chan789");
|
||||||
assert!(msg.is_none());
|
assert!(msg.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mattermost_parse_post_no_thread_when_disabled() {
|
||||||
|
let ch =
|
||||||
|
MattermostChannel::new("url".into(), "token".into(), None, vec!["*".into()], false);
|
||||||
|
let post = json!({
|
||||||
|
"id": "post123",
|
||||||
|
"user_id": "user456",
|
||||||
|
"message": "hello world",
|
||||||
|
"create_at": 1_600_000_000_000_i64,
|
||||||
|
"root_id": ""
|
||||||
|
});
|
||||||
|
|
||||||
|
let msg = ch
|
||||||
|
.parse_mattermost_post(&post, "bot123", 1_500_000_000_000_i64, "chan789")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(msg.reply_target, "chan789"); // No thread suffix
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mattermost_existing_thread_always_threads() {
|
||||||
|
// Even with thread_replies=false, replies to existing threads stay in the thread
|
||||||
|
let ch =
|
||||||
|
MattermostChannel::new("url".into(), "token".into(), None, vec!["*".into()], false);
|
||||||
|
let post = json!({
|
||||||
|
"id": "post123",
|
||||||
|
"user_id": "user456",
|
||||||
|
"message": "reply in thread",
|
||||||
|
"create_at": 1_600_000_000_000_i64,
|
||||||
|
"root_id": "root789"
|
||||||
|
});
|
||||||
|
|
||||||
|
let msg = ch
|
||||||
|
.parse_mattermost_post(&post, "bot123", 1_500_000_000_000_i64, "chan789")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(msg.reply_target, "chan789:root789"); // Stays in existing thread
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1261,6 +1261,7 @@ pub async fn start_channels(config: Config) -> Result<()> {
|
||||||
mm.bot_token.clone(),
|
mm.bot_token.clone(),
|
||||||
mm.channel_id.clone(),
|
mm.channel_id.clone(),
|
||||||
mm.allowed_users.clone(),
|
mm.allowed_users.clone(),
|
||||||
|
mm.thread_replies.unwrap_or(false),
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1499,6 +1499,10 @@ pub struct MattermostConfig {
|
||||||
pub channel_id: Option<String>,
|
pub channel_id: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub allowed_users: Vec<String>,
|
pub allowed_users: Vec<String>,
|
||||||
|
/// When true, replies thread on the original post. When false (default),
|
||||||
|
/// replies go to the channel root.
|
||||||
|
#[serde(default)]
|
||||||
|
pub thread_replies: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|
|
||||||
|
|
@ -300,6 +300,7 @@ async fn deliver_if_configured(config: &Config, job: &CronJob, output: &str) ->
|
||||||
mm.bot_token.clone(),
|
mm.bot_token.clone(),
|
||||||
mm.channel_id.clone(),
|
mm.channel_id.clone(),
|
||||||
mm.allowed_users.clone(),
|
mm.allowed_users.clone(),
|
||||||
|
mm.thread_replies.unwrap_or(false),
|
||||||
);
|
);
|
||||||
channel.send(&SendMessage::new(output, target)).await?;
|
channel.send(&SendMessage::new(output, target)).await?;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue