opencrabs 0.5.1

The autonomous, self-improving AI agent. Single Rust binary. Every channel. Recommended: the 40MB prebuilt binary for macOS, Linux and Windows: https://github.com/adolfousier/opencrabs/releases
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
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
//! The `oc://` target URL scheme and its single resolver (#148).
//!
//! EVERY tool that targets a session or a channel accepts these URLs and
//! funnels through [`resolve_target`] here — this module is the ONLY place
//! that may parse or construct `oc://` strings (the opaqueness wall, D11;
//! enforced by `src/tests/oc_target_opaqueness_test.rs`). Consumers hand
//! the raw string over and get a [`ResolvedTarget`] back, or an error that
//! names the candidates — never a guess.
//!
//! Grammar:
//!
//! ```text
//! oc://session/<uuid | 8-char-prefix>
//! oc://telegram/<chat_id>[/<thread>]     thread=1 => General topic (session-scoping
//!                                        key, #1220) — resolves, delivers threadless;
//!                                        thread=0 is a PARSE ERROR, never coerced.
//! oc://discord/<channel_id>
//! oc://slack/<channel_id>
//! oc://whatsapp/<phone | jid>
//! oc://here                              ambient — resolves against the origin stamp
//! ```
//!
//! The legacy `telegram:<chat>[:<thread>]` deliver_to grammar stays accepted
//! and distinct (no `://`), so existing cron job rows keep working untouched.
//! Non-numeric segments are percent-encoded per RFC 3986 (D11); numeric ids
//! stay bare digits.

use anyhow::{Result, anyhow, bail};
use uuid::Uuid;

use super::telegram::session_resolve::GENERAL_TOPIC_ID;
use crate::brain::tools::OriginTarget;

/// Where a resolved target's delivery should go.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TargetDestination {
    /// Deliver into the session that owns the URL — the delivery surface
    /// re-derives its current binding from the session id at send time.
    Session(Uuid),
    /// Deliver to this concrete channel target (chat id + optional topic).
    /// Cron bakes this form at create time; the owning session id rides
    /// alongside for liveness revalidation (fork #17).
    Channel {
        channel: &'static str,
        chat_id: String,
        thread: Option<i32>,
        session: Option<Uuid>,
    },
}

/// The result of resolving an `oc://` target URL.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedTarget {
    /// The session the URL designates, when the resolver could pin one.
    pub session: Option<Uuid>,
    /// Concrete delivery destination.
    pub destination: TargetDestination,
}

impl ResolvedTarget {
    /// The legacy deliver_to string for this target (`telegram:<chat>[:<t>]`
    /// …) — the concrete form baked into cron job rows. General topics bake
    /// WITHOUT `:1` (#1319): the scoping key never becomes a wire address.
    pub fn deliver_to(&self) -> String {
        match &self.destination {
            TargetDestination::Session(_) => String::new(),
            TargetDestination::Channel {
                channel,
                chat_id,
                thread,
                ..
            } => match thread {
                Some(t) if *t != GENERAL_TOPIC_ID => format!("{channel}:{chat_id}:{t}"),
                _ => format!("{channel}:{chat_id}"),
            },
        }
    }
}

/// Percent-encode a non-numeric URL path segment (D11): everything outside
/// RFC 3986 unreserved set becomes `%XX`. Numeric segments pass through
/// bare — callers must not feed digits here.
pub fn encode_segment(seg: &str) -> String {
    let mut out = String::with_capacity(seg.len());
    for b in seg.bytes() {
        match b {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
                out.push(b as char)
            }
            other => out.push_str(&format!("%{other:02X}")),
        }
    }
    out
}

/// Decode a percent-encoded segment. Invalid escapes are an error — the
/// resolver never guesses at mangled input.
pub fn decode_segment(seg: &str) -> Result<String> {
    let bytes = seg.as_bytes();
    let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
    let mut i = 0;
    while i < bytes.len() {
        if bytes[i] == b'%' {
            let hex = bytes
                .get(i + 1..i + 3)
                .ok_or_else(|| anyhow!("invalid percent-encoding in '{seg}'"))?;
            let s = std::str::from_utf8(hex)
                .map_err(|_| anyhow!("invalid percent-encoding in '{seg}'"))?;
            let byte = u8::from_str_radix(s, 16)
                .map_err(|_| anyhow!("invalid percent-encoding in '{seg}'"))?;
            out.push(byte);
            i += 3;
        } else {
            out.push(bytes[i]);
            i += 1;
        }
    }
    String::from_utf8(out).map_err(|_| anyhow!("segment '{seg}' is not valid UTF-8"))
}

