1use 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
25const PICK_TIMEOUT: Duration = Duration::from_secs(60);
29
30pub struct PendingPick {
35 pub original_body: String,
37 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
54pub type PendingPicks = Arc<tokio::sync::Mutex<HashMap<String, PendingPick>>>;
59
60pub 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
74pub 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 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
118pub(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
156pub(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
183fn 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
200async 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#[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 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#[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 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 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 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 (resolved_repo.unwrap_or(""), &session_key)
368 };
369
370 if body.starts_with('/') {
371 if let Some(cmd) = parse_command(&body) {
373 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 forward_to_tmux(transport, repo, task_id, &body).await;
430 }
431 } else {
432 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 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 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 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 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 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 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 {
665 let key = format!("{channel}:{}", msg.id);
666 let mut picks = pending_picks.lock().await;
667 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 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
708pub(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 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
754pub(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 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 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 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 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
825pub(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 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 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
875pub(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 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
904pub(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 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 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 assert!(parse_pick_callback("pick:").is_none());
972 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 #[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); 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 #[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 #[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 let actual_session = tmux.session_name(repo, task_id);
1152
1153 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 assert_eq!(actual_session, "orch-repo-internal-42");
1164 }
1165}