1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
//! Tests for `telegram_send::resolve_thread_id` — the helper that
//! lets the agent pass an explicit `thread_id` to override the
//! auto-lookup behavior added in commit `1e46cd26` (closes #130).
//!
//! Use cases the override enables:
//! * Cron jobs posting to a specific topic (e.g. daily summary in
//! #announcements regardless of what was discussed last in #dev).
//! * Multi-topic conversations where the agent wants to post into
//! a topic OTHER than the most recent one stored.
//! * Explicit user instruction: "post this in topic 17".
//!
//! Suggested by leshchenko1979 on issue #130:
//! https://github.com/adolfousier/opencrabs/issues/130#issuecomment-4582189795
use crate::brain::tools::telegram_send::resolve_thread_id;
use crate::channels::telegram::TelegramState;
use serde_json::json;
use teloxide::types::{MessageId, ThreadId};
use uuid::Uuid;
// A fresh state + nil session means no session-origin topic is bound, so the
// resolver behaves exactly like the old two-arg version (explicit override,
// else auto-lookup). Session-origin routing (#450) is covered separately.
fn empty_state() -> TelegramState {
TelegramState::new()
}
#[tokio::test]
async fn explicit_thread_id_is_returned_verbatim() {
let input = json!({ "thread_id": 17 });
let result = resolve_thread_id(&input, 0, Uuid::nil(), &empty_state()).await;
assert_eq!(result, Some(ThreadId(MessageId(17))));
}
#[tokio::test]
async fn explicit_thread_id_works_for_negative_legacy_thread_ids() {
// Legacy Telegram chat shapes occasionally surface negative
// thread_id values within i32 range. The helper must pass them
// through, not reject them.
let input = json!({ "thread_id": -2147483 });
let result = resolve_thread_id(&input, 0, Uuid::nil(), &empty_state()).await;
assert_eq!(result, Some(ThreadId(MessageId(-2147483))));
}
#[tokio::test]
async fn explicit_thread_id_overflowing_i32_falls_back_to_lookup() {
// teloxide's ThreadId wraps MessageId(i32). Values past i32::MAX
// can't be represented. Rather than returning a wrong/zero ID,
// the helper falls through to the auto-lookup path.
let input = json!({ "thread_id": 9_999_999_999_i64 });
// No global pool initialised → auto-lookup returns None → final
// result is None. We just confirm no panic + no garbage value.
let result = resolve_thread_id(&input, 12345, Uuid::nil(), &empty_state()).await;
assert_eq!(result, None);
}
#[tokio::test]
async fn no_explicit_thread_id_falls_back_to_lookup() {
// Absent field → auto-lookup path. In tests the global pool
// isn't initialised so the lookup returns None; the important
// contract is "no override path, no garbage value, no panic".
let input = json!({ "chat_id": 12345 });
let result = resolve_thread_id(&input, 12345, Uuid::nil(), &empty_state()).await;
assert_eq!(result, None);
}
#[tokio::test]
async fn non_integer_thread_id_falls_back_to_lookup() {
// Defensive: the agent could emit "thread_id": "17" (string) or
// an object/array. Don't accept those as overrides — fall back
// to auto-lookup so a malformed override doesn't poison routing.
let input = json!({ "thread_id": "17" });
let result = resolve_thread_id(&input, 12345, Uuid::nil(), &empty_state()).await;
// Auto-lookup returns None in test (no global pool).
assert_eq!(result, None);
}
#[tokio::test]
async fn explicit_thread_id_zero_is_returned() {
// Telegram's General topic is sometimes represented as thread 0
// depending on API surface. The helper doesn't second-guess
// i32-valid values.
let input = json!({ "thread_id": 0 });
let result = resolve_thread_id(&input, 0, Uuid::nil(), &empty_state()).await;
assert_eq!(result, Some(ThreadId(MessageId(0))));
}
#[tokio::test]
async fn session_origin_topic_is_used_when_no_explicit_thread_id() {
// #450: with no explicit thread_id, the resolver inherits the forum topic
// this session started in (the same session_topic map follow_up_question
// uses), so a reply routes back to the originating topic automatically.
let state = empty_state();
let session_id = Uuid::from_u128(0xABCD);
state
.register_session_chat(session_id, 12345, Some(42))
.await;
let input = json!({});
let result = resolve_thread_id(&input, 12345, session_id, &state).await;
assert_eq!(result, Some(ThreadId(MessageId(42))));
}
#[tokio::test]
async fn explicit_thread_id_overrides_session_origin_topic() {
// Explicit routing still wins over the inherited session topic (#450).
let state = empty_state();
let session_id = Uuid::from_u128(0xABCD);
state
.register_session_chat(session_id, 12345, Some(42))
.await;
let input = json!({ "thread_id": 7 });
let result = resolve_thread_id(&input, 12345, session_id, &state).await;
assert_eq!(result, Some(ThreadId(MessageId(7))));
}
#[tokio::test]
async fn non_forum_session_origin_falls_through_to_lookup() {
// A session bound to a non-forum chat has topic_id = None, so the resolver
// skips session-origin and falls through to the auto-lookup (None in test).
let state = empty_state();
let session_id = Uuid::from_u128(0xBEEF);
state.register_session_chat(session_id, 12345, None).await;
let input = json!({});
let result = resolve_thread_id(&input, 12345, session_id, &state).await;
assert_eq!(result, None);
}