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