Skip to content
Merged
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
2 changes: 0 additions & 2 deletions src/channel/slack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ use std::sync::Arc;

use anyhow::{bail, Context, Result};
use async_trait::async_trait;
use chrono::Utc;
use futures_util::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize};
use tokio::sync::{mpsc, RwLock};
Expand Down Expand Up @@ -291,7 +290,6 @@ impl ChannelProvider for SlackProvider {
chat_id,
user_id,
text,
timestamp: Utc::now(),
context: MessageContext {
workspace: Arc::clone(&self.workspace),
provider: Arc::clone(&self_arc),
Expand Down
27 changes: 13 additions & 14 deletions src/channel/telegram.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ use std::sync::Arc;

use anyhow::{Context, Result};
use async_trait::async_trait;
use chrono::Utc;
use teloxide::prelude::*;
use teloxide::types::InputFile;
use tokio::sync::mpsc;
Expand Down Expand Up @@ -71,15 +70,21 @@ impl ChannelProvider for TelegramProvider {
return Ok(());
};

let user_id = match &msg.from {
Some(u) => u.id.0.to_string(),
let user = match &msg.from {
Some(u) => u,
None => {
tracing::trace!("telegram message with no sender — dropped");
return Ok(());
}
};
let user_id = user.id.0.to_string();

if !gate.is_allowed(&user_id) {
let username_allowed = user
.username
.as_deref()
.is_some_and(|name| gate.is_allowed(&name.to_lowercase()));

if !username_allowed && !gate.is_allowed(&user_id) {
tracing::trace!(user_id, "unauthorized telegram message — dropped");
return Ok(());
}
Expand All @@ -93,7 +98,6 @@ impl ChannelProvider for TelegramProvider {
chat_id,
user_id,
text: text.to_string(),
timestamp: Utc::now(),
context: MessageContext {
workspace: Arc::clone(&workspace),
provider,
Expand Down Expand Up @@ -154,15 +158,10 @@ impl ChannelProvider for TelegramProvider {
resolved.insert(id.to_string());
}
AllowedUser::Handle(handle) => {
// The Telegram Bot API does not expose a username→ID lookup endpoint.
// We store the handle as-is and warn the operator. Numeric IDs are
// preferred for reliable allow-listing.
tracing::warn!(
handle,
"Telegram username resolution via Bot API is not supported without \
prior interaction. Storing handle as-is; numeric IDs are more reliable."
);
resolved.insert(handle.clone());
// Normalize: strip leading '@' and lowercase so the gate can match
// against the username field on incoming messages (which has no '@').
let normalized = handle.strip_prefix('@').unwrap_or(handle).to_lowercase();
resolved.insert(normalized);
}
}
}
Expand Down
2 changes: 0 additions & 2 deletions src/channel/whatsapp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::routing::{get, post};
use axum::Json;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use tokio::sync::mpsc;
use tokio::sync::RwLock;
Expand Down Expand Up @@ -187,7 +186,6 @@ async fn handle_inbound(
chat_id,
user_id,
text: text_obj.body,
timestamp: Utc::now(),
context: MessageContext {
workspace: Arc::clone(&state.workspace),
provider: Arc::clone(&state.provider),
Expand Down
9 changes: 0 additions & 9 deletions src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,6 @@ impl SessionStore {
}
}

/// Returns `true` if the conversation has an active session (should pass `--continue`).
#[allow(dead_code)] // Phase 1 scaffolding — used in tests and future pipeline stages
pub fn should_continue(&self, chat_id: &ChatId) -> bool {
self.sessions
.get(chat_id)
.map(|s| s.is_active)
.unwrap_or(false)
}

/// Mark a session active after a successful prompt execution.
pub fn mark_active(&mut self, chat_id: &ChatId) {
let state = self.sessions.entry(chat_id.clone()).or_default();
Expand Down
55 changes: 55 additions & 0 deletions src/tests/channel/telegram_test.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,59 @@
use super::*;
use crate::channel::ChannelProvider;
use crate::config::{ChunkStrategy, OutputConfig};
use crate::security::SecurityGate;
use crate::types::{AllowedUser, WorkspaceHandle};
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::RwLock;

fn make_provider() -> TelegramProvider {
let workspace = Arc::new(RwLock::new(WorkspaceHandle {
name: "test".to_string(),
directory: PathBuf::from("/tmp"),
backend: "claude-cli".to_string(),
timeout: None,
}));
let output_config = Arc::new(OutputConfig {
max_message_chars: 4000,
file_upload_threshold_bytes: 51200,
chunk_strategy: ChunkStrategy::Natural,
});
TelegramProvider::new(
"fake-token".to_string(),
SecurityGate::new(HashSet::new()),
workspace,
output_config,
)
}

#[tokio::test]
async fn resolve_users_normalizes_handle_with_at() {
// "@MyUser" should be stored as "myuser" — no '@', lowercased.
let provider = make_provider();
let users = vec![AllowedUser::Handle("@MyUser".to_string())];
let resolved = provider.resolve_users(&users).await.unwrap();
assert!(resolved.contains("myuser"));
assert!(!resolved.contains("@MyUser"));
assert!(!resolved.contains("@myuser"));
}

#[tokio::test]
async fn resolve_users_normalizes_handle_without_at() {
let provider = make_provider();
let users = vec![AllowedUser::Handle("SomeUser".to_string())];
let resolved = provider.resolve_users(&users).await.unwrap();
assert!(resolved.contains("someuser"));
}

#[tokio::test]
async fn resolve_users_keeps_numeric_id() {
let provider = make_provider();
let users = vec![AllowedUser::NumericId(987654321)];
let resolved = provider.resolve_users(&users).await.unwrap();
assert!(resolved.contains("987654321"));
}

#[test]
fn short_message_unchanged() {
Expand Down
10 changes: 6 additions & 4 deletions src/tests/security_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ fn gate(ids: &[&str]) -> SecurityGate {

#[test]
fn allowed_user_passes() {
let g = gate(&["123456", "@user-x"]);
let g = gate(&["123456", "user-x"]);
assert!(g.is_allowed("123456"));
assert!(g.is_allowed("@user-x"));
assert!(g.is_allowed("user-x"));
}

#[test]
Expand All @@ -26,6 +26,8 @@ fn empty_allowlist_blocks_all() {

#[test]
fn exact_match_required() {
let g = gate(&["@user-x"]);
assert!(!g.is_allowed("user-x"));
// Handles are normalized before entering the gate (no '@', lowercased).
let g = gate(&["user-x"]);
assert!(!g.is_allowed("@user-x")); // raw '@' form never matches
assert!(!g.is_allowed("User-X")); // case mismatch never matches
}
10 changes: 5 additions & 5 deletions src/tests/session_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,15 @@ fn wa_chat(id: &str) -> ChatId {
#[test]
fn fresh_chat_is_not_active() {
let store = SessionStore::new();
assert!(!store.should_continue(&tg_chat("42")));
assert!(!store.get(&tg_chat("42")).is_active);
}

#[test]
fn after_mark_active_should_continue() {
let mut store = SessionStore::new();
let id = tg_chat("42");
store.mark_active(&id);
assert!(store.should_continue(&id));
assert!(store.get(&id).is_active);
}

#[test]
Expand All @@ -35,7 +35,7 @@ fn after_reset_is_not_active() {
let id = tg_chat("42");
store.mark_active(&id);
store.reset(&id);
assert!(!store.should_continue(&id));
assert!(!store.get(&id).is_active);
}

#[test]
Expand All @@ -44,8 +44,8 @@ fn different_platforms_same_id_are_independent() {
let tg = tg_chat("12345");
let wa = wa_chat("12345");
store.mark_active(&tg);
assert!(store.should_continue(&tg));
assert!(!store.should_continue(&wa));
assert!(store.get(&tg).is_active);
assert!(!store.get(&wa).is_active);
}

#[test]
Expand Down
4 changes: 1 addition & 3 deletions src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,6 @@ pub struct InboundMessage {
/// Platform-native user identifier as a string (for `SecurityGate` comparison and rate limiting).
pub user_id: String,
pub text: String,
#[allow(dead_code)] // reserved for future audit logging
pub timestamp: DateTime<Utc>,
/// Routing context stamped by the channel listener at ingestion time.
pub context: MessageContext,
}
Expand Down Expand Up @@ -81,7 +79,7 @@ pub struct CliResponse {
pub stdout: String,
pub stderr: String,
pub exit_code: i32,
#[allow(dead_code)] // reserved for future telemetry/metrics
#[allow(dead_code)] // TODO: telemetry/metrics
pub duration: Duration,
}

Expand Down
Loading