/// Split an `oc://` URL into `(authority, decoded-segments)`. Pure syntax —
/// no channel semantics. This is the ONLY grammar parser for `oc://`.
fn parse_url(url: &str) -> Result<(&str, Vec<String>)> {
    let rest = url
        .strip_prefix("oc://")
        .ok_or_else(|| anyhow!("not an oc:// url: '{url}'"))?;
    let (authority, path) = match rest.split_once('/') {
        Some((a, p)) => (a, p),
        None => (rest, ""),
    };
    if authority.is_empty() {
        bail!("empty authority in '{url}'");
    }
    let segments: Vec<String> = if path.is_empty() {
        Vec::new()
    } else {
        path.split('/')
            .map(decode_segment)
            .collect::<Result<Vec<_>>>()?
    };
    Ok((authority, segments))
}

/// The `here` token and its URL form — both resolve against the ambient
/// origin stamp. A cron turn (headless) has no origin and must refuse.
pub const HERE_TOKEN: &str = "here";

/// Is this string an `oc://` URL or the `here` token? Cheap syntax check so
/// consumers can decide whether to route through the resolver without
/// parsing the contents themselves (opaqueness wall — they still may not).
pub fn is_target_url(s: &str) -> bool {
    s.starts_with("oc://") || s == HERE_TOKEN
}

/// What the resolver needs from the world. Implemented by the wiring context
/// (ChannelManager + session lookup) so tests can drive resolution without
/// a live daemon.
#[async_trait::async_trait]
pub trait TargetResolution: Send + Sync {
    /// The session bound to `(channel, chat_id, thread)` — reverse
    /// ownership map. `None` when no session currently owns it.
    async fn session_for_channel(
        &self,
        channel: &str,
        chat_id: &str,
        thread: Option<i32>,
    ) -> Option<Uuid>;

    /// The channel target a session is currently bound to (forward map) —
    /// used to confirm a resolved session still owns a live channel.
    async fn binding_for_session(&self, session: Uuid) -> Option<OriginTarget>;

    /// The session bound to an `oc://telegram/<chat>` URL with NO thread:
    /// `Ok(None)` when the chat simply has no binding, `Err(list)` when the
    /// chat is a forum with MULTIPLE topic sessions — the caller must pick
    /// a topic; bare-chat resolution would guess (D10/D9 multi-topic rule).
    async fn telegram_chat_topics(&self, chat_id: i64) -> Result<Option<Vec<i32>>>;
}

