Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion crates/aionui-app/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@ name = "aioncore"
path = "src/main.rs"

[features]
default = ["telegram", "lark", "dingtalk", "weixin"]
default = ["telegram", "lark", "dingtalk", "weixin", "slack"]
telegram = ["aionui-channel/telegram"]
lark = ["aionui-channel/lark"]
dingtalk = ["aionui-channel/dingtalk"]
weixin = ["aionui-channel/weixin"]
slack = ["aionui-channel/slack"]

[dependencies]
aionui-common.workspace = true
Expand Down
1 change: 1 addition & 0 deletions crates/aionui-channel/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ telegram = ["dep:reqwest"]
lark = ["dep:reqwest", "dep:tokio-tungstenite", "dep:futures-util", "dep:prost", "dep:rustls", "dep:rustls-native-certs"]
dingtalk = ["dep:reqwest", "dep:tokio-tungstenite", "dep:futures-util", "dep:rustls", "dep:rustls-native-certs"]
weixin = ["dep:reqwest", "dep:futures-util", "dep:base64", "dep:uuid"]
slack = ["dep:reqwest", "dep:tokio-tungstenite", "dep:futures-util", "dep:rustls", "dep:rustls-native-certs"]

[dependencies]
aionui-common.workspace = true
Expand Down
13 changes: 13 additions & 0 deletions crates/aionui-channel/src/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ pub const LARK_MESSAGE_LIMIT: usize = 4000;
/// Maximum characters per DingTalk message.
pub const DINGTALK_MESSAGE_LIMIT: usize = 4000;

/// Maximum characters per Slack message (practical chunk; API allows more).
pub const SLACK_MESSAGE_LIMIT: usize = 4000;

// ---------------------------------------------------------------------------
// Reconnection (Telegram long-polling)
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -64,6 +67,16 @@ pub const DINGTALK_MAX_RECONNECT_ATTEMPTS: u32 = 10;
/// Maximum delay between DingTalk reconnection attempts (exponential backoff cap).
pub const DINGTALK_MAX_RECONNECT_DELAY: Duration = Duration::from_secs(30);

// ---------------------------------------------------------------------------
// Slack
// ---------------------------------------------------------------------------

/// Maximum reconnection attempts for Slack Socket Mode.
pub const SLACK_MAX_RECONNECT_ATTEMPTS: u32 = 10;

/// Maximum delay between Slack Socket Mode reconnection attempts.
pub const SLACK_MAX_RECONNECT_DELAY: Duration = Duration::from_secs(30);

/// DingTalk access token TTL refresh margin (5 minutes before expiry).
/// Used by `DingtalkApi` for proactive token refresh.
#[allow(dead_code)]
Expand Down
97 changes: 97 additions & 0 deletions crates/aionui-channel/src/formatter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ use crate::types::PluginType;
///
/// - Telegram: escape HTML, then convert markdown → HTML tags
/// - Lark/DingTalk: convert HTML tags → markdown
/// - Slack: convert common markdown → Slack `mrkdwn`
/// - WeChat/WeCom: strip all HTML
/// - Fallback: escape HTML special chars
pub fn format_text_for_platform(text: &str, platform: PluginType) -> String {
match platform {
PluginType::Telegram => markdown_to_telegram_html(text),
PluginType::Lark | PluginType::Dingtalk => html_to_markdown(text),
PluginType::Slack => markdown_to_slack_mrkdwn(text),
PluginType::Weixin => strip_html(text),
_ => escape_html(text),
}
Expand Down Expand Up @@ -65,6 +67,101 @@ fn html_to_markdown(text: &str) -> String {
strip_tags_loop(s.as_ref())
}

// ── Slack mrkdwn ─────────────────────────────────────────────────
//
// Slack does not render standard Markdown. With `mrkdwn: true` it expects:
// *bold* _italic_ ~strike~ `code` ```blocks```
// <url|label> links
// Headers (##) are not supported — convert to bold lines.

static RE_SLACK_HEADER: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?m)^(#{1,6})\s+(.+)$").unwrap());
static RE_SLACK_STRIKE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"~~(.+?)~~").unwrap());
static RE_SLACK_BOLD_STAR: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\*\*(.+?)\*\*").unwrap());
static RE_SLACK_BOLD_UNDER: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"__(.+?)__").unwrap());
// Single-asterisk italic only when not already Slack bold (*text*).
static RE_SLACK_ITALIC_STAR: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?P<pre>^|[^*])\*(?P<body>[^*\n]+?)\*(?P<post>$|[^*])").unwrap());

