opencrabs 0.3.62

The autonomous, self-improving AI agent. Single Rust binary. Every channel. Install with: cargo install opencrabs
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
//! Raw-aware Telegram update polling (#354).
//!
//! teloxide parses each update into typed structs and silently DISCARDS any
//! content field it does not know. A forward of a rich-formatted message
//! (new Bot API content types) therefore reaches the process and dies at the
//! parse boundary: the handler sees a message with no text, no media, and —
//! depending on how the unknown content deserializes — not even the forward
//! metadata, while the user watches the content sit fully rendered in their
//! chat.
//!
//! This listener fetches updates as RAW JSON first, stashes every message's
//! raw payload keyed by (chat_id, message_id), and only then converts to the
//! typed `Update` for the normal dispatcher. When the typed accessors come
//! up empty, the handler pulls the raw payload from the stash and hands the
//! actual content to the agent instead of dropping the message.

use std::collections::VecDeque;
use std::sync::Mutex;

use serde_json::Value;
use teloxide::stop::{StopFlag, StopToken, mk_stop_token};
use teloxide::types::Update;
use teloxide::update_listeners::StatefulListener;

/// Bounded stash of raw message payloads keyed by (chat_id, message_id).
/// 128 entries is minutes of traffic — the handler consumes an entry within
/// milliseconds of the dispatcher receiving the typed update.
static RAW_STASH: Mutex<VecDeque<((i64, i32), Value)>> = Mutex::new(VecDeque::new());
const STASH_CAP: usize = 128;

pub(crate) fn stash_raw_message(chat_id: i64, message_id: i32, raw: Value) {
    let mut q = RAW_STASH.lock().unwrap_or_else(|e| e.into_inner());
    q.retain(|((c, m), _)| !(*c == chat_id && *m == message_id));
    q.push_back(((chat_id, message_id), raw));
    while q.len() > STASH_CAP {
        q.pop_front();
    }
}

pub(crate) fn take_raw_message(chat_id: i64, message_id: i32) -> Option<Value> {
    let mut q = RAW_STASH.lock().unwrap_or_else(|e| e.into_inner());
    let idx = q
        .iter()
        .position(|((c, m), _)| *c == chat_id && *m == message_id)?;
    q.remove(idx).map(|(_, v)| v)
}

/// Forward origin from a RAW message payload — works even when teloxide's
/// typed parse dropped it along with the unknown content type.
pub(crate) fn raw_forward_origin(raw: &Value) -> Option<String> {
    let origin = raw.get("forward_origin")?;
    if let Some(u) = origin.get("sender_user") {
        let mut label = u
            .get("first_name")
            .and_then(|v| v.as_str())
            .unwrap_or("someone")
            .to_string();
        if let Some(last) = u.get("last_name").and_then(|v| v.as_str()) {
            label.push(' ');
            label.push_str(last);
        }
        if u.get("is_bot").and_then(|v| v.as_bool()).unwrap_or(false) {
            label.push_str(" (bot)");
        }
        return Some(label);
    }
    if let Some(name) = origin.get("sender_user_name").and_then(|v| v.as_str()) {
        return Some(name.to_string());
    }
    for key in ["sender_chat", "chat"] {
        if let Some(title) = origin
            .get(key)
            .and_then(|c| c.get("title"))
            .and_then(|v| v.as_str())
        {
            return Some(title.to_string());
        }
    }
    Some("an unknown origin".to_string())
}

/// Render a raw message payload for the agent: drop the noisy envelope keys
/// the agent never needs (sender/chat objects, ids, dates) and pretty-print
/// the rest — that is where the content of an unknown type lives. Truncated
/// so a huge payload cannot blow up the turn.
pub(crate) fn raw_content_for_agent(raw: &Value) -> String {
    const DROP_KEYS: &[&str] = &[
        "from",
        "chat",
        "date",
        "message_id",
        "edit_date",
        "forward_origin",
        "has_protected_content",
        "sender_chat",
        "via_bot",
        "message_thread_id",
    ];
    let mut content = raw.clone();
    if let Some(obj) = content.as_object_mut() {
        for k in DROP_KEYS {
            obj.remove(*k);
        }
    }
    let pretty = serde_json::to_string_pretty(&content).unwrap_or_else(|_| content.to_string());
    crate::utils::truncate_str(&pretty, 4096).to_string()
}

struct RawPollState {
    http: reqwest::Client,
    token: String,
    offset: i64,
    pending: VecDeque<Update>,
    flag: StopFlag,
    stop_token: StopToken,
}

