Skip to main content

orch/engine/
channel_handler.rs

1//! Channel message handlers — routes incoming channel messages to the appropriate action.
2//!
3//! Extracted from `engine/mod.rs` to keep the engine lifecycle code separate from
4//! message dispatch logic. All handler functions here are stateless with respect to
5//! the engine tick loop and operate only through the shared ref types passed in.
6
7use std::collections::HashMap;
8use std::sync::Arc;
9use std::time::{Duration, Instant};
10
11use tokio::sync::Mutex;
12
13use crate::backends::{ExternalId, Status};
14use crate::channels::capture::CaptureService;
15use crate::channels::routing::ChannelRouter;
16use crate::channels::transport::conversation_key;
17use crate::channels::transport::{MessageRoute, Transport};
18use crate::channels::{ChannelRegistry, IncomingMessage, OutgoingMessage};
19use crate::engine::commands::{CommandStoreOps, execute_command, parse_command};
20use crate::engine::tasks::{CreateTaskRequest, Task, TaskType};
21use crate::github::http::GhHttp;
22use crate::store::TaskStatus;
23use crate::tmux::TmuxManager;
24
25use super::EngineRef;
26use crate::channels::stream::fanout_output;
27use crate::channels::tmux;
28use crate::channels::transport;
29use crate::control;
30
31// ── Project picker ─────────────────────────────────────────────────────────
32
33/// Timeout for pending project picks before they are discarded.
34const PICK_TIMEOUT: Duration = Duration::from_secs(60);
35
36/// A pending project selection waiting for the user to tap a button.
37///
38/// Stored in-memory with a 60-second TTL. If the user does not pick within
39/// that window the entry is silently discarded on next access.
40#[derive(Debug)]
41pub struct PendingPick {
42    /// Original free-text body that will become the task title/body.
43    pub original_body: String,
44    /// When this pick expires and should be discarded.
45    pub expires_at: Instant,
46}
47
48impl PendingPick {
49    pub fn new(original_body: String) -> Self {
50        Self {
51            original_body,
52            expires_at: Instant::now() + PICK_TIMEOUT,
53        }
54    }
55
56    pub fn is_expired(&self) -> bool {
57        Instant::now() >= self.expires_at
58    }
59}
60
61/// In-memory map of pending project picks, keyed by `"<channel>:<original_msg_id>"`.
62///
63/// The key uses the original user message ID so that the callback_data embedded in
64/// button payloads (`pick:<original_msg_id>:<repo>`) maps back unambiguously.
65pub type PendingPicks = Arc<Mutex<HashMap<String, PendingPick>>>;
66
67/// Parse a pick callback body of the form `"pick:<original_msg_id>:<repo>"`.
68///
69/// Returns `(original_msg_id, repo)` on success, `None` otherwise.
70pub fn parse_pick_callback(body: &str) -> Option<(String, String)> {
71    let rest = body.strip_prefix("pick:")?;
72    let colon = rest.find(':')?;
73    let orig_id = &rest[..colon];
74    let repo = &rest[colon + 1..];
75    if orig_id.is_empty() || repo.is_empty() {
76        return None;
77    }
78    Some((orig_id.to_string(), repo.to_string()))
79}
80
81/// Build the list of `(button_label, callback_data)` pairs for a project picker.
82///
83/// `original_msg_id` is embedded in every `callback_data` so the handler can
84/// look up the original message body in `pending_picks`.
85///
86/// Button label: the last path component of the repo slug (e.g. `"orch"` from
87/// `"gabrielkoerich/orch"`), falling back to the full slug when no `/` is present.
88pub fn buttons_for_picker(repos: &[&str], original_msg_id: &str) -> Vec<(String, String)> {
89    repos
90        .iter()
91        .map(|repo| {
92            let label = repo.rsplit('/').next().unwrap_or(repo).to_string();
93            let callback_data = format!("pick:{original_msg_id}:{repo}");
94            (label, callback_data)
95        })
96        .collect()
97}
98
99fn control_session_id(channel: &str, thread_id: &str, topic_id: Option<&str>) -> String {
100    format!("control:{}", conversation_key(channel, thread_id, topic_id))
101}
102
103fn normalize_channel_route(
104    msg: &IncomingMessage,
105    route: MessageRoute,
106    is_control: bool,
107) -> MessageRoute {
108    if is_control {
109        return MessageRoute::ControlSession;
110    }
111
112    match route {
113        // Telegram should behave like `orch chat` by default. Keep explicit
114        // commands, bound task threads, and existing picker callbacks on their
115        // normal paths.
116        MessageRoute::NewTask
117            if msg.channel == "telegram" && parse_pick_callback(msg.body.trim()).is_none() =>
118        {
119            MessageRoute::ControlSession
120        }
121        other => other,
122    }
123}
124
125/// Send a reply message to a specific channel thread.
126///
127/// Iterates the channel registry and finds the channel by name,
128/// then sends the message to the given thread ID.
129/// Optionally sends to a specific topic (e.g. Telegram forum topic).
130pub(super) async fn send_channel_reply(
131    channels: &Arc<ChannelRegistry>,
132    channel_name: &str,
133    thread_id: &str,
134    body: String,
135    topic_id: Option<&str>,
136) {
137    for ch in channels.iter() {
138        if ch.name() == channel_name {
139            let msg = OutgoingMessage {
140                thread_id: thread_id.to_string(),
141                body,
142                reply_to: None,
143                metadata: serde_json::json!({}),
144                topic_id: topic_id.map(String::from),
145            };
146            if let Err(e) = ch.send(&msg).await {
147                tracing::warn!(
148                    channel = channel_name,
149                    thread_id,
150                    ?e,
151                    "failed to send channel reply"
152                );
153            }
154            return;
155        }
156    }
157    tracing::debug!(
158        channel = channel_name,
159        "channel not found in registry for reply"
160    );
161}
162
163/// Forward a text message to an agent's tmux session via send-keys.
164pub(super) async fn forward_to_tmux(
165    transport: &Arc<Transport>,
166    repo: &str,
167    task_id: &str,
168    text: &str,
169) {
170    match transport.get_binding(repo, task_id).await {
171        Some(binding) => match tmux::send_keys(&binding.tmux_session, text).await {
172            Err(e) => {
173                tracing::warn!(
174                    task_id,
175                    session = %binding.tmux_session,
176                    ?e,
177                    "failed to forward message to tmux session"
178                );
179            }
180            _ => {
181                tracing::debug!(
182                    task_id,
183                    session = %binding.tmux_session,
184                    "forwarded message to tmux"
185                );
186            }
187        },
188        _ => {
189            tracing::warn!(task_id, "no tmux binding found, cannot forward message");
190        }
191    }
192}
193
194/// Find the index in `repos` whose computed tmux session name matches `tmux_session`.
195///
196/// This is used to route `TaskSession` slash commands to the correct project backend
197/// in a multi-project setup. Returns `0` (first project) when no match is found so
198/// that single-project deployments continue to work without change.
199fn engine_ref_idx_for_session(
200    repos: &[&str],
201    tmux: &TmuxManager,
202    task_id: &str,
203    tmux_session: &str,
204) -> usize {
205    repos
206        .iter()
207        .position(|repo| tmux.session_name(repo, task_id) == tmux_session)
208        .unwrap_or(0)
209}
210
211/// Acknowledge an interactive callback for the appropriate channel.
212///
213/// Telegram requires `answerCallbackQuery` to dismiss the button spinner.
214/// Discord buttons are already acknowledged (type 6) in the websocket handler.
215/// Other channels are no-ops.
216async fn ack_channel_interaction(
217    channels: &Arc<ChannelRegistry>,
218    channel_name: &str,
219    metadata: &serde_json::Value,
220) {
221    if channel_name != "telegram" {
222        return;
223    }
224    let Some(cb_id) = metadata["callback_query_id"].as_str() else {
225        return;
226    };
227    for ch in channels.iter() {
228        if ch.name() == channel_name {
229            if let Err(e) = ch.ack_interaction(cb_id).await {
230                tracing::warn!(channel = channel_name, ?e, "failed to ack callback query");
231            }
232            return;
233        }
234    }
235}
236
237/// Create an internal task in the named project and send a confirmation reply.
238///
239/// Used both by the direct `NewTask` path and by the project-picker callback path.
240///
241/// `forced_repo` overrides project resolution and ALWAYS shows the `in [repo]` label.
242#[expect(
243    clippy::too_many_arguments,
244    reason = "shared by two call paths that each hold these pieces separately"
245)]
246async fn create_and_announce_task(
247    body: &str,
248    forced_repo: Option<&str>,
249    channel: &str,
250    thread_id: &str,
251    topic_id: Option<&str>,
252    engine_refs: &[EngineRef],
253    transport: &Arc<Transport>,
254    tmux: &Arc<TmuxManager>,
255    capture: &Arc<CaptureService>,
256    channels: &Arc<ChannelRegistry>,
257) {
258    let target = if let Some(repo) = forced_repo {
259        engine_refs.iter().find(|(r, _, _, _)| r == repo)
260    } else {
261        engine_refs.first()
262    };
263
264    let Some((repo, _, task_manager, _)) = target else {
265        tracing::warn!("no project configured, cannot create task from channel message");
266        send_channel_reply(
267            channels,
268            channel,
269            thread_id,
270            "No project configured. Cannot create task.".to_string(),
271            topic_id,
272        )
273        .await;
274        return;
275    };
276
277    let title = if body.chars().count() > 80 {
278        let truncated: String = body.chars().take(80).collect();
279        format!("{truncated}…")
280    } else {
281        body.to_string()
282    };
283    let req = CreateTaskRequest {
284        title,
285        body: body.to_string(),
286        task_type: TaskType::Internal,
287        labels: vec!["channel-created".to_string()],
288        source: channel.to_string(),
289        source_id: thread_id.to_string(),
290    };
291    match task_manager.create_task(req).await {
292        Ok(task) => {
293            let task_id = match &task {
294                Task::Internal(t) => format!("internal:{}", t.id),
295                Task::External(t) => t.id.0.clone(),
296            };
297            let session_name = tmux.session_name(repo, &task_id);
298            transport
299                .bind(repo, &task_id, &session_name, channel, thread_id, topic_id)
300                .await;
301            capture
302                .register_session(repo, &task_id, &session_name)
303                .await;
304            let transport_clone = Arc::clone(transport);
305            let channels_clone = Arc::clone(channels);
306            let task_id_clone = task_id.clone();
307            let repo_clone = repo.clone();
308            tokio::spawn(async move {
309                fanout_output(repo_clone, task_id_clone, transport_clone, channels_clone).await;
310            });
311            // Always show project label (caller sets forced_repo when project isn't obvious)
312            let project_label = format!(" in [{repo}]");
313            let reply =
314                format!("Task created: `{task_id}`{project_label} — I'll start working on it now.");
315            send_channel_reply(channels, channel, thread_id, reply, topic_id).await;
316        }
317        Err(e) => {
318            tracing::warn!(repo, err = %e, "failed to create task from channel message");
319            send_channel_reply(
320                channels,
321                channel,
322                thread_id,
323                format!("Failed to create task: {e}"),
324                topic_id,
325            )
326            .await;
327        }
328    }
329}
330
331/// Handle an incoming channel message by routing it to the appropriate action.
332///
333/// - `TaskSession`: slash command → execute on task; otherwise → forward to tmux
334/// - `Command`: global commands like `/status`, `/stats`, `/subscribe`, `/unsubscribe`, `/stream`
335/// - `NewTask`: create an internal task, bind thread, start output fanout
336///
337/// The `channel_router` resolves which project a message targets based on
338/// its topic/channel ID. This enables per-project task creation and status views.
339///
340/// When a free-text message arrives on the General channel with multiple projects
341/// configured, a project picker is shown instead of silently using the first project.
342#[expect(
343    clippy::too_many_arguments,
344    reason = "the channel loop passes each engine handle separately"
345)]
346pub(super) async fn handle_channel_message(
347    msg: IncomingMessage,
348    transport: &Arc<Transport>,
349    tmux: &Arc<TmuxManager>,
350    capture: &Arc<CaptureService>,
351    channels: &Arc<ChannelRegistry>,
352    engine_refs: &[EngineRef],
353    channel_router: &Arc<ChannelRouter>,
354    pending_picks: &PendingPicks,
355) {
356    // Resolve project from incoming message topic/channel
357    let topic_id = msg.topic_id.as_deref().unwrap_or(&msg.thread_id);
358    let resolved_repo = channel_router.resolve_project(&msg.channel, topic_id);
359    let is_general = channel_router.is_general(&msg.channel, topic_id);
360    let is_control = channel_router.is_control_channel(&msg.channel, topic_id);
361    let msg_topic_id = msg.topic_id.clone();
362
363    // Check control-channel overrides before dispatch so configured control
364    // topics and Telegram default chat sessions both land on the chat path.
365    let route = normalize_channel_route(&msg, transport.route(&msg).await, is_control);
366
367    match route {
368        MessageRoute::TaskSession { session_key } => {
369            let body = msg.body.trim().to_string();
370            let channel = msg.channel.clone();
371            let thread_id = msg.thread_id.clone();
372
373            // Derive repo and task_id from the session key.
374            // For external tasks the key is "repo:task_id"; for internal tasks
375            // it is "internal:<id>" and we fall back to resolved_repo.
376            let (repo, task_id): (&str, &str) =
377                if let Some((r, t)) = transport::parse_session_key(&session_key) {
378                    (r, t)
379                } else {
380                    // Internal task or unparseable key — use resolved_repo
381                    (resolved_repo.unwrap_or(""), &session_key)
382                };
383
384            if body.starts_with('/') {
385                // Parse slash command and execute it on the bound task
386                if let Some(cmd) = parse_command(&body) {
387                    // Find the engine ref that owns this task by matching the tmux session
388                    // name stored in the transport binding.  In a multi-project setup this
389                    // prevents routing the command to the wrong project's GitHub backend.
390                    // Falls back to the first project when no binding / no match is found.
391                    let repos: Vec<&str> =
392                        engine_refs.iter().map(|(r, _, _, _)| r.as_str()).collect();
393                    let idx = transport.get_binding(repo, task_id).await.map_or(0, |b| {
394                        engine_ref_idx_for_session(&repos, tmux, task_id, &b.tmux_session)
395                    });
396                    if let Some((repo, backend, task_manager, store)) = engine_refs.get(idx) {
397                        let gh = match GhHttp::new() {
398                            Ok(gh) => gh,
399                            Err(e) => {
400                                tracing::warn!(error = %e, "failed to build HTTP client for command execution");
401                                send_channel_reply(
402                                    channels,
403                                    &channel,
404                                    &thread_id,
405                                    format!("Command `{cmd}` failed: {e}"),
406                                    msg_topic_id.as_deref(),
407                                )
408                                .await;
409                                return;
410                            }
411                        };
412                        let ext_id = ExternalId(task_id.to_string());
413                        let store_ops: Option<Arc<dyn CommandStoreOps>> = store
414                            .as_ref()
415                            .map(|s| Arc::clone(s) as Arc<dyn CommandStoreOps>);
416                        let result = execute_command(
417                            backend,
418                            &gh,
419                            repo,
420                            &ext_id,
421                            &cmd,
422                            &store_ops,
423                            task_manager,
424                        )
425                        .await;
426                        let reply = match result {
427                            Ok(r) => r,
428                            Err(e) => format!("Command `{cmd}` failed: {e}"),
429                        };
430                        send_channel_reply(
431                            channels,
432                            &channel,
433                            &thread_id,
434                            reply,
435                            msg_topic_id.as_deref(),
436                        )
437                        .await;
438                    }
439                } else {
440                    // Unknown command — forward to agent as-is
441                    forward_to_tmux(transport, repo, task_id, &body).await;
442                }
443            } else {
444                // Regular message — forward to the agent's tmux session
445                forward_to_tmux(transport, repo, task_id, &body).await;
446            }
447        }
448
449        MessageRoute::Command { raw } => {
450            let cmd_str = raw.trim().to_string();
451            let channel = msg.channel.clone();
452            let thread_id = msg.thread_id.clone();
453
454            if cmd_str == "/status" || cmd_str.starts_with("/status ") {
455                // /status — project-aware: show tasks for resolved project or all
456                let reply = handle_status_command(engine_refs, resolved_repo).await;
457                send_channel_reply(
458                    channels,
459                    &channel,
460                    &thread_id,
461                    reply,
462                    msg_topic_id.as_deref(),
463                )
464                .await;
465            } else if cmd_str == "/stats" || cmd_str.starts_with("/stats ") {
466                let reply = handle_stats_command(engine_refs, resolved_repo, is_general).await;
467                send_channel_reply(
468                    channels,
469                    &channel,
470                    &thread_id,
471                    reply,
472                    msg_topic_id.as_deref(),
473                )
474                .await;
475            } else if cmd_str.starts_with("/subscribe") {
476                let reply = handle_subscribe_command(
477                    &cmd_str,
478                    &channel,
479                    &thread_id,
480                    msg_topic_id.as_deref(),
481                    engine_refs,
482                    channel_router,
483                )
484                .await;
485                send_channel_reply(
486                    channels,
487                    &channel,
488                    &thread_id,
489                    reply,
490                    msg_topic_id.as_deref(),
491                )
492                .await;
493            } else if cmd_str.starts_with("/unsubscribe") {
494                let reply = handle_unsubscribe_command(
495                    &cmd_str,
496                    &channel,
497                    &thread_id,
498                    engine_refs,
499                    channel_router,
500                )
501                .await;
502                send_channel_reply(
503                    channels,
504                    &channel,
505                    &thread_id,
506                    reply,
507                    msg_topic_id.as_deref(),
508                )
509                .await;
510            } else if cmd_str.starts_with("/stream") {
511                let reply = handle_stream_command(
512                    &cmd_str,
513                    &channel,
514                    &thread_id,
515                    msg_topic_id.as_deref(),
516                    transport,
517                    engine_refs,
518                )
519                .await;
520                send_channel_reply(
521                    channels,
522                    &channel,
523                    &thread_id,
524                    reply,
525                    msg_topic_id.as_deref(),
526                )
527                .await;
528            } else if let Some(cmd) = parse_command(&cmd_str) {
529                let reply = format!(
530                    "Command `{cmd}` requires a task context. \
531                     Send it in a thread that is bound to a running task."
532                );
533                send_channel_reply(
534                    channels,
535                    &channel,
536                    &thread_id,
537                    reply,
538                    msg_topic_id.as_deref(),
539                )
540                .await;
541            } else {
542                let reply = "Available commands: /status, /stats, /subscribe, /unsubscribe, \
543                             /stream, /retry, /close, /block, /unblock, /review — \
544                             send task-specific commands in a task thread."
545                    .to_string();
546                send_channel_reply(
547                    channels,
548                    &channel,
549                    &thread_id,
550                    reply,
551                    msg_topic_id.as_deref(),
552                )
553                .await;
554            }
555        }
556
557        MessageRoute::ControlSession => {
558            let channel = msg.channel.clone();
559            let thread_id = msg.thread_id.clone();
560            let body = msg.body.clone();
561
562            // Session ID is per channel+topic so Telegram and Discord are isolated.
563            let session_id = control_session_id(&channel, &thread_id, msg_topic_id.as_deref());
564            let channel_thread = conversation_key(&channel, &thread_id, msg_topic_id.as_deref());
565
566            // Find a store from the first available engine reference.
567            let store = engine_refs
568                .iter()
569                .find_map(|(_, _, _, s)| s.as_ref())
570                .cloned();
571
572            if let Some(store) = store {
573                match control::send_message(
574                    &store,
575                    &session_id,
576                    &channel,
577                    Some(&channel_thread),
578                    &body,
579                )
580                .await
581                {
582                    Ok(reply) => {
583                        send_channel_reply(
584                            channels,
585                            &channel,
586                            &thread_id,
587                            reply,
588                            msg_topic_id.as_deref(),
589                        )
590                        .await;
591                    }
592                    Err(e) => {
593                        tracing::warn!(error = %e, "control session agent invocation failed");
594                        send_channel_reply(
595                            channels,
596                            &channel,
597                            &thread_id,
598                            format!("Control session error: {e}"),
599                            msg_topic_id.as_deref(),
600                        )
601                        .await;
602                    }
603                }
604            } else {
605                tracing::warn!("no store available, cannot handle control session message");
606                send_channel_reply(
607                    channels,
608                    &channel,
609                    &thread_id,
610                    "No projects configured. Please add a project with `orch project add` before using the control channel.".to_string(),
611                    msg_topic_id.as_deref(),
612                )
613                .await;
614            }
615        }
616
617        MessageRoute::NewTask => {
618            let channel = msg.channel.clone();
619            let thread_id = msg.thread_id.clone();
620
621            // Handle pick callback (user tapped a project button).
622            if let Some((orig_id, repo)) = parse_pick_callback(msg.body.trim()) {
623                let pick_key = format!("{channel}:{orig_id}");
624                let original_body = {
625                    let mut picks = pending_picks.lock().await;
626                    picks
627                        .remove(&pick_key)
628                        .filter(|p| !p.is_expired())
629                        .map(|p| p.original_body)
630                };
631                if let Some(body) = original_body {
632                    // Acknowledge the interaction (Telegram spinner, Discord already ack'd)
633                    ack_channel_interaction(channels, &channel, &msg.metadata).await;
634
635                    create_and_announce_task(
636                        &body,
637                        Some(repo.as_str()),
638                        &channel,
639                        &thread_id,
640                        msg_topic_id.as_deref(),
641                        engine_refs,
642                        transport,
643                        tmux,
644                        capture,
645                        channels,
646                    )
647                    .await;
648                } else {
649                    tracing::debug!(
650                        pick_key,
651                        "pick callback received but no matching pending pick (expired or unknown)"
652                    );
653                    send_channel_reply(
654                        channels,
655                        &channel,
656                        &thread_id,
657                        "Sorry, that selection has expired. Please send your message again."
658                            .to_string(),
659                        msg_topic_id.as_deref(),
660                    )
661                    .await;
662                }
663                return;
664            }
665
666            // Multi-project General channel: show a picker instead of silently using first.
667            if resolved_repo.is_none() && engine_refs.len() > 1 {
668                let repos: Vec<&str> = engine_refs.iter().map(|(r, _, _, _)| r.as_str()).collect();
669                let buttons = buttons_for_picker(&repos, &msg.id);
670                let buttons_json: Vec<serde_json::Value> = buttons
671                    .iter()
672                    .map(|(text, cb)| serde_json::json!({ "text": text, "callback_data": cb }))
673                    .collect();
674
675                // Store the original body so the pick callback can create the task.
676                {
677                    let key = format!("{channel}:{}", msg.id);
678                    let mut picks = pending_picks.lock().await;
679                    // Evict expired entries to prevent unbounded growth.
680                    picks.retain(|_, v| !v.is_expired());
681                    picks.insert(key, PendingPick::new(msg.body.clone()));
682                }
683
684                let picker_msg = OutgoingMessage {
685                    thread_id: thread_id.clone(),
686                    body: "Which project should I create this task in?".to_string(),
687                    reply_to: None,
688                    metadata: serde_json::json!({ "buttons": buttons_json }),
689                    topic_id: msg_topic_id.clone(),
690                };
691                for ch in channels.iter() {
692                    if ch.name() == channel {
693                        if let Err(e) = ch.send(&picker_msg).await {
694                            tracing::warn!(channel, ?e, "failed to send project picker");
695                        }
696                        break;
697                    }
698                }
699                return;
700            }
701
702            // Single project (or specific project resolved): create task directly.
703            create_and_announce_task(
704                &msg.body,
705                resolved_repo,
706                &channel,
707                &thread_id,
708                msg_topic_id.as_deref(),
709                engine_refs,
710                transport,
711                tmux,
712                capture,
713                channels,
714            )
715            .await;
716        }
717    }
718}
719
720/// Handle `/status` command with project-aware filtering.
721pub(super) async fn handle_status_command(
722    engine_refs: &[EngineRef],
723    resolved_repo: Option<&str>,
724) -> String {
725    let mut lines = vec!["**Active tasks:**".to_string()];
726    for (repo, _, task_manager, _) in engine_refs {
727        // If a specific project was resolved, only show that project
728        if let Some(target) = resolved_repo
729            && repo != target
730        {
731            continue;
732        }
733        match task_manager
734            .list_external_by_status(Status::InProgress)
735            .await
736        {
737            Ok(tasks) => {
738                for t in &tasks {
739                    lines.push(format!("- #{} [{}]: {}", t.id.0, repo, t.title));
740                }
741            }
742            Err(e) => {
743                tracing::warn!(repo, err = %e, "failed to list in-progress tasks");
744            }
745        }
746        match task_manager
747            .list_internal_by_status(TaskStatus::InProgress)
748            .await
749        {
750            Ok(tasks) => {
751                for t in &tasks {
752                    lines.push(format!("- #{} [{}]: {}", t.id.0, repo, t.title));
753                }
754            }
755            Err(e) => {
756                tracing::warn!(repo, err = %e, "failed to list in-progress internal tasks");
757            }
758        }
759    }
760    if lines.len() == 1 {
761        lines.push("No tasks currently in progress.".to_string());
762    }
763    lines.join("\n")
764}
765
766/// Handle `/stats` command — show 24h metrics, optionally per-project.
767pub(super) async fn handle_stats_command(
768    engine_refs: &[EngineRef],
769    resolved_repo: Option<&str>,
770    is_general: bool,
771) -> String {
772    let mut lines = vec!["**Stats (24h)**".to_string(), String::new()];
773
774    // Determine which repos to query
775    let repos_to_query: Vec<&str> = if let Some(repo) = resolved_repo {
776        vec![repo]
777    } else {
778        engine_refs.iter().map(|(r, _, _, _)| r.as_str()).collect()
779    };
780
781    for repo in &repos_to_query {
782        // Find the store for this repo
783        let store = engine_refs
784            .iter()
785            .find(|(r, _, _, _)| r == repo)
786            .and_then(|(_, _, _, s)| s.as_ref());
787
788        let Some(store) = store else { continue };
789
790        match store.get_metrics_summary_by_repo(repo, 24).await {
791            Ok(summary) => {
792                let total = summary.tasks_completed_24h + summary.tasks_failed_24h;
793                #[expect(clippy::cast_precision_loss, reason = "a display percentage")]
794                let rate = if total > 0 {
795                    (summary.tasks_completed_24h as f64 / total as f64) * 100.0
796                } else {
797                    0.0
798                };
799                // Show repo name only if showing multiple (general or no resolution)
800                if is_general || resolved_repo.is_none() {
801                    lines.push(format!(
802                        "**{}**: {} done, {} failed ({:.1}%)",
803                        repo, summary.tasks_completed_24h, summary.tasks_failed_24h, rate
804                    ));
805                } else {
806                    lines.push(format!(
807                        "{} done, {} failed ({:.1}%)",
808                        summary.tasks_completed_24h, summary.tasks_failed_24h, rate
809                    ));
810                }
811                // Per-agent breakdown
812                for agent in &summary.agent_stats {
813                    lines.push(format!(
814                        "  {}: {} runs ({:.0}%)",
815                        agent.agent, agent.total_runs, agent.success_rate
816                    ));
817                }
818                if !summary.agent_stats.is_empty() {
819                    lines.push(String::new());
820                }
821            }
822            Err(e) => {
823                tracing::warn!(repo, err = %e, "failed to query metrics");
824                lines.push(format!("**{repo}**: error fetching metrics"));
825            }
826        }
827    }
828
829    if lines.len() <= 2 {
830        lines.push("No metrics data available.".to_string());
831    }
832    lines.join("\n")
833}
834
835/// Handle `/subscribe <project>` command.
836pub(super) async fn handle_subscribe_command(
837    cmd_str: &str,
838    channel: &str,
839    thread_id: &str,
840    topic_id: Option<&str>,
841    engine_refs: &[EngineRef],
842    channel_router: &Arc<ChannelRouter>,
843) -> String {
844    let parts: Vec<&str> = cmd_str.splitn(2, ' ').collect();
845    if parts.len() < 2 || parts[1].trim().is_empty() {
846        let projects: Vec<&str> = channel_router
847            .projects()
848            .iter()
849            .map(String::as_str)
850            .collect();
851        return format!(
852            "Usage: `/subscribe <project>`\nAvailable projects: {}",
853            projects.join(", ")
854        );
855    }
856    let project = parts[1].trim();
857
858    // Validate project exists
859    if !channel_router.projects().iter().any(|p| p == project) {
860        return format!(
861            "Unknown project `{project}`. Available: {}",
862            channel_router
863                .projects()
864                .iter()
865                .map(String::as_str)
866                .collect::<Vec<_>>()
867                .join(", ")
868        );
869    }
870
871    // Find a store to use for subscription
872    if let Some((_, _, _, Some(store))) = engine_refs.first() {
873        match store
874            .subscribe_channel(channel, thread_id, project, topic_id)
875            .await
876        {
877            Ok(()) => format!("Subscribed to notifications from `{project}`."),
878            Err(e) => format!("Failed to subscribe: {e}"),
879        }
880    } else {
881        "No store available for subscription management.".to_string()
882    }
883}
884
885/// Handle `/unsubscribe <project>` command.
886pub(super) async fn handle_unsubscribe_command(
887    cmd_str: &str,
888    channel: &str,
889    thread_id: &str,
890    engine_refs: &[EngineRef],
891    channel_router: &Arc<ChannelRouter>,
892) -> String {
893    let parts: Vec<&str> = cmd_str.splitn(2, ' ').collect();
894    if parts.len() < 2 || parts[1].trim().is_empty() {
895        return "Usage: `/unsubscribe <project>`".to_string();
896    }
897    let project = parts[1].trim();
898
899    // Validate project exists
900    if !channel_router.projects().iter().any(|p| p == project) {
901        return format!("Unknown project `{project}`.");
902    }
903
904    if let Some((_, _, _, Some(store))) = engine_refs.first() {
905        match store.unsubscribe_channel(channel, thread_id, project).await {
906            Ok(()) => format!("Unsubscribed from `{project}` notifications."),
907            Err(e) => format!("Failed to unsubscribe: {e}"),
908        }
909    } else {
910        "No store available for subscription management.".to_string()
911    }
912}
913
914/// Handle `/stream <task_id>` command — bind channel to task output stream.
915pub(super) async fn handle_stream_command(
916    cmd_str: &str,
917    channel: &str,
918    thread_id: &str,
919    topic_id: Option<&str>,
920    transport: &Arc<Transport>,
921    engine_refs: &[EngineRef],
922) -> String {
923    let parts: Vec<&str> = cmd_str.splitn(2, ' ').collect();
924    if parts.len() < 2 || parts[1].trim().is_empty() {
925        return "Usage: `/stream <task_id>`".to_string();
926    }
927    let task_id = parts[1].trim();
928
929    // Check if the task has an active binding (i.e. is running)
930    // Try all known repos to find the binding
931    let repos: Vec<&str> = engine_refs.iter().map(|(r, _, _, _)| r.as_str()).collect();
932    let mut found = None;
933    for repo in &repos {
934        if let Some(binding) = transport.get_binding(repo, task_id).await {
935            found = Some((repo.to_string(), binding));
936            break;
937        }
938    }
939
940    if let Some((repo, binding)) = found {
941        // Bind this channel/thread as an additional output target
942        // The session name is retrieved from the existing binding
943        transport
944            .bind(
945                &repo,
946                task_id,
947                &binding.tmux_session,
948                channel,
949                thread_id,
950                topic_id,
951            )
952            .await;
953        format!("Streaming output from task `{task_id}` to this channel.")
954    } else {
955        format!("Task `{task_id}` is not currently running or has no active session.")
956    }
957}
958
959#[cfg(test)]
960mod tests {
961    use super::*;
962    use std::time::{Duration, Instant};
963
964    #[test]
965    fn parse_pick_callback_parses_valid_input() {
966        let (orig_id, repo) = parse_pick_callback("pick:msg123:owner/repo").unwrap();
967        assert_eq!(orig_id, "msg123");
968        assert_eq!(repo, "owner/repo");
969    }
970
971    #[test]
972    fn parse_pick_callback_returns_none_for_non_pick_prefix() {
973        assert!(parse_pick_callback("not-a-pick").is_none());
974        assert!(parse_pick_callback("/command").is_none());
975        assert!(parse_pick_callback("").is_none());
976    }
977
978    #[test]
979    fn parse_pick_callback_returns_none_when_too_few_parts() {
980        // "pick:" with no id/repo
981        assert!(parse_pick_callback("pick:").is_none());
982        // "pick:only-one-part" — no repo after second colon
983        assert!(parse_pick_callback("pick:only-one-part").is_none());
984    }
985
986    #[test]
987    fn parse_pick_callback_allows_slash_in_repo() {
988        let (orig_id, repo) = parse_pick_callback("pick:abc:owner/repo-name").unwrap();
989        assert_eq!(orig_id, "abc");
990        assert_eq!(repo, "owner/repo-name");
991    }
992
993    #[test]
994    fn pending_pick_is_expired_when_deadline_in_past() {
995        let pick = PendingPick {
996            original_body: "fix the login button".to_string(),
997            expires_at: Instant::now()
998                .checked_sub(Duration::from_secs(1))
999                .expect("the clock is past one second"),
1000        };
1001        assert!(pick.is_expired());
1002    }
1003
1004    #[test]
1005    fn pending_pick_is_not_expired_before_deadline() {
1006        let pick = PendingPick {
1007            original_body: "fix the login button".to_string(),
1008            expires_at: Instant::now() + Duration::from_secs(60),
1009        };
1010        assert!(!pick.is_expired());
1011    }
1012
1013    #[test]
1014    fn buttons_for_picker_generates_pick_callback_data() {
1015        let repos = ["owner/project-a", "owner/project-b"];
1016        let buttons = buttons_for_picker(&repos, "msg_123");
1017        assert_eq!(buttons.len(), 2);
1018        assert_eq!(
1019            buttons[0],
1020            (
1021                "project-a".to_string(),
1022                "pick:msg_123:owner/project-a".to_string()
1023            )
1024        );
1025        assert_eq!(
1026            buttons[1],
1027            (
1028                "project-b".to_string(),
1029                "pick:msg_123:owner/project-b".to_string()
1030            )
1031        );
1032    }
1033
1034    #[test]
1035    fn buttons_for_picker_uses_full_name_when_no_slash() {
1036        let repos = ["myrepo"];
1037        let buttons = buttons_for_picker(&repos, "id1");
1038        assert_eq!(buttons[0].0, "myrepo");
1039        assert_eq!(buttons[0].1, "pick:id1:myrepo");
1040    }
1041
1042    #[test]
1043    fn control_session_id_uses_conversation_key() {
1044        assert_eq!(
1045            control_session_id("telegram", "123", Some("456")),
1046            "control:telegram:123|456"
1047        );
1048        assert_eq!(
1049            control_session_id("telegram", "123", None),
1050            "control:telegram:123"
1051        );
1052    }
1053
1054    #[test]
1055    fn normalize_channel_route_promotes_telegram_new_task_to_control_session() {
1056        let msg = IncomingMessage {
1057            channel: "telegram".to_string(),
1058            id: "m1".to_string(),
1059            thread_id: "42".to_string(),
1060            author: "user".to_string(),
1061            body: "what's running?".to_string(),
1062            timestamp: chrono::Utc::now(),
1063            metadata: serde_json::json!({}),
1064            topic_id: None,
1065        };
1066
1067        assert!(matches!(
1068            normalize_channel_route(&msg, MessageRoute::NewTask, false),
1069            MessageRoute::ControlSession
1070        ));
1071    }
1072
1073    #[test]
1074    fn normalize_channel_route_preserves_picker_callbacks() {
1075        let msg = IncomingMessage {
1076            channel: "telegram".to_string(),
1077            id: "m1".to_string(),
1078            thread_id: "42".to_string(),
1079            author: "user".to_string(),
1080            body: "pick:m1:owner/repo".to_string(),
1081            timestamp: chrono::Utc::now(),
1082            metadata: serde_json::json!({}),
1083            topic_id: None,
1084        };
1085
1086        assert!(matches!(
1087            normalize_channel_route(&msg, MessageRoute::NewTask, false),
1088            MessageRoute::NewTask
1089        ));
1090    }
1091
1092    #[test]
1093    fn normalize_channel_route_preserves_non_telegram_new_task() {
1094        let msg = IncomingMessage {
1095            channel: "discord".to_string(),
1096            id: "m1".to_string(),
1097            thread_id: "42".to_string(),
1098            author: "user".to_string(),
1099            body: "create a task".to_string(),
1100            timestamp: chrono::Utc::now(),
1101            metadata: serde_json::json!({}),
1102            topic_id: None,
1103        };
1104
1105        assert!(matches!(
1106            normalize_channel_route(&msg, MessageRoute::NewTask, false),
1107            MessageRoute::NewTask
1108        ));
1109    }
1110
1111    /// Regression test for issue #780: TaskSession slash commands must use the engine_ref
1112    /// that owns the task, not always the first one.
1113    ///
1114    /// In a two-project setup where project-b owns task "42" (its tmux session is
1115    /// "orch-project-b-42"), `engine_ref_idx_for_session` must return 1 (project-b),
1116    /// not 0 (project-a).
1117    #[test]
1118    fn engine_ref_idx_for_session_returns_owning_project() {
1119        let tmux = TmuxManager::new();
1120        let repos: Vec<&str> = vec!["owner/project-a", "owner/project-b"];
1121        let task_id = "42";
1122        let session_b = tmux.session_name("owner/project-b", task_id); // "orch-project-b-42"
1123
1124        let idx = engine_ref_idx_for_session(&repos, &tmux, task_id, &session_b);
1125        assert_eq!(
1126            idx, 1,
1127            "should resolve to project-b (index 1), not project-a"
1128        );
1129    }
1130
1131    /// Regression test for issue #780: when no session name matches any project,
1132    /// fall back to the first engine_ref (index 0) to preserve existing behaviour.
1133    #[test]
1134    fn engine_ref_idx_for_session_falls_back_to_zero_on_no_match() {
1135        let tmux = TmuxManager::new();
1136        let repos: Vec<&str> = vec!["owner/project-a", "owner/project-b"];
1137        let task_id = "42";
1138
1139        let idx = engine_ref_idx_for_session(&repos, &tmux, task_id, "orch-unknown-42");
1140        assert_eq!(
1141            idx, 0,
1142            "unknown session should fall back to first project (index 0)"
1143        );
1144    }
1145
1146    /// Regression test for issue #773: the session name registered in the transport
1147    /// binding must match the name that TmuxManager uses for the actual tmux session.
1148    ///
1149    /// For internal tasks the raw task ID is "internal:42" (colon), but tmux sanitizes
1150    /// it to "internal-42" (hyphen).  The old code used `format!("orch-{repo}-{task_id}")`
1151    /// which embedded the unsanitized colon, so `bind()` / `register_session()` stored
1152    /// "orch-repo-internal:42" while the real session was "orch-repo-internal-42".
1153    ///
1154    /// After the fix both call sites use `tmux.session_name(repo, &task_id)` which
1155    /// applies the same sanitization, so the names match.
1156    #[test]
1157    fn session_name_used_for_internal_task_matches_tmux_manager() {
1158        let tmux = TmuxManager::new();
1159        let repo = "owner/repo";
1160        let task_id = "internal:42";
1161
1162        // This is the name the runner/dispatch tick uses for the actual tmux session.
1163        let actual_session = tmux.session_name(repo, task_id);
1164
1165        // Before the fix, channel_handler computed the session name as:
1166        //   format!("orch-{repo}-{task_id}") = "orch-owner/repo-internal:42"
1167        // which does NOT equal the sanitized name.
1168        let old_buggy_name = format!("orch-{repo}-{task_id}");
1169        assert_ne!(
1170            old_buggy_name, actual_session,
1171            "sanity check: old format should differ from sanitized name"
1172        );
1173
1174        // After the fix, channel_handler uses tmux.session_name() — same as the runner.
1175        assert!(
1176            actual_session.ends_with("repo-internal-42"),
1177            "internal task session must use the sanitized hyphenated id, got {actual_session}"
1178        );
1179        assert!(
1180            !actual_session.contains(':'),
1181            "tmux session names cannot contain a colon"
1182        );
1183    }
1184}