/// Convert common Markdown to Slack mrkdwn.
fn markdown_to_slack_mrkdwn(text: &str) -> String {
// Protect fenced/inline code so formatting inside them is left alone.
let mut blocks: Vec<String> = Vec::new();
let s = RE_CODE_BLOCK.replace_all(text, |caps: &regex::Captures| {
let idx = blocks.len();
blocks.push(format!("```{}```", &caps[1]));
format!("\u{E000}BLOCK{idx}\u{E001}")
});
let mut inlines: Vec<String> = Vec::new();
let s = RE_INLINE_CODE.replace_all(&s, |caps: &regex::Captures| {
let idx = inlines.len();
inlines.push(format!("`{}`", &caps[1]));
format!("\u{E000}CODE{idx}\u{E001}")
});

// Links before other markup so brackets don't get mangled.
let s = RE_LINK.replace_all(&s, "<$2|$1>");

// Headers → bold (protect placeholders so italic pass won't rewrite them).
let mut bolds: Vec<String> = Vec::new();
let s = RE_SLACK_HEADER.replace_all(&s, |caps: &regex::Captures| {
let idx = bolds.len();
bolds.push(caps[2].to_owned());
format!("\u{E000}BOLD{idx}\u{E001}")
});

// Strikethrough ~~x~~ → ~x~
let s = RE_SLACK_STRIKE.replace_all(&s, "~$1~");

// Bold **x** / __x__ → placeholders (Slack bold is *x*, applied on restore)
let s = RE_SLACK_BOLD_STAR.replace_all(&s, |caps: &regex::Captures| {
let idx = bolds.len();
bolds.push(caps[1].to_owned());
format!("\u{E000}BOLD{idx}\u{E001}")
});
let s = RE_SLACK_BOLD_UNDER.replace_all(&s, |caps: &regex::Captures| {
let idx = bolds.len();
bolds.push(caps[1].to_owned());
format!("\u{E000}BOLD{idx}\u{E001}")
});

// Italic *x* (remaining singles) → _x_
let s = RE_SLACK_ITALIC_STAR.replace_all(&s, "${pre}_${body}_${post}");

// Materialize bold as Slack *text*
let mut s = s.into_owned();
for (i, body) in bolds.iter().enumerate() {
s = s.replace(&format!("\u{E000}BOLD{i}\u{E001}"), &format!("*{body}*"));
}
let s = s;

// Escape & < > that are not already part of <url|label> or placeholders.
// We escape ampersands first, then leave our intentional <url|label> alone by
// temporarily protecting them.
let mut links: Vec<String> = Vec::new();
let s = {
static RE_SLACK_LINK: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"<(https?://[^|>]+)\|([^>]+)>").unwrap());
RE_SLACK_LINK.replace_all(&s, |caps: &regex::Captures| {
let idx = links.len();
links.push(caps[0].to_owned());
format!("\u{E000}LINK{idx}\u{E001}")
})
};
let s = s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;");

// Restore protected segments (reverse order of replacement).
let mut out = s;
for (i, link) in links.iter().enumerate() {
out = out.replace(&format!("\u{E000}LINK{i}\u{E001}"), link);
}
for (i, code) in inlines.iter().enumerate() {
out = out.replace(&format!("\u{E000}CODE{i}\u{E001}"), code);
}
for (i, block) in blocks.iter().enumerate() {
out = out.replace(&format!("\u{E000}BLOCK{i}\u{E001}"), block);
}
out
}

// ── WeChat ───────────────────────────────────────────────────────

fn strip_html(text: &str) -> String {
Expand Down
1 change: 1 addition & 0 deletions crates/aionui-channel/src/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1013,6 +1013,7 @@ mod tests {
client_secret: None,
account_id: None,
bot_token: None,
app_token: None,
extra: HashMap::new(),
},
config: None,
Expand Down
55 changes: 49 additions & 6 deletions crates/aionui-channel/src/message_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -365,8 +365,9 @@ fn platform_to_source(platform: PluginType) -> ConversationSource {
PluginType::Lark => ConversationSource::Lark,
PluginType::Dingtalk => ConversationSource::Dingtalk,
PluginType::Weixin => ConversationSource::Weixin,
// Reserved variants default to Aionui
PluginType::Slack | PluginType::Discord => ConversationSource::Aionui,
PluginType::Slack => ConversationSource::Slack,
// Discord keeps the generic source until a dedicated channel ships.
PluginType::Discord => ConversationSource::Aionui,
}
}

