use serde_json::Value;
use tokio::sync::mpsc;
use crate::channels::{MessagingSubscription, MessagingSubscriptionError, MessagingTask};
use crate::messages::ListenerType;
use crate::types::NormalizedMessage;
struct TelegramFilter {
subscribed_channels: Option<Vec<String>>,
allowed_senders: Vec<String>,
}
impl TelegramFilter {
fn new(subscribed_channels: Option<Vec<String>>, allowed_senders: Vec<String>) -> Self {
Self {
subscribed_channels,
allowed_senders,
}
}
fn apply(&self, message: NormalizedMessage) -> Option<NormalizedMessage> {
let metadata = &message.metadata;
let channel = metadata.get("chat_id").and_then(Value::as_str);
let sender_id = metadata.get("sender_user_id").and_then(Value::as_str);
let username = metadata.get("sender_username").and_then(Value::as_str);
if !self
.subscribed_channels
.as_ref()
.is_none_or(|channels| channel.is_some_and(|channel| channels.iter().any(|selected| selected == channel)))
|| (!self.allowed_senders.is_empty()
&& !self.allowed_senders.iter().any(|allowed| {
sender_id == Some(allowed.as_str())
|| username.is_some_and(|username| {
allowed
.trim_start_matches('@')
.eq_ignore_ascii_case(username)
})
}))
{
return None;
}
Some(message)
}
}
pub(super) fn subscribe(
mut subscription: MessagingSubscription,
mut rx: mpsc::Receiver<NormalizedMessage>,
tx: mpsc::Sender<NormalizedMessage>,
) -> Result<MessagingTask, MessagingSubscriptionError> {
subscription.validate(ListenerType::Telegram)?;
if let Some(channels) = &mut subscription.subscribed_channels {
for channel in channels {
*channel = channel
.parse::<i64>()
.map_err(|_| MessagingSubscriptionError::Configuration {
listener_type: ListenerType::Telegram,
message: "Telegram channel IDs must be integers".to_owned(),
})?
.to_string();
}
}
let policy = TelegramFilter::new(subscription.subscribed_channels, subscription.allowed_senders);
Ok(tokio::spawn(async move {
while let Some(message) = tokio::select! { _ = tx.closed() => None, message = rx.recv() => message } {
if let Some(accepted) = policy.apply(message)
&& tx.send(accepted).await.is_err()
{
break;
}
}
Ok(())
}))
}
#[cfg(test)]
mod tests {
use teloxide_core::types::Update;
use super::super::helpers::extract_body;
use super::*;
fn normalize_update(update: &Update, chat_id: i64, _bot: &str, _poll: u64) -> Option<NormalizedMessage> {
let message = super::super::helpers::normalize_update(update)?;
TelegramFilter::new(Some(vec![chat_id.to_string()]), Vec::new()).apply(message)
}
fn make_text_update(update_id: i32, chat_id: i64, text: &str) -> teloxide_core::types::Update {
let json = serde_json::json!({
"update_id": update_id,
"message": {
"message_id": 1,
"date": 1_234_567_890_i64,
"chat": { "id": chat_id, "type": "private" },
"from": { "id": 100, "is_bot": false, "first_name": "Test" },
"text": text
}
});
serde_json::from_str(&json.to_string()).expect("valid update JSON")
}
fn make_text_update_wrong_chat(update_id: i32, text: &str) -> teloxide_core::types::Update {
make_text_update(update_id, 99999, text)
}
#[test]
fn trigger_policy_enforces_sender_acl_before_the_workstream() {
let update = make_text_update(1, 12345, "message");
assert!(
TelegramFilter::new(Some(vec!["12345".to_owned()]), Vec::new())
.apply(super::super::helpers::normalize_update(&update).unwrap())
.is_some()
);
assert!(
TelegramFilter::new(Some(vec!["12345".to_owned()]), vec!["100".to_owned()])
.apply(super::super::helpers::normalize_update(&update).unwrap())
.is_some()
);
assert!(
TelegramFilter::new(Some(vec!["12345".to_owned()]), vec!["999".to_owned()])
.apply(super::super::helpers::normalize_update(&update).unwrap())
.is_none()
);
assert!(
TelegramFilter::new(Some(vec!["999".to_owned()]), vec!["100".to_owned()])
.apply(super::super::helpers::normalize_update(&update).unwrap())
.is_none()
);
}
#[test]
fn sender_acl_rejects_an_update_without_sender_identity() {
let mut update = make_text_update(1, 12345, "message");
if let teloxide_core::types::UpdateKind::Message(message) = &mut update.kind {
message.from = None;
}
assert!(
TelegramFilter::new(Some(vec!["12345".to_owned()]), vec!["100".to_owned()])
.apply(super::super::helpers::normalize_update(&update).unwrap())
.is_none()
);
}
fn make_photo_update_with_caption(update_id: i32, chat_id: i64, caption: &str) -> teloxide_core::types::Update {
let json = serde_json::json!({
"update_id": update_id,
"message": {
"message_id": 2,
"date": 1_234_567_890_i64,
"chat": { "id": chat_id, "type": "private" },
"from": { "id": 100, "is_bot": false, "first_name": "Test" },
"photo": [
{ "file_id": "abc", "file_unique_id": "u1", "width": 100, "height": 100, "file_size": 1024 }
],
"caption": caption
}
});
serde_json::from_str(&json.to_string()).expect("valid photo update JSON")
}
fn make_sticker_update(update_id: i32, chat_id: i64) -> teloxide_core::types::Update {
let json = serde_json::json!({
"update_id": update_id,
"message": {
"message_id": 3,
"date": 1_234_567_890_i64,
"chat": { "id": chat_id, "type": "private" },
"from": { "id": 100, "is_bot": false, "first_name": "Test" },
"sticker": {
"file_id": "stk1",
"file_unique_id": "u2",
"width": 512,
"height": 512,
"is_animated": false,
"is_video": false,
"type": "regular"
}
}
});
serde_json::from_str(&json.to_string()).expect("valid sticker update JSON")
}
fn make_channel_post_update(update_id: i32, chat_id: i64, text: &str) -> teloxide_core::types::Update {
let json = serde_json::json!({
"update_id": update_id,
"channel_post": {
"message_id": 4,
"date": 1_234_567_890_i64,
"chat": { "id": chat_id, "type": "channel" },
"text": text
}
});
serde_json::from_str(&json.to_string()).expect("valid channel_post update JSON")
}
fn make_video_update_with_caption(update_id: i32, chat_id: i64, caption: &str) -> teloxide_core::types::Update {
let json = serde_json::json!({
"update_id": update_id,
"message": {
"message_id": 20,
"date": 1_234_567_890_i64,
"chat": { "id": chat_id, "type": "private" },
"from": { "id": 100, "is_bot": false, "first_name": "Test" },
"video": {
"file_id": "vid1",
"file_unique_id": "uv1",
"width": 1280,
"height": 720,
"duration": 10,
"mime_type": "video/mp4"
},
"caption": caption
}
});
serde_json::from_str(&json.to_string()).expect("valid video update JSON")
}
fn make_video_update_no_caption(update_id: i32, chat_id: i64) -> teloxide_core::types::Update {
let json = serde_json::json!({
"update_id": update_id,
"message": {
"message_id": 21,
"date": 1_234_567_890_i64,
"chat": { "id": chat_id, "type": "private" },
"from": { "id": 100, "is_bot": false, "first_name": "Test" },
"video": {
"file_id": "vid2",
"file_unique_id": "uv2",
"width": 1280,
"height": 720,
"duration": 5,
"mime_type": "video/mp4"
}
}
});
serde_json::from_str(&json.to_string()).expect("valid video-no-caption update JSON")
}
fn make_voice_update_with_caption(update_id: i32, chat_id: i64, caption: &str) -> teloxide_core::types::Update {
let json = serde_json::json!({
"update_id": update_id,
"message": {
"message_id": 30,
"date": 1_234_567_890_i64,
"chat": { "id": chat_id, "type": "private" },
"from": { "id": 100, "is_bot": false, "first_name": "Test" },
"voice": {
"file_id": "voc1",
"file_unique_id": "uvc1",
"duration": 3,
"mime_type": "audio/ogg"
},
"caption": caption
}
});
serde_json::from_str(&json.to_string()).expect("valid voice update JSON")
}
fn make_voice_update_no_caption(update_id: i32, chat_id: i64) -> teloxide_core::types::Update {
let json = serde_json::json!({
"update_id": update_id,
"message": {
"message_id": 31,
"date": 1_234_567_890_i64,
"chat": { "id": chat_id, "type": "private" },
"from": { "id": 100, "is_bot": false, "first_name": "Test" },
"voice": {
"file_id": "voc2",
"file_unique_id": "uvc2",
"duration": 2,
"mime_type": "audio/ogg"
}
}
});
serde_json::from_str(&json.to_string()).expect("valid voice-no-caption update JSON")
}
fn make_audio_update_with_caption(update_id: i32, chat_id: i64, caption: &str) -> teloxide_core::types::Update {
let json = serde_json::json!({
"update_id": update_id,
"message": {
"message_id": 40,
"date": 1_234_567_890_i64,
"chat": { "id": chat_id, "type": "private" },
"from": { "id": 100, "is_bot": false, "first_name": "Test" },
"audio": {
"file_id": "aud1",
"file_unique_id": "ua1",
"duration": 120,
"mime_type": "audio/mpeg"
},
"caption": caption
}
});
serde_json::from_str(&json.to_string()).expect("valid audio update JSON")
}
fn make_audio_update_no_caption(update_id: i32, chat_id: i64) -> teloxide_core::types::Update {
let json = serde_json::json!({
"update_id": update_id,
"message": {
"message_id": 41,
"date": 1_234_567_890_i64,
"chat": { "id": chat_id, "type": "private" },
"from": { "id": 100, "is_bot": false, "first_name": "Test" },
"audio": {
"file_id": "aud2",
"file_unique_id": "ua2",
"duration": 60,
"mime_type": "audio/mpeg"
}
}
});
serde_json::from_str(&json.to_string()).expect("valid audio-no-caption update JSON")
}
fn make_animation_update_with_caption(update_id: i32, chat_id: i64, caption: &str) -> teloxide_core::types::Update {
let json = serde_json::json!({
"update_id": update_id,
"message": {
"message_id": 50,
"date": 1_234_567_890_i64,
"chat": { "id": chat_id, "type": "private" },
"from": { "id": 100, "is_bot": false, "first_name": "Test" },
"animation": {
"file_id": "anim1",
"file_unique_id": "uan1",
"width": 320,
"height": 240,
"duration": 4,
"mime_type": "video/mp4"
},
"document": {
"file_id": "anim1",
"file_unique_id": "uan1",
"mime_type": "video/mp4"
},
"caption": caption
}
});
serde_json::from_str(&json.to_string()).expect("valid animation update JSON")
}
fn make_animation_update_no_caption(update_id: i32, chat_id: i64) -> teloxide_core::types::Update {
let json = serde_json::json!({
"update_id": update_id,
"message": {
"message_id": 51,
"date": 1_234_567_890_i64,
"chat": { "id": chat_id, "type": "private" },
"from": { "id": 100, "is_bot": false, "first_name": "Test" },
"animation": {
"file_id": "anim2",
"file_unique_id": "uan2",
"width": 320,
"height": 240,
"duration": 2,
"mime_type": "video/mp4"
},
"document": {
"file_id": "anim2",
"file_unique_id": "uan2",
"mime_type": "video/mp4"
}
}
});
serde_json::from_str(&json.to_string()).expect("valid animation-no-caption update JSON")
}
const EXPECTED_CHAT_ID: i64 = 12345;
const BOT_ID: &str = "my_bot";
const POLL_INTERVAL: u64 = 30;
#[test]
fn normalize_text_message_returns_body() {
let update = make_text_update(1, EXPECTED_CHAT_ID, "hello world");
let result = normalize_update(&update, EXPECTED_CHAT_ID, BOT_ID, POLL_INTERVAL);
assert!(result.is_some(), "expected Some for matching chat text message");
let msg = result.unwrap();
assert_eq!(msg.body, "hello world");
assert_eq!(msg.metadata["channel"], "telegram");
}
#[test]
fn normalize_wrong_chat_drops_message() {
let update = make_text_update_wrong_chat(2, "should be dropped");
let result = normalize_update(&update, EXPECTED_CHAT_ID, BOT_ID, POLL_INTERVAL);
assert!(result.is_none(), "expected None for wrong chat ID");
}
#[test]
fn normalize_photo_with_caption_returns_caption() {
let update = make_photo_update_with_caption(3, EXPECTED_CHAT_ID, "photo caption");
let result = normalize_update(&update, EXPECTED_CHAT_ID, BOT_ID, POLL_INTERVAL);
assert!(result.is_some(), "expected Some for photo with caption");
assert_eq!(result.unwrap().body, "photo caption");
}
#[test]
fn normalize_sticker_drops_attachment_only() {
let update = make_sticker_update(4, EXPECTED_CHAT_ID);
let result = normalize_update(&update, EXPECTED_CHAT_ID, BOT_ID, POLL_INTERVAL);
assert!(result.is_none(), "expected None for attachment-only sticker");
}
#[test]
fn normalize_channel_post_returns_body() {
let update = make_channel_post_update(5, EXPECTED_CHAT_ID, "channel text");
let result = normalize_update(&update, EXPECTED_CHAT_ID, BOT_ID, POLL_INTERVAL);
assert!(result.is_some(), "expected Some for channel_post");
assert_eq!(result.unwrap().body, "channel text");
}
#[test]
fn normalize_empty_text_drops_message() {
let update = make_text_update(6, EXPECTED_CHAT_ID, "");
let result = normalize_update(&update, EXPECTED_CHAT_ID, BOT_ID, POLL_INTERVAL);
assert!(result.is_none(), "expected None for empty text body");
}
#[test]
fn extract_body_text_message() {
let update = make_text_update(10, EXPECTED_CHAT_ID, "test body");
if let teloxide_core::types::UpdateKind::Message(msg) = &update.kind {
let body = extract_body(msg);
assert_eq!(body, Some("test body".to_string()));
} else {
panic!("expected Message update kind");
}
}
#[test]
fn extract_body_photo_with_caption() {
let update = make_photo_update_with_caption(11, EXPECTED_CHAT_ID, "my caption");
if let teloxide_core::types::UpdateKind::Message(msg) = &update.kind {
let body = extract_body(msg);
assert_eq!(body, Some("my caption".to_string()));
} else {
panic!("expected Message update kind");
}
}
#[test]
fn extract_body_sticker_returns_none() {
let update = make_sticker_update(12, EXPECTED_CHAT_ID);
if let teloxide_core::types::UpdateKind::Message(msg) = &update.kind {
let body = extract_body(msg);
assert!(body.is_none(), "sticker should yield no body");
} else {
panic!("expected Message update kind");
}
}
#[test]
fn extract_body_video_with_caption_returns_caption() {
let update = make_video_update_with_caption(20, EXPECTED_CHAT_ID, "video caption");
if let teloxide_core::types::UpdateKind::Message(msg) = &update.kind {
let body = extract_body(msg);
assert_eq!(
body,
Some("video caption".to_string()),
"video with caption should return caption"
);
} else {
panic!("expected Message update kind");
}
}
#[test]
fn extract_body_video_without_caption_returns_none() {
let update = make_video_update_no_caption(21, EXPECTED_CHAT_ID);
if let teloxide_core::types::UpdateKind::Message(msg) = &update.kind {
let body = extract_body(msg);
assert!(body.is_none(), "video without caption should yield no body");
} else {
panic!("expected Message update kind");
}
}
#[test]
fn extract_body_voice_with_caption_returns_caption() {
let update = make_voice_update_with_caption(30, EXPECTED_CHAT_ID, "voice caption");
if let teloxide_core::types::UpdateKind::Message(msg) = &update.kind {
let body = extract_body(msg);
assert_eq!(
body,
Some("voice caption".to_string()),
"voice with caption should return caption"
);
} else {
panic!("expected Message update kind");
}
}
#[test]
fn extract_body_voice_without_caption_returns_none() {
let update = make_voice_update_no_caption(31, EXPECTED_CHAT_ID);
if let teloxide_core::types::UpdateKind::Message(msg) = &update.kind {
let body = extract_body(msg);
assert!(body.is_none(), "voice without caption should yield no body");
} else {
panic!("expected Message update kind");
}
}
#[test]
fn extract_body_audio_with_caption_returns_caption() {
let update = make_audio_update_with_caption(40, EXPECTED_CHAT_ID, "audio caption");
if let teloxide_core::types::UpdateKind::Message(msg) = &update.kind {
let body = extract_body(msg);
assert_eq!(
body,
Some("audio caption".to_string()),
"audio with caption should return caption"
);
} else {
panic!("expected Message update kind");
}
}
#[test]
fn extract_body_audio_without_caption_returns_none() {
let update = make_audio_update_no_caption(41, EXPECTED_CHAT_ID);
if let teloxide_core::types::UpdateKind::Message(msg) = &update.kind {
let body = extract_body(msg);
assert!(body.is_none(), "audio without caption should yield no body");
} else {
panic!("expected Message update kind");
}
}
#[test]
fn extract_body_animation_with_caption_returns_caption() {
let update = make_animation_update_with_caption(50, EXPECTED_CHAT_ID, "animation caption");
if let teloxide_core::types::UpdateKind::Message(msg) = &update.kind {
let body = extract_body(msg);
assert_eq!(
body,
Some("animation caption".to_string()),
"animation with caption should return caption"
);
} else {
panic!("expected Message update kind");
}
}
#[test]
fn extract_body_animation_without_caption_returns_none() {
let update = make_animation_update_no_caption(51, EXPECTED_CHAT_ID);
if let teloxide_core::types::UpdateKind::Message(msg) = &update.kind {
let body = extract_body(msg);
assert!(body.is_none(), "animation without caption should yield no body");
} else {
panic!("expected Message update kind");
}
}
}