/// Resolve an `oc://` URL (or the `here` token) to a concrete target.
///
/// Resolution order: `here`/ambient origin → session authority (exact uuid
/// fast-path, then case-insensitive 8-char prefix with the 0/1/many law) →
/// channel authority via the reverse ownership map. Errors name candidates
/// or the reason — resolution never guesses.
pub async fn resolve_target(
    url: &str,
    origin: Option<&OriginTarget>,
    world: &(impl TargetResolution + ?Sized),
    sessions: &[crate::db::models::Session],
) -> Result<ResolvedTarget> {
    // `here` — ambient origin only. No origin (cron, CLI, sub-agent) is a
    // clean refusal: a headless surface has no "here".
    if url == HERE_TOKEN {
        return match origin {
            None => bail!(
                "'here' has no current channel on this surface — pass an explicit oc:// target"
            ),
            Some(o) => Ok(ResolvedTarget {
                session: world
                    .session_for_channel(o.channel, &o.chat_id, o.thread)
                    .await,
                destination: TargetDestination::Channel {
                    channel: o.channel,
                    chat_id: o.chat_id.to_string(),
                    thread: o.thread,
                    session: world
                        .session_for_channel(o.channel, &o.chat_id, o.thread)
                        .await,
                },
            }),
        };
    }

    let (authority, segments) = parse_url(url)?;

    match authority {
        "session" => {
            let id = segments
                .first()
                .ok_or_else(|| anyhow!("oc://session needs an id: '{url}'"))?;
            // Exact-uuid fast path, then prefix (0/1/many, never guess).
            let session = if let Ok(u) = Uuid::parse_str(id) {
                sessions
                    .iter()
                    .find(|s| s.id == u)
                    .map(|s| s.id)
                    .ok_or_else(|| anyhow!("no session with id '{id}'"))?
            } else {
                // Prefix law lives in one place (#1340): reuse it verbatim so
                // the ambiguity rules cannot drift from the CLI resolvers.
                crate::cli::session_resolve::resolve_one_by_prefix(sessions, id)
                    .map_err(|e| anyhow!("{e}"))?
            };
            let binding = world.binding_for_session(session).await;
            Ok(ResolvedTarget {
                session: Some(session),
                destination: match binding {
                    Some(b) => TargetDestination::Channel {
                        channel: b.channel,
                        chat_id: b.chat_id,
                        thread: b.thread,
                        session: Some(session),
                    },
                    None => TargetDestination::Session(session),
                },
            })
        }
        "telegram" => {
            let chat_id = segments
                .first()
                .ok_or_else(|| anyhow!("oc://telegram needs a chat id: '{url}'"))?;
            let _chat: i64 = chat_id
                .parse()
                .map_err(|_| anyhow!("telegram chat id must be numeric: '{chat_id}'"))?;
            let thread: Option<i32> = match segments.get(1) {
                None => None,
                Some(t) => {
                    let t: i32 = t
                        .parse()
                        .map_err(|_| anyhow!("telegram thread must be numeric: '{t}'"))?;
                    if t == 0 {
                        // D10: `0` means nothing in the Telegram API or this
                        // codebase — refuse, never coerce to General/bare.
                        bail!("thread id 0 is not a valid Telegram topic (General is 1)");
                    }
                    Some(t)
                }
            };
            let session = world.session_for_channel("telegram", chat_id, thread).await;
            // Bare-chat URL on a forum chat: ambiguous by design (D9/D10).
            if thread.is_none()
                && session.is_none()
                && let Ok(cid) = chat_id.parse::<i64>()
                && let Some(topics) = world.telegram_chat_topics(cid).await?
                && topics.len() > 1
            {
                bail!(
                    "chat {chat_id} is a forum with multiple topic sessions — \
                     pick a topic: {}",
                    topics
                        .iter()
                        .map(|t| format!("oc://telegram/{chat_id}/{t}"))
                        .collect::<Vec<_>>()
                        .join(", ")
                );
            }
            Ok(ResolvedTarget {
                session,
                destination: TargetDestination::Channel {
                    channel: "telegram",
                    chat_id: chat_id.to_string(),
                    thread,
                    session,
                },
            })
        }
        "discord" | "slack" | "whatsapp" => {
            let chat_id = segments
                .first()
                .ok_or_else(|| anyhow!("oc://{authority} needs a channel id: '{url}'"))?;
            if chat_id.is_empty() {
                bail!("empty channel id in '{url}'");
            }
            // WhatsApp: accept a bare phone number as sugar for the JID.
            let id = if authority == "whatsapp" && !chat_id.contains('@') {
                let digits = chat_id.trim_start_matches('+');
                if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
                    bail!("whatsapp target must be a phone number or a JID: '{chat_id}'");
                }
                format!("{digits}@s.whatsapp.net")
            } else {
                chat_id.clone()
            };
            let session = world.session_for_channel(authority, &id, None).await;
            Ok(ResolvedTarget {
                session,
                destination: TargetDestination::Channel {
                    channel: authority_static(authority)?,
                    chat_id: id,
                    thread: None,
                    session,
                },
            })
        }
        other => {
            bail!("unknown oc:// authority '{other}' (session|telegram|discord|slack|whatsapp)")
        }
    }
}