Expand Down Expand Up @@ -419,12 +420,35 @@ fn channel_conversation_name(
parts.push(b.to_owned());
}
if let Some(cid) = chat_id {
let end = cid.len().min(8);
parts.push(cid[..end].to_owned());
parts.push(chat_id_name_suffix(cid));
}
parts.join("-")
}

/// Short, display-friendly slug from a session `chat_id`.
///
/// Plain ids (Telegram, etc.) keep the historical first-8 truncation.
/// Composite keys like Slack `{channel}:{thread_root}` also include a
/// thread suffix so two threads in the same channel do not share the same
/// conversation title in the sidebar.
fn chat_id_name_suffix(chat_id: &str) -> String {
match chat_id.split_once(':') {
Some((channel, thread)) if !channel.is_empty() && !thread.is_empty() => {
let channel_part: String = channel.chars().take(8).collect();
// Slack thread ts is e.g. "1712345678.123456" — keep alphanumerics only
// and take the last 8 so nearby threads still differ.
let thread_digits: String = thread.chars().filter(|c| c.is_ascii_alphanumeric()).collect();
let thread_part = if thread_digits.len() <= 8 {
thread_digits
} else {
thread_digits[thread_digits.len() - 8..].to_owned()
};
format!("{channel_part}-{thread_part}")
}
_ => chat_id.chars().take(8).collect(),
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -457,8 +481,12 @@ mod tests {
}

#[test]
fn platform_to_source_reserved_defaults_to_aionui() {
assert_eq!(platform_to_source(PluginType::Slack), ConversationSource::Aionui);
fn platform_to_source_slack() {
assert_eq!(platform_to_source(PluginType::Slack), ConversationSource::Slack);
}

#[test]
fn platform_to_source_discord_defaults_to_aionui() {
assert_eq!(platform_to_source(PluginType::Discord), ConversationSource::Aionui);
}

Expand Down Expand Up @@ -706,4 +734,19 @@ mod tests {
let name = channel_conversation_name(PluginType::Telegram, "aionrs", Some("claude"), Some("70880480"));
assert_eq!(name, "tg-aionrs-70880480");
}

#[test]
fn conv_name_slack_composite_chat_id_includes_thread() {
let a = channel_conversation_name(PluginType::Slack, "aionrs", None, Some("D0BKLLCM:1710000000.000100"));
let b = channel_conversation_name(PluginType::Slack, "aionrs", None, Some("D0BKLLCM:1710000000.000200"));
assert_eq!(a, "slack-aionrs-D0BKLLCM-00000100");
assert_eq!(b, "slack-aionrs-D0BKLLCM-00000200");
assert_ne!(a, b);
}

#[test]
fn chat_id_suffix_plain_still_truncates_to_eight() {
assert_eq!(chat_id_name_suffix("123456789abcdef"), "12345678");
assert_eq!(chat_id_name_suffix("70880480"), "70880480");
}
}
1 change: 1 addition & 0 deletions crates/aionui-channel/src/plugin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ mod tests {
client_secret: None,
account_id: None,
bot_token: None,
app_token: None,
extra: HashMap::new(),
},
config: None,
Expand Down
6 changes: 6 additions & 0 deletions crates/aionui-channel/src/plugins/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ pub mod dingtalk;
#[cfg(feature = "weixin")]
pub mod weixin;

#[cfg(feature = "slack")]
pub mod slack;

use crate::plugin::ChannelPlugin;
use crate::types::PluginType;

Expand All @@ -30,6 +33,9 @@ pub fn create_plugin(plugin_type: PluginType) -> Option<Box<dyn ChannelPlugin>>
#[cfg(feature = "weixin")]
PluginType::Weixin => Some(Box::new(weixin::WeixinPlugin::new())),

#[cfg(feature = "slack")]
PluginType::Slack => Some(Box::new(slack::SlackPlugin::new())),

#[allow(unreachable_patterns)]
_ => None,
}
Expand Down
Loading