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
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
//! Slack binding for the channel-agnostic session-manager proxy (TELUI-6, #2549).
//!
//! Why: the focus state machine and both proxy directions (INJECT free text →
//! session send, SUMMARIZE session activity → channel) are channel-agnostic and
//! live in [`crate::client::proxy`] so Slack reuses the SAME [`SessionProxy`] the
//! Telegram binding (`telegram::focus`) and the daemon's local HTTP proxy routes
//! use. This module is the THIN Slack binding: it maps a Slack conversation
//! (channel, or channel+thread) to the proxy's conversation key and renders the
//! proxy's structured outcomes into Slack `mrkdwn`. No focus state or routing
//! logic lives here — only presentation and the per-conversation key convention.
//! What: [`handle_focus`]/[`handle_unfocus`]/[`handle_summary`]/[`inject_reply`]
//! call the shared [`SessionProxy`] and render its outcome as a `mrkdwn` reply
//! body, escaping every user/backend-controlled field with [`mrkdwn_escape`] (a
//! Slack session named `<!channel> pwned` must never broadcast-ping the channel
//! from a proxy reply — mirrors `telegram::formatter::html_escape`'s escape
//! sites). [`conv`] is the Slack conversation-key convention (channel id, plus
//! the thread timestamp when the message is in a thread); [`effective_conv`]
//! adds thread-to-channel FALLBACK for message routing, so a threaded reply
//! still reaches a channel-level focus. [`ProxyVerb`]/[`proxy_verb`] classify
//! the three slash verbs (`/focus`, `/unfocus`, `/summary`) the adapter routes
//! to the proxy instead of projecting onto a [`crate::client::TrustyCommand`].
//! Test: `focus/tests.rs` covers the render mapping (including hostile-name
//! escaping) for each outcome and the verb/key/fallback conventions; the state
//! machine and daemon paths are covered by `client::proxy::tests`, and the
//! inbound-event routing by `slack::tests`.
use crate;
use ;
/// The prompt shown when a proxy action needs a focused session but none is set.
///
/// Why: `mrkdwn` uses backticks for inline code (not Telegram's `<code>`), so the
/// hint is authored in Slack's own markup rather than HTML.
/// What: a `mrkdwn` string naming the `/focus` verb the operator must run first.
/// Test: `render_inject_no_focus_hints`, `render_summary_no_focus_hints`.
const NO_FOCUS_HINT: &str = "No session is focused — use `/focus <session>` first.";
/// Safety ceiling (raw chars) for the activity summary text in a `/summary` reply.
///
/// Why: Slack's `chat.postMessage` `text` caps at 40,000 chars; a runaway
/// activity digest could exceed it and be rejected. Today's digest contract (a
/// lightweight [`crate::client::ActivityDigest`]) is always short, so this is a
/// DEFENSIVE ceiling for a path that should never be hit — but it is enforced,
/// not merely assumed (mirrors the Telegram binding's `MAX_SUMMARY_CHARS`, sized
/// for Slack's far larger budget).
/// What: the raw-text budget before the summary is embedded in the reply,
/// comfortably below Slack's 40,000-char message limit.
/// Test: `truncate_summary_leaves_short_text_untouched`,
/// `truncate_summary_caps_long_text`.
const MAX_SUMMARY_CHARS: usize = 2000;
/// Truncate `s` to at most [`MAX_SUMMARY_CHARS`] characters, marking truncation.
///
/// Why: factored out of [`render_summary`] so the truncation rule is
/// unit-testable independent of the full `mrkdwn` render.
/// What: returns `s` unchanged (borrowed, no allocation) when within budget;
/// otherwise the first [`MAX_SUMMARY_CHARS`] chars (on a char boundary) plus a
/// `" […truncated]"` marker.
/// Test: `truncate_summary_leaves_short_text_untouched`,
/// `truncate_summary_caps_long_text`.
/// Map a Slack conversation (channel + optional thread) to the proxy's key.
///
/// Why: the proxy keys focus by an opaque channel-supplied string. Slack's
/// conversation identity is the channel id, refined to a thread when a message is
/// posted in one — so a focus set in a thread is scoped to that thread while a
/// focus in the main channel is scoped to the channel. Converting once here keeps
/// the convention out of the handlers.
/// What: returns `channel` when `thread` is `None`, else `channel:thread_ts`.
/// Test: `conv_keys_by_channel_and_thread`.
/// Resolve the proxy conversation key to use for an inbound MESSAGE, applying
/// thread-to-channel focus fallback.
///
/// Why (#2565 review, MEDIUM-HIGH): `/focus` arrives via a slash command with no
/// thread context and always keys by the bare channel ([`conv`]`(channel, None)`).
/// A plain-text reply posted INSIDE a thread keys by
/// [`conv`]`(channel, Some(ts))` — a DIFFERENT key. Without a fallback, that
/// thread key never carries the channel's focus and every threaded reply
/// silently misroutes to the action-capable coordinator instead of the
/// channel-focused session — a silent misroute, not an error. Falling back from
/// the thread key to the bare-channel key, but ONLY when the thread key itself
/// has no focus, restores the expected behavior while preserving thread-scoped
/// focus when a thread was explicitly `/focus`ed (checked first, so a
/// thread-specific focus always wins over the channel's).
/// What: with no `thread`, [`conv`]`(channel, thread)` already equals the bare
/// channel key, so this is a no-op passthrough. With a `thread`: if the
/// thread-scoped key already has a focus, returns it unchanged (thread wins).
/// Otherwise, if the bare-channel key has a focus, returns THAT key instead
/// (fallback). If neither has a focus, returns the thread-scoped key (so the
/// caller's `current_focus` check reports `None` and free text falls through to
/// the coordinator, same as today). This is for MESSAGE routing (`inject`/
/// `summarize`) only — `/focus`/`/unfocus` always target the caller's literal
/// `conv` and never fall back.
/// Test: `effective_conv_falls_back_to_channel_when_thread_unfocused`,
/// `effective_conv_prefers_thread_when_thread_focused`,
/// `effective_conv_stays_channel_key_with_no_thread`,
/// `effective_conv_thread_key_when_neither_focused`.
/// The three slash verbs the Slack adapter routes to the session-manager PROXY.
///
/// Why: `/focus`, `/unfocus`, and `/summary` drive the per-conversation focus
/// state machine, NOT the daemon command surface — modelling them as a typed verb
/// keeps the adapter's dispatch a thin, exhaustive match instead of scattering
/// string compares (mirrors how the Telegram binding special-cases the same three
/// commands in `on_message`).
/// What: one variant per proxy verb; [`proxy_verb`] parses a raw Slack `command`
/// field into `Some(_)`, or `None` for any other verb (which then falls through
/// to normal `parse_slash` dispatch or free-text routing).
/// Test: `proxy_verb_classifies_the_three_verbs`.
/// Classify a raw Slack slash `command` as a proxy verb, if it is one.
///
/// Why: the adapter must intercept the three proxy verbs BEFORE projecting a
/// slash command onto a [`crate::client::TrustyCommand`], since they have no
/// command-surface equivalent. Centralizing the match keeps the routing thin and
/// the leading-slash / case handling identical to `commands::parse_slash`.
/// What: strips an optional leading `/`, lowercases, and matches `focus` /
/// `unfocus` / `summary`; returns `None` for anything else.
/// Test: `proxy_verb_classifies_the_three_verbs`.
/// Handle `/focus [session]` for a Slack conversation `conv`.
///
/// Why: focusing routes through the shared proxy (which validates the target and
/// captures its id/name); this binding only renders the outcome as Slack `mrkdwn`.
/// What: calls [`SessionProxy::focus`] and renders the [`FocusOutcome`].
/// Test: `handle_focus_empty_hints`, `render_focus_focused_names_session`.
pub async
/// Handle `/unfocus` for a Slack conversation `conv`.
///
/// Why: returns the conversation to fleet-wide chat; the reply names the cleared
/// session so the state change is unambiguous.
/// What: clears the proxy focus and renders a confirmation (or a no-op notice).
/// Test: `handle_unfocus_when_none`, `slash_unfocus_reaches_proxy`.
/// Handle `/summary` for a Slack conversation `conv` — digest the session.
///
/// Why: the SUMMARIZE proxy direction on the Slack surface — read back what the
/// focused session is doing without attaching to tmux.
/// What: calls [`SessionProxy::summarize`] and renders the [`SummarizeOutcome`].
/// Test: `render_summary_ok_shows_state_and_pending`, `slash_summary_reaches_proxy_activity`.
pub async
/// Route a free-text message to the focused session (INJECT) and render the ack.
///
/// Why: the payoff of focus mode on Slack — a plain message becomes a send at the
/// focused session; the proxy handles resolution and dead-session auto-unfocus,
/// this binding renders the result.
/// What: calls [`SessionProxy::inject`] and renders the [`InjectOutcome`].
/// Test: `render_inject_sent_echoes_text`, `message_when_focused_reaches_proxy_send`.
pub async
/// Render a [`FocusOutcome`] as a Slack `mrkdwn` reply.
///
/// Test: `render_focus_focused_names_session`, `render_focus_current_none_hints_usage`,
/// `render_focus_not_found_reports`, `render_focus_escapes_hostile_session_name`.
/// Render an [`InjectOutcome`] as a Slack `mrkdwn` reply.
///
/// Test: `render_inject_sent_echoes_text`, `render_inject_auto_unfocused_signals_gone`,
/// `render_inject_failed_keeps_focus_prose`, `render_inject_no_focus_hints`,
/// `render_inject_escapes_hostile_session_name`.
/// Render a [`SummarizeOutcome`] as a Slack `mrkdwn` reply.
///
/// Test: `render_summary_ok_shows_state_and_pending`, `render_summary_no_focus_hints`,
/// `render_summary_escapes_hostile_fields`.