/// One getUpdates long-poll: stash raw message payloads, queue the typed
/// updates. Errors are logged and absorbed with a short backoff — the outer
/// dispatcher retry loop still guards against total failure.
async fn poll_once(st: &mut RawPollState) {
    let url = format!("https://api.telegram.org/bot{}/getUpdates", st.token);
    let body = serde_json::json!({
        "timeout": 30,
        "offset": st.offset,
        "allowed_updates": ["message", "edited_message", "callback_query", "message_reaction"],
    });
    let resp = st
        .http
        .post(&url)
        .json(&body)
        .timeout(std::time::Duration::from_secs(40))
        .send()
        .await;
    let value: Value = match resp {
        Ok(r) => match r.json().await {
            Ok(v) => v,
            Err(e) => {
                tracing::warn!("Telegram raw poll: body read failed: {e}");
                tokio::time::sleep(std::time::Duration::from_secs(3)).await;
                return;
            }
        },
        Err(e) => {
            tracing::warn!("Telegram raw poll: request failed: {e}");
            tokio::time::sleep(std::time::Duration::from_secs(3)).await;
            return;
        }
    };
    if value.get("ok").and_then(|v| v.as_bool()) != Some(true) {
        tracing::warn!(
            "Telegram raw poll: getUpdates not ok: {}",
            crate::utils::truncate_str(&value.to_string(), 200)
        );
        tokio::time::sleep(std::time::Duration::from_secs(3)).await;
        return;
    }
    let Some(updates) = value.get("result").and_then(|v| v.as_array()) else {
        return;
    };
    for u in updates {
        let update_id = u.get("update_id").and_then(|v| v.as_i64()).unwrap_or(-1);
        st.offset = st.offset.max(update_id + 1);
        let mut u = u.clone();
        // Stash the raw message BEFORE the typed parse can lose content, and
        // SYNTHESIZE readable text into messages whose content type this Bot
        // API client does not know: without a known content field the update
        // either parses into an empty message or into an Error kind no
        // dispatch branch matches — both ended as silence (#354). Rewriting
        // the unknown content as a plain text message makes it flow through
        // the ENTIRE normal pipeline (handler, agent, context, display).
        for key in ["message", "edited_message"] {
            if let Some(m) = u.get_mut(key) {
                let chat_id = m
                    .get("chat")
                    .and_then(|c| c.get("id"))
                    .and_then(|v| v.as_i64());
                let msg_id = m.get("message_id").and_then(|v| v.as_i64());
                if let (Some(c), Some(mid)) = (chat_id, msg_id) {
                    stash_raw_message(c, mid as i32, m.clone());
                }
                synthesize_unknown_content(m);
            }
        }
        // MUST be from_str: teloxide's Update deserializer only works from
        // string input — from_value yields UpdateKind::Error for EVERYTHING
        // (proven in telegram_raw_update_parse_test; it took the intake down).
        match serde_json::from_str::<Update>(&u.to_string()) {
            Ok(update) => {
                tracing::debug!(
                    "Telegram raw poll: update {update_id} parsed, kind={}",
                    update_kind_name(&update)
                );
                st.pending.push_back(update);
            }
            Err(e) => {
                // The raw payload is stashed; only the typed dispatch is
                // impossible. Loudly logged — never silently eaten.
                tracing::error!(
                    "Telegram raw poll: update {update_id} failed typed parse ({e}) — raw \
                     stashed, typed dispatch skipped: {}",
                    crate::utils::truncate_str(&u.to_string(), 300)
                );
            }
        }
    }
    if !updates.is_empty() {
        tracing::info!(
            "Telegram raw poll: batch of {} update(s), offset now {}",
            updates.len(),
            st.offset
        );
    }
}

/// Message JSON keys that count as KNOWN content — if any is present the
/// typed parse can represent the message and nothing needs synthesizing.
/// Service-event keys are included so pins/joins/topic events keep their
/// normal (ignored) handling instead of being rewritten into text.
const KNOWN_CONTENT_KEYS: &[&str] = &[
    "text",
    "caption",
    "photo",
    "voice",
    "video",
    "document",
    "animation",
    "video_note",
    "sticker",
    "audio",
    "poll",
    "location",
    "contact",
    "venue",
    "dice",
    "game",
    "story",
    "invoice",
    "successful_payment",
    "new_chat_members",
    "left_chat_member",
    "pinned_message",
    "new_chat_title",
    "new_chat_photo",
    "delete_chat_photo",
    "group_chat_created",
    "supergroup_chat_created",
    "channel_chat_created",
    "message_auto_delete_timer_changed",
    "migrate_to_chat_id",
    "migrate_from_chat_id",
    "forum_topic_created",
    "forum_topic_closed",
    "forum_topic_reopened",
    "forum_topic_edited",
    "video_chat_started",
    "video_chat_ended",
    "video_chat_scheduled",
    "video_chat_participants_invited",
    "web_app_data",
    "proximity_alert_triggered",
    // Known to teloxide-core 0.13 (verified against its serde definitions):
    // these parse into typed kinds, so they must NOT take the synthesize
    // detour; content-carrying ones are rendered readably by `rich_decode`
    // in the handler's no-content branch (#359).
    "checklist",
    "checklist_tasks_done",
    "checklist_tasks_added",
    "paid_media",
    "giveaway",
    "giveaway_created",
    "giveaway_winners",
    "giveaway_completed",
    "gift",
    "unique_gift",
    "users_shared",
    "chat_shared",
    "refunded_payment",
    "boost_added",
    "chat_background_set",
    "connected_website",
    "write_access_allowed",
    "passport_data",
    "direct_message_price_changed",
    "paid_message_price_changed",
    "general_forum_topic_hidden",
    "general_forum_topic_unhidden",
];