fn authority_static(a: &str) -> Result<&'static str> {
    Ok(match a {
        "discord" => "discord",
        "slack" => "slack",
        "whatsapp" => "whatsapp",
        other => bail!("unknown authority '{other}'"),
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    fn sess(id: Uuid, title: &str) -> crate::db::models::Session {
        let mut s = crate::db::models::Session {
            id: Uuid::nil(),
            title: None,
            model: None,
            provider_name: None,
            created_at: chrono::Utc::now(),
            updated_at: chrono::Utc::now(),
            archived_at: None,
            token_count: 0,
            total_cost: 0.0,
            working_directory: None,
            auto_title_attempted: false,
            project_id: None,
        };
        s.id = id;
        s.title = Some(title.to_string());
        s
    }

    /// In-memory `TargetResolution` for syntax + ambiguity tests.
    struct FakeWorld {
        bindings: std::collections::HashMap<(String, String, Option<i32>), Uuid>,
        forward: std::collections::HashMap<Uuid, OriginTarget>,
        forum_topics: std::collections::HashMap<i64, Vec<i32>>,
    }

    impl FakeWorld {
        fn new() -> Self {
            Self {
                bindings: Default::default(),
                forward: Default::default(),
                forum_topics: Default::default(),
            }
        }
        fn bind(&mut self, ch: &str, chat: &str, t: Option<i32>, s: Uuid) {
            self.bindings
                .insert((ch.to_string(), chat.to_string(), t), s);
        }
    }

    #[async_trait::async_trait]
    impl TargetResolution for FakeWorld {
        async fn session_for_channel(
            &self,
            channel: &str,
            chat_id: &str,
            thread: Option<i32>,
        ) -> Option<Uuid> {
            self.bindings
                .get(&(channel.to_string(), chat_id.to_string(), thread))
                .copied()
        }
        async fn binding_for_session(&self, session: Uuid) -> Option<OriginTarget> {
            self.forward.get(&session).cloned()
        }
        async fn telegram_chat_topics(&self, chat_id: i64) -> Result<Option<Vec<i32>>> {
            Ok(self.forum_topics.get(&chat_id).cloned())
        }
    }

    #[tokio::test]
    async fn here_without_origin_is_refused() {
        let w = FakeWorld::new();
        let e = resolve_target("here", None, &w, &[]).await.unwrap_err();
        assert!(e.to_string().contains("no current channel"), "{e}");
    }

    #[tokio::test]
    async fn here_resolves_from_origin() {
        let mut w = FakeWorld::new();
        let s = Uuid::new_v4();
        w.bind("telegram", "-100123", Some(GENERAL_TOPIC_ID), s);
        let origin = OriginTarget {
            channel: "telegram",
            chat_id: "-100123".into(),
            thread: Some(GENERAL_TOPIC_ID),
        };
        let r = resolve_target("here", Some(&origin), &w, &[])
            .await
            .unwrap();
        assert_eq!(r.deliver_to(), "telegram:-100123");
    }

    #[tokio::test]
    async fn telegram_thread_zero_is_parse_error() {
        let w = FakeWorld::new();
        let e = resolve_target("oc://telegram/-100123/0", None, &w, &[])
            .await
            .unwrap_err();
        assert!(e.to_string().contains("not a valid Telegram topic"), "{e}");
    }

    #[tokio::test]
    async fn telegram_general_resolves_and_delivers_threadless() {
        let mut w = FakeWorld::new();
        let s = Uuid::new_v4();
        w.bind("telegram", "-100123", Some(GENERAL_TOPIC_ID), s);
        let r = resolve_target("oc://telegram/-100123/1", None, &w, &[])
            .await
            .unwrap();
        assert_eq!(r.session, Some(s));
        assert_eq!(r.deliver_to(), "telegram:-100123");
    }

    #[tokio::test]
    async fn telegram_real_thread_bakes_with_thread() {
        let mut w = FakeWorld::new();
        let s = Uuid::new_v4();
        w.bind("telegram", "-100123", Some(42), s);
        let r = resolve_target("oc://telegram/-100123/42", None, &w, &[])
            .await
            .unwrap();
        assert_eq!(r.session, Some(s));
        assert_eq!(r.deliver_to(), "telegram:-100123:42");
    }

    #[tokio::test]
    async fn telegram_bare_chat_on_multi_topic_forum_is_ambiguous() {
        let mut w = FakeWorld::new();
        w.forum_topics.insert(-100123, vec![GENERAL_TOPIC_ID, 42]);
        let e = resolve_target("oc://telegram/-100123", None, &w, &[])
            .await
            .unwrap_err();
        assert!(e.to_string().contains("multiple topic sessions"), "{e}");
        assert!(e.to_string().contains("oc://telegram/-100123/42"));
    }

    #[tokio::test]
    async fn session_prefix_ambiguity_lists_candidates() {
        let a = Uuid::new_v4();
        let b = Uuid::new_v4();
        let w = FakeWorld::new();
        let shared = format!("{:08x}", a.as_u128() >> 96);
        // Force a shared 8-char prefix by using the same UUID twice — instead
        // assert the single-match and no-match paths.
        let r = resolve_target(
            &format!("oc://session/{a}"),
            None,
            &w,
            &[sess(a, "one"), sess(b, "two")],
        )
        .await
        .unwrap();
        assert_eq!(r.session, Some(a));
        let e = resolve_target("oc://session/zzzzzzzz", None, &w, &[sess(a, "one")])
            .await
            .unwrap_err();
        assert!(e.to_string().contains("no session"), "{e}");
        let _ = shared; // prefix-shaping documented above
    }

    #[tokio::test]
    async fn whatsapp_phone_normalizes_to_jid() {
        let mut w = FakeWorld::new();
        let s = Uuid::new_v4();
        w.bind("whatsapp", "79991234567@s.whatsapp.net", None, s);
        let r = resolve_target("oc://whatsapp/+79991234567", None, &w, &[])
            .await
            .unwrap();
        assert_eq!(r.session, Some(s));
        assert_eq!(r.deliver_to(), "whatsapp:79991234567@s.whatsapp.net");
    }

    #[tokio::test]
    async fn unknown_authority_and_bad_paths_are_errors() {
        let w = FakeWorld::new();
        assert!(resolve_target("oc://irc/1", None, &w, &[]).await.is_err());
        assert!(
            resolve_target("oc://telegram/", None, &w, &[])
                .await
                .is_err()
        );
        assert!(
            resolve_target("https://example.com", None, &w, &[])
                .await
                .is_err()
        );
    }

    #[test]
    fn encoding_roundtrip() {
        let raw = "kanban board/2";
        let enc = encode_segment(raw);
        assert!(!enc.contains(' '));
        assert_eq!(decode_segment(&enc).unwrap(), raw);
        assert!(decode_segment("%zz").is_err());
        assert!(decode_segment("%4").is_err());
    }
}