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
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
//! Pure Telegram session title + label-drift helpers (testable without teloxide).
//!
//! Issue #121: naive full-title comparison reverted auto-titled DM sessions back
//! to the default `Telegram: DM …` template on every subsequent message.
/// Build the canonical session title for a Telegram chat.
///
/// `topic_id` scopes the title to a forum topic (#215): `Some` appends
/// `:topic:<id>` to the suffix so each topic in a supergroup resolves to its
/// own session row instead of all topics collapsing onto the bare
/// `[chat:<id>]` session. `None` (DMs, non-forum groups, the General topic)
/// produces the exact same title as before topics existed.
/// Legacy title format (pre suffix) for migration lookups.
/// Title/lookup suffix for a chat, optionally scoped to a forum topic (#215).
///
/// Base chats stay `[chat:<id>]`; a real forum topic becomes
/// `[chat:<id>:topic:<tid>]`. `find_session_by_title_suffix` matches with
/// `LIKE '%suffix'`, and a base title never ends with `:topic:<n>]` while a
/// topic title never ends with the bare `[chat:<id>]`, so the two never
/// cross-resolve.
/// Resolve the forum-topic id used to key a session (#215). Returns the thread
/// id ONLY for a genuine forum-topic message. DMs, non-forum groups, the
/// General topic, and plain reply-threads report `is_topic_message == false`
/// (or no thread id) and resolve to `None`, so they keep sharing the base
/// `[chat:<id>]` session exactly as before. Gating on `is_topic_message` is
/// deliberate: a bare `thread_id` can appear on ordinary reply-threads, which
/// must NOT spawn isolated sessions.
/// Canonical Telegram thread id of a forum group's built-in General topic.
/// General messages carry NO explicit `message_thread_id` and are not flagged
/// `is_topic_message`, which historically made them indistinguishable from
/// DMs / non-forum groups (#1220).
pub const GENERAL_TOPIC_ID: i32 = 1;
/// The `message_thread_id` to put on the wire for a session scoped to `topic`.
///
/// [`GENERAL_TOPIC_ID`] is a SESSION-SCOPING KEY, never an address. #1220
/// invented it so a forum's General topic would not collide with a DM on the
/// same chat, and that is right — but General messages carry no
/// `message_thread_id` at all, so sending `1` asks Telegram for a thread that
/// does not exist and every message-CREATING call is refused with
/// `Bad Request: message thread not found` (#1319).
///
/// Edits hid this for a long time: they address a `message_id` and never
/// consult a thread, so a General-bound session kept updating its status
/// cards in place and only failed the moment it had something new to say.
///
/// Every sender goes through here rather than converting the stored topic
/// itself. Six call sites were each doing their own `ThreadId(MessageId(t))`,
/// and patching six is an invitation for a seventh to reintroduce it.
/// Where a push that BELONGS to a session must be delivered (pure, #1200).
///
/// The distinction the delivery paths kept losing is THREE-state, and it has
/// to survive the trip from the binding to the wire:
///
/// * `Bound(Some(topic))` — the session lives in a real forum topic.
/// * `Bound(None)` — the session lives in the General topic of a forum, or in
/// a DM / non-forum group. Both are addressed by the ABSENCE of a thread
/// (#1319): General's `Some(1)` is a scoping key, never an address.
/// * `Unbound` — no binding exists for this session anywhere, and only then
/// is the chat's most recent topic the best answer available.
///
/// `TelegramState::session_topic` flattens the first two arms into one `None`
/// (its `.flatten()` collapses `Some(None)` and a missing entry), so a caller
/// that resolves through it and then falls back to the chat-wide lookup sends
/// a General-bound session's message into whichever topic spoke last. That is
/// the defect this function exists to make unrepeatable: on 2026-09-21 the
/// hourly digest of the "Общее / Неразобранное" topic (session bound to
/// General) was delivered into the project topic that happened to be the most
/// recent speaker.
///
/// `in_memory` is the live binding (`TelegramState::session_binding`), which
/// the connect-time re-registration of #1224 fills from the durable rows;
/// `durable` is the `session_bindings` row itself, read only when the maps
/// hold nothing for the session (a push can arrive before that re-registration
/// has run). A binding for a DIFFERENT chat is not this chat's business, so
/// both are matched against `chat_id`.
/// Decide [`PushTarget`] from the two stores, pure (#1200).
/// #1220: normalize a raw topic resolution for KNOWN forum chats.
///
/// `raw` is the output of [`topic_session_id`]; `known_forum` is the
/// evidence-based verdict that this chat has hosted at least one
/// thread-scoped message (see `TelegramState::note_thread_evidence`).
///
/// In a known forum, a message with no explicit topic IS the General topic,
/// not "no topic" — so it gets its own session bucket keyed on
/// `Some(GENERAL_TOPIC_ID)` instead of collapsing into the base
/// `[chat:<id>]` session. This makes bg-task / subagent pushes (#1200)
/// route back to General instead of falling to the chat-wide-latest
/// lookup and landing in whatever topic spoke last.
///
/// Cold start: until the chat's first thread-scoped message is observed
/// (in-memory cache), `known_forum` is false and behaviour is unchanged.
/// Session-scoping topic id for ANY Telegram event: an incoming message or an
/// inline-button callback. Single entry point so the two paths cannot drift.
///
/// They did drift (#1248): ingress composed
/// `normalize_topic(topic_session_id(..), known_forum)` while the callback
/// resolver called `topic_session_id` alone. In a known forum the General
/// topic then resolved `None` for button presses and `Some(GENERAL_TOPIC_ID)`
/// for messages, so `/models` taps looked up `(chat, None)` — the pre-#1220
/// base session — and wrote the provider/model pick into a row that serves no
/// messages, while the chat kept answering on its real `(chat, Some(1))`
/// session. The switch appeared to be silently ignored.
/// True when a session exceeded the configured idle window (same rule as handler suffix path).
/// Handler resolve policy: explicit chat binding wins over suffix `updated_at` winner.
///
/// Cold-start behaviour: the `chat_sessions` map that feeds `chat_bound`
/// is in-memory and process-scoped — every opencrabs restart starts the
/// map empty. The first message in any chat after a restart therefore
/// returns `ResolveSource::Suffix` (no binding yet) and falls through to
/// `find_session_by_title_suffix`. That's correct behaviour, just worth
/// being aware of when reading the policy in isolation: an empty map
/// doesn't mean "create a new session", it means "look up the existing
/// row by suffix".
/// Whether to overwrite a stored session title with the freshly built template.
///
/// - Default DM titles: refresh when the template default changed (display name).
/// - Auto-titled / custom DM titles: never clobber (issue #121).
/// - Telegram groups: refresh when the visible group label changed (suffix stable).