/// If the raw message carries NONE of the known content keys, rewrite it in
/// place into a plain text message whose text is the raw content payload
/// (plus forward provenance), and drop the unknown keys so the typed parse
/// lands on a normal text message.
fn synthesize_unknown_content(m: &mut Value) {
    let Some(obj) = m.as_object_mut() else { return };
    if KNOWN_CONTENT_KEYS.iter().any(|k| obj.contains_key(*k)) {
        return;
    }
    // Envelope-only keys: what remains beyond these IS the unknown content.
    const ENVELOPE: &[&str] = &[
        "message_id",
        "from",
        "chat",
        "date",
        "edit_date",
        "forward_origin",
        "forward_from",
        "forward_date",
        "reply_to_message",
        "via_bot",
        "sender_chat",
        "message_thread_id",
        "is_topic_message",
        "has_protected_content",
        "author_signature",
        "entities",
        "link_preview_options",
        "quote",
        "external_reply",
        "effect_id",
        "is_from_offline",
        "business_connection_id",
        "sender_boost_count",
        "sender_business_bot",
        "is_automatic_forward",
        "paid_star_count",
    ];
    let unknown: serde_json::Map<String, Value> = obj
        .iter()
        .filter(|(k, _)| !ENVELOPE.contains(&k.as_str()))
        .map(|(k, v)| (k.clone(), v.clone()))
        .collect();
    if unknown.is_empty() {
        // Nothing beyond the envelope at all — a service shape we don't
        // recognize; leave it for the normal (ignore) path.
        return;
    }
    let origin_note = raw_forward_origin(m)
        .map(|o| format!(" forwarded from \"{o}\""))
        .unwrap_or_default();
    // A future unknown type may still carry a payload a decoder recognizes;
    // try readable decoding first, fall back to the raw-JSON dump (#359).
    let text = match super::rich_decode::decode_rich_content(m) {
        Some(decoded) => format!("[A rich message{origin_note}]:\n{decoded}"),
        None => {
            let pretty = serde_json::to_string_pretty(&Value::Object(unknown.clone()))
                .unwrap_or_else(|_| "<unrenderable>".to_string());
            format!(
                "[A message{origin_note} arrived in a format the Bot API client cannot \
                 decode natively. Its raw content payload follows — read the content \
                 directly from it:]\n```json\n{}\n```",
                crate::utils::truncate_str(&pretty, 4096)
            )
        }
    };
    tracing::warn!(
        "Telegram raw poll: synthesizing text for unknown content keys {:?} (chat={:?}, msg={:?})",
        unknown.keys().collect::<Vec<_>>(),
        m.get("chat").and_then(|c| c.get("id")),
        m.get("message_id"),
    );
    let obj = m.as_object_mut().expect("checked above");
    for k in unknown.keys() {
        obj.remove(k);
    }
    obj.remove("entities");
    obj.insert("text".to_string(), Value::String(text));
}

/// Short name of an update's kind for the poll debug log.
fn update_kind_name(u: &Update) -> &'static str {
    use teloxide::types::UpdateKind;
    match &u.kind {
        UpdateKind::Message(_) => "message",
        UpdateKind::EditedMessage(_) => "edited_message",
        UpdateKind::CallbackQuery(_) => "callback_query",
        UpdateKind::MessageReaction(_) => "message_reaction",
        UpdateKind::Error(_) => "ERROR(unparsed)",
        _ => "other",
    }
}

/// Stream of typed updates driven by the raw poll loop. A named fn (rather
/// than a closure) so the higher-ranked lifetime `StatefulListener` needs is
/// inferred correctly.
fn raw_update_stream(
    st: &mut RawPollState,
) -> impl futures::Stream<Item = Result<Update, std::convert::Infallible>> + Send + '_ {
    futures::stream::unfold(st, |st| async move {
        loop {
            if st.flag.is_stopped() {
                return None;
            }
            if let Some(u) = st.pending.pop_front() {
                return Some((Ok(u), st));
            }
            poll_once(st).await;
        }
    })
}

fn raw_stop_token(st: &mut RawPollState) -> StopToken {
    st.stop_token.clone()
}

/// Build the raw-aware update listener for the dispatcher.
pub(crate) fn raw_polling_listener(
    token: String,
) -> impl teloxide::update_listeners::UpdateListener<Err = std::convert::Infallible> {
    let (stop_token, flag) = mk_stop_token();
    let state = RawPollState {
        http: reqwest::Client::new(),
        token,
        offset: 0,
        pending: VecDeque::new(),
        flag,
        stop_token,
    };
    StatefulListener::new(state, raw_update_stream, raw_stop_token)
}