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