Skip to main content

netget/server/torrent_tracker/
actions.rs

1//! BitTorrent Tracker protocol actions implementation
2
3use crate::llm::actions::{
4    protocol_trait::{ActionResult, Protocol, Server},
5    ActionDefinition, Parameter,
6};
7use crate::protocol::log_template::LogTemplate;
8use crate::protocol::EventType;
9use crate::state::app_state::AppState;
10use anyhow::{Context, Result};
11use serde_json::json;
12use std::sync::LazyLock;
13
14pub struct TorrentTrackerProtocol;
15
16impl TorrentTrackerProtocol {
17    pub fn new() -> Self {
18        Self
19    }
20}
21
22// Implement Protocol trait (common functionality)
23impl Protocol for TorrentTrackerProtocol {
24    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
25        Vec::new()
26    }
27    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
28        vec![
29            SEND_ANNOUNCE_RESPONSE_ACTION.clone(),
30            SEND_SCRAPE_RESPONSE_ACTION.clone(),
31            SEND_ERROR_RESPONSE_ACTION.clone(),
32        ]
33    }
34    fn protocol_name(&self) -> &'static str {
35        "Torrent-Tracker"
36    }
37    fn get_event_types(&self) -> Vec<EventType> {
38        vec![
39            TRACKER_ANNOUNCE_REQUEST_EVENT.clone(),
40            TRACKER_SCRAPE_REQUEST_EVENT.clone(),
41        ]
42    }
43    fn stack_name(&self) -> &'static str {
44        "ETH>IP>TCP>HTTP>BitTorrent-Tracker"
45    }
46    fn keywords(&self) -> Vec<&'static str> {
47        vec!["torrent-tracker", "tracker", "bittorrent-tracker"]
48    }
49    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
50        use crate::protocol::metadata::{
51            DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
52        };
53
54        ProtocolMetadataV2::builder()
55            .state(DevelopmentState::Beta)
56            .privilege_requirement(PrivilegeRequirement::None)
57            .well_known_port(6969)
58            .implementation("HTTP server with bencode response encoding (serde_bencode)")
59            .llm_control("Peer list generation, announce/scrape responses")
60            .e2e_testing(
61                "REAL THIRD-PARTY CLIENT: aria2c 1.37.0 (a C++ BitTorrent implementation) in \
62                 tests/server/torrent_tracker/real_client_test.rs. NOT #[ignore]d and NOT \
63                 skip-gated - both tests FAIL, naming `brew install aria2`, when the binary is \
64                 absent. Not circular: the server hand-rolls its HTTP on a raw TcpListener and \
65                 encodes with serde_bencode; aria2 links neither. aria2 announces with its own \
66                 info_hash and peer_id (the mock rule MATCHES ON BOTH, so a percent-decode or \
67                 hex-encode error means the rule never fires and the test fails in both halves), \
68                 and then DECODES OUR COMPACT PEER LIST - a bencode byte string, six bytes per \
69                 peer, four address octets plus a big-endian port, with nothing self-describing \
70                 in it - which the test asserts by requiring aria2 to dial 10.0.0.1:6881 AND \
71                 10.0.0.2:6882, so a wrong byte order, stride or length loses a peer and fails. \
72                 `compact=1` is HARDCODED in aria2 and is the branch a real swarm always takes; \
73                 no earlier test drove it against a real client. The second test asserts aria2 \
74                 printing `Tracker returned failure reason: <our text>`, i.e. its own parse of \
75                 our bencoded refusal dict inside an HTTP 200. \
76                 This field previously claimed 'Real BitTorrent clients (transmission, aria2)' \
77                 when nothing in the tree referenced either, and was then corrected to say no \
78                 real client existed; aria2 is now genuinely driven. Alongside: e2e_test, \
79                 llm_failure_test and peer_inject_test cover announce/scrape round trips, a \
80                 send_error_response refusal, connection stats and the LLM-failure reply \
81                 (503/500 + bencoded `failure reason`, asserted to leak nothing internal). \
82                 NOT PROVEN: the DICTIONARY peer form against a real client (aria2 can parse it \
83                 but never requests it, so only hand-written tests reach that branch); IPv6 and \
84                 BEP 7 `peers6`, which are NOT IMPLEMENTED; multi-info_hash scrape, and /scrape \
85                 against a real client at all, since aria2 does not issue one on its own; a \
86                 completed download, there being no real swarm; and the 400/408 paths."
87            )
88            .notes("Bencode<->JSON conversion, compact peer format. Stores nothing: peers, swarm counts and scrape statistics all come from the model, so successive announces are not correlated unless the model correlates them.")
89            .build()
90    }
91    fn description(&self) -> &'static str {
92        "BitTorrent Tracker server for coordinating peers (announce/scrape)"
93    }
94    fn example_prompt(&self) -> &'static str {
95        "start a bittorrent tracker on port 6969"
96    }
97    fn group_name(&self) -> &'static str {
98        "P2P"
99    }
100
101    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
102        use crate::llm::actions::StartupExamples;
103        use serde_json::json;
104
105        // Deterministic: answer every announce with an empty peer list and a
106        // 30-minute interval, no LLM call.
107        let script = r#"import json, sys
108data = json.load(sys.stdin)
109event = data["event"]
110if data["event_type_id"] == "tracker_announce_request":
111    actions = [{"type": "send_announce_response", "interval": 1800,
112                "complete": 0, "incomplete": 0, "peers": []}]
113else:
114    actions = []
115print(json.dumps({"actions": actions}))"#;
116
117        StartupExamples::new(
118            // LLM mode: LLM handles BitTorrent tracker
119            json!({
120                "type": "open_server",
121                "port": 6969,
122                "base_stack": "torrent-tracker",
123                "instruction": "Act as BitTorrent tracker. Track peers and respond to announce/scrape requests"
124            }),
125            // Script mode: Code-based tracker handling
126            json!({
127                "type": "open_server",
128                "port": 6969,
129                "base_stack": "torrent-tracker",
130                "event_handlers": [{
131                    "event_pattern": "tracker_announce_request",
132                    "handler": {
133                        "type": "script",
134                        "language": "python",
135                        "code": script
136                    }
137                }]
138            }),
139            // Static mode: Fixed tracker responses
140            json!({
141                "type": "open_server",
142                "port": 6969,
143                "base_stack": "torrent-tracker",
144                "event_handlers": [{
145                    "event_pattern": "tracker_announce_request",
146                    "handler": {
147                        "type": "static",
148                        "actions": [{
149                            "type": "send_announce_response",
150                            "interval": 1800,
151                            "complete": 10,
152                            "incomplete": 5,
153                            "compact": "{{event.compact}}",
154                            "peers": [{"ip": "127.0.0.1", "port": 51413}]
155                        }]
156                    }
157                }]
158            }),
159        )
160    }
161}
162
163// Implement Server trait (server-specific functionality)
164impl Server for TorrentTrackerProtocol {
165    fn spawn(
166        &self,
167        ctx: crate::protocol::SpawnContext,
168    ) -> std::pin::Pin<
169        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
170    > {
171        Box::pin(async move {
172            use crate::server::torrent_tracker::TorrentTrackerServer;
173            TorrentTrackerServer::spawn_with_llm_actions(
174                ctx.legacy_listen_addr(),
175                ctx.llm_client,
176                ctx.state,
177                ctx.status_tx,
178                ctx.server_id,
179            )
180            .await
181        })
182    }
183    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
184        let action_type = action
185            .get("type")
186            .and_then(|v| v.as_str())
187            .context("Missing 'type' field in action")?;
188
189        match action_type {
190            "send_announce_response" => self.execute_send_announce_response(action),
191            "send_scrape_response" => self.execute_send_scrape_response(action),
192            "send_error_response" => self.execute_send_error_response(action),
193            // Not advertised to the model — a tracker response already carries
194            // `Connection: close` and the session ends on its own, so there is nothing for
195            // the model to decide here. It exists because the dashboard's
196            // `[ disconnect this peer ]` runs the protocol's **own** `close_connection`
197            // through the peer handle, and without an arm the operator's only way to reach a
198            // parked announce would fail as an unknown action.
199            "close_connection" => Ok(ActionResult::CloseConnection),
200            _ => Err(anyhow::anyhow!(
201                "Unknown BitTorrent Tracker action: {}",
202                action_type
203            )),
204        }
205    }
206}
207
208/// Interpret a JSON value as a BitTorrent "compact" flag.
209///
210/// Clients send `compact=1` in the query string, so the value reaching an action via
211/// `{{event.compact}}` is the number 1, not `true`. Accept both spellings (and the
212/// string forms a model is liable to produce) rather than silently falling back to the
213/// dictionary format that most clients reject.
214fn is_compact(value: Option<&serde_json::Value>) -> bool {
215    match value {
216        Some(serde_json::Value::Bool(b)) => *b,
217        Some(serde_json::Value::Number(n)) => n.as_u64().is_some_and(|n| n != 0),
218        Some(serde_json::Value::String(s)) => {
219            matches!(s.as_str(), "1" | "true" | "yes" | "on")
220        }
221        _ => false,
222    }
223}
224
225impl TorrentTrackerProtocol {
226    fn execute_send_announce_response(&self, action: serde_json::Value) -> Result<ActionResult> {
227        let interval = action
228            .get("interval")
229            .and_then(|v| v.as_u64())
230            .unwrap_or(1800) as i64;
231        let complete = action.get("complete").and_then(|v| v.as_u64()).unwrap_or(0) as i64;
232        let incomplete = action
233            .get("incomplete")
234            .and_then(|v| v.as_u64())
235            .unwrap_or(0) as i64;
236        let compact = is_compact(action.get("compact"));
237
238        let peer_entries = action
239            .get("peers")
240            .and_then(|v| v.as_array())
241            .cloned()
242            .unwrap_or_default();
243
244        // BEP 23: `compact=1` replaces the list of dictionaries with a byte string of
245        // 6-byte entries (4-byte IPv4 + 2-byte big-endian port). Nearly every real client
246        // (transmission, libtorrent, aria2) asks for compact and several refuse the
247        // dictionary form outright, so honouring the flag is what makes this tracker
248        // usable by anything other than a hand-written test client.
249        let peers_value = if compact {
250            let mut bytes = Vec::with_capacity(peer_entries.len() * 6);
251            for peer in &peer_entries {
252                let Some(ip) = peer.get("ip").and_then(|v| v.as_str()) else {
253                    continue;
254                };
255                let Some(port) = peer.get("port").and_then(|v| v.as_u64()) else {
256                    continue;
257                };
258                let Ok(std::net::IpAddr::V4(addr)) = ip.parse::<std::net::IpAddr>() else {
259                    // IPv6 peers need BEP 7's separate `peers6` key; skip rather than
260                    // emit a malformed 6-byte entry.
261                    continue;
262                };
263                bytes.extend_from_slice(&addr.octets());
264                bytes.extend_from_slice(&(port as u16).to_be_bytes());
265            }
266            serde_bencode::value::Value::Bytes(bytes)
267        } else {
268            let peers = peer_entries
269                .iter()
270                .filter_map(|peer| {
271                    let ip = peer.get("ip").and_then(|v| v.as_str())?.to_string();
272                    let port = peer.get("port").and_then(|v| v.as_u64())? as i64;
273
274                    let mut dict = std::collections::HashMap::new();
275                    // `peer id` is optional in the dictionary model (BEP 3) and a model
276                    // that omits it should still get a well-formed peer entry.
277                    //
278                    // It is hex-decoded when it is valid 40-character hex, because that is
279                    // the form the *event* reports an inbound `peer_id` in, and echoing the
280                    // event's value is the obvious thing for a model to do. Writing those
281                    // characters through as text put 40 bytes on the wire where BEP 3 wants
282                    // 20 — the `send_tcp_data` defect shape. A value that is not 40-char hex
283                    // is a literal peer id (`-TR2940-abcdefghijkl`, which real clients send
284                    // as raw ASCII) and goes through unchanged; the two are told apart by
285                    // decoding rather than by a flag, because only one of them can be both
286                    // 40 characters long and valid hex.
287                    if let Some(peer_id) = peer.get("peer_id").and_then(|v| v.as_str()) {
288                        let bytes = match hex::decode(peer_id) {
289                            Ok(decoded) if peer_id.len() == 40 => decoded,
290                            _ => peer_id.as_bytes().to_vec(),
291                        };
292                        dict.insert(
293                            b"peer id".to_vec(),
294                            serde_bencode::value::Value::Bytes(bytes),
295                        );
296                    }
297                    dict.insert(
298                        b"ip".to_vec(),
299                        serde_bencode::value::Value::Bytes(ip.into_bytes()),
300                    );
301                    dict.insert(b"port".to_vec(), serde_bencode::value::Value::Int(port));
302                    Some(serde_bencode::value::Value::Dict(dict))
303                })
304                .collect::<Vec<_>>();
305            serde_bencode::value::Value::List(peers)
306        };
307
308        let mut response_dict = std::collections::HashMap::new();
309        response_dict.insert(
310            b"interval".to_vec(),
311            serde_bencode::value::Value::Int(interval),
312        );
313        response_dict.insert(
314            b"complete".to_vec(),
315            serde_bencode::value::Value::Int(complete),
316        );
317        response_dict.insert(
318            b"incomplete".to_vec(),
319            serde_bencode::value::Value::Int(incomplete),
320        );
321        response_dict.insert(b"peers".to_vec(), peers_value);
322
323        let bencode_data =
324            serde_bencode::to_bytes(&serde_bencode::value::Value::Dict(response_dict))?;
325        let http_response = format!(
326            "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
327            bencode_data.len()
328        );
329        let mut full_response = http_response.into_bytes();
330        full_response.extend_from_slice(&bencode_data);
331
332        Ok(ActionResult::Output(full_response))
333    }
334
335    fn execute_send_scrape_response(&self, action: serde_json::Value) -> Result<ActionResult> {
336        // `files` is documented and modelled two ways and both appear in the wild:
337        //   {"<info_hash hex>": {complete, downloaded, incomplete}, ...}
338        //   [{"info_hash": "<hex>", complete, downloaded, incomplete}, ...]
339        // The executor used to accept only the first, so the array form documented in
340        // CLAUDE.md (and used by the E2E test) silently produced an empty `files` dict.
341        let entries: Vec<(String, &serde_json::Value)> = match action.get("files") {
342            Some(serde_json::Value::Object(obj)) => {
343                obj.iter().map(|(k, v)| (k.clone(), v)).collect()
344            }
345            Some(serde_json::Value::Array(arr)) => arr
346                .iter()
347                .filter_map(|entry| {
348                    let hash = entry.get("info_hash").and_then(|v| v.as_str())?;
349                    Some((hash.to_string(), entry))
350                })
351                .collect(),
352            _ => Vec::new(),
353        };
354
355        let mut files = std::collections::HashMap::new();
356        for (info_hash_hex, stats) in entries {
357            let Ok(info_hash) = hex::decode(&info_hash_hex) else {
358                continue;
359            };
360            let complete = stats.get("complete").and_then(|v| v.as_u64()).unwrap_or(0) as i64;
361            let downloaded = stats
362                .get("downloaded")
363                .and_then(|v| v.as_u64())
364                .unwrap_or(0) as i64;
365            let incomplete = stats
366                .get("incomplete")
367                .and_then(|v| v.as_u64())
368                .unwrap_or(0) as i64;
369
370            let mut stats_dict = std::collections::HashMap::new();
371            stats_dict.insert(
372                b"complete".to_vec(),
373                serde_bencode::value::Value::Int(complete),
374            );
375            stats_dict.insert(
376                b"downloaded".to_vec(),
377                serde_bencode::value::Value::Int(downloaded),
378            );
379            stats_dict.insert(
380                b"incomplete".to_vec(),
381                serde_bencode::value::Value::Int(incomplete),
382            );
383            files.insert(info_hash, serde_bencode::value::Value::Dict(stats_dict));
384        }
385
386        let mut response_dict = std::collections::HashMap::new();
387        response_dict.insert(b"files".to_vec(), serde_bencode::value::Value::Dict(files));
388
389        let bencode_data =
390            serde_bencode::to_bytes(&serde_bencode::value::Value::Dict(response_dict))?;
391        let http_response = format!(
392            "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
393            bencode_data.len()
394        );
395        let mut full_response = http_response.into_bytes();
396        full_response.extend_from_slice(&bencode_data);
397
398        Ok(ActionResult::Output(full_response))
399    }
400
401    fn execute_send_error_response(&self, action: serde_json::Value) -> Result<ActionResult> {
402        // The action definition, CLAUDE.md and the E2E test all said `failure_reason`
403        // (matching the bencode key BEP 3 defines) while the executor read `error`, so
404        // every documented use produced the literal string "Unknown error". Accept both.
405        // `failure_reason` is declared `required: true`, so it is enforced rather than
406        // defaulted. It used to fall back to the literal string "Unknown error", which is
407        // then what the client displays before it stops announcing to this tracker — a
408        // refusal that says nothing is barely better than no refusal, and the declaration
409        // promised the model it would be told when it left the field out.
410        let error_message = action
411            .get("failure_reason")
412            .or_else(|| action.get("error"))
413            .and_then(|v| v.as_str())
414            .context(
415                "send_error_response requires 'failure_reason' (alias 'error'): the text the \
416                 client displays before it stops announcing to this tracker",
417            )?;
418
419        let mut response_dict = std::collections::HashMap::new();
420        response_dict.insert(
421            b"failure reason".to_vec(),
422            serde_bencode::value::Value::Bytes(error_message.as_bytes().to_vec()),
423        );
424
425        let bencode_data =
426            serde_bencode::to_bytes(&serde_bencode::value::Value::Dict(response_dict))?;
427        let http_response = format!(
428            "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
429            bencode_data.len()
430        );
431        let mut full_response = http_response.into_bytes();
432        full_response.extend_from_slice(&bencode_data);
433
434        Ok(ActionResult::Output(full_response))
435    }
436}
437
438/// Fields every tracker request event carries.
439///
440/// `parse_http_request` guarantees `request_type`, `path` and `compact` are always
441/// present so that a static handler can reference `{{event.compact}}` without the
442/// interpolator failing on a client that omitted the query parameter.
443fn common_request_parameters() -> Vec<Parameter> {
444    vec![
445        Parameter {
446            name: "request_type".to_string(),
447            type_hint: "string".to_string(),
448            description: "\"announce\", \"scrape\" or \"unknown\"".to_string(),
449            required: true,
450        },
451        Parameter {
452            name: "path".to_string(),
453            type_hint: "string".to_string(),
454            description: "Request path including the query string".to_string(),
455            required: true,
456        },
457        Parameter {
458            name: "info_hash".to_string(),
459            type_hint: "string".to_string(),
460            description: "Torrent info hash, hex-encoded (40 chars). Echo it back as the \
461                          key of a scrape `files` entry."
462                .to_string(),
463            required: false,
464        },
465        Parameter {
466            name: "compact".to_string(),
467            type_hint: "number".to_string(),
468            description: "1 if the client wants BEP 23 compact peers, 0 otherwise. \
469                          Always present; pass it straight through to the response action."
470                .to_string(),
471            required: true,
472        },
473    ]
474}
475
476pub static TRACKER_ANNOUNCE_REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
477    let mut parameters = common_request_parameters();
478    parameters.extend(vec![
479        Parameter {
480            name: "peer_id".to_string(),
481            type_hint: "string".to_string(),
482            description: "Announcing peer's ID, hex-encoded (40 chars)".to_string(),
483            required: false,
484        },
485        Parameter {
486            name: "port".to_string(),
487            type_hint: "number".to_string(),
488            description: "Port the announcing peer listens on".to_string(),
489            required: false,
490        },
491        Parameter {
492            name: "uploaded".to_string(),
493            type_hint: "number".to_string(),
494            description: "Bytes uploaded so far".to_string(),
495            required: false,
496        },
497        Parameter {
498            name: "downloaded".to_string(),
499            type_hint: "number".to_string(),
500            description: "Bytes downloaded so far".to_string(),
501            required: false,
502        },
503        Parameter {
504            name: "left".to_string(),
505            type_hint: "number".to_string(),
506            description: "Bytes still needed. 0 means the peer is a seeder.".to_string(),
507            required: false,
508        },
509        Parameter {
510            name: "event".to_string(),
511            type_hint: "string".to_string(),
512            description: "\"started\", \"completed\", \"stopped\" or absent".to_string(),
513            required: false,
514        },
515        Parameter {
516            name: "numwant".to_string(),
517            type_hint: "number".to_string(),
518            description: "How many peers the client would like back".to_string(),
519            required: false,
520        },
521    ]);
522
523    EventType::new(
524        "tracker_announce_request",
525        "BitTorrent client announced itself and is asking for peers",
526        // Concrete, not `"{{event.compact}}"`. A `response_example` is shown to the *model*,
527        // and `{{...}}` is interpolated only for static handlers — so the placeholder
528        // reached `is_compact` as text, matched nothing, and silently produced the
529        // dictionary peer form that real clients refuse. The static-handler example in
530        // `get_startup_examples` keeps the placeholder, where it does work.
531        json!({
532            "type": "send_announce_response",
533            "interval": 1800,
534            "complete": 1,
535            "incomplete": 0,
536            "compact": 1,
537            "peers": [{"ip": "127.0.0.1", "port": 51413}]
538        }),
539    )
540    .with_parameters(parameters)
541    .with_actions(vec![
542        SEND_ANNOUNCE_RESPONSE_ACTION.clone(),
543        SEND_ERROR_RESPONSE_ACTION.clone(),
544    ])
545    .with_alternative_example(json!({
546        "type": "send_error_response",
547        "failure_reason": "This tracker does not serve that torrent"
548    }))
549    .with_log_template(
550        LogTemplate::new()
551            .with_info("{client_ip} BT announce {event} ({duration_ms}ms)")
552            .with_debug("BT tracker announce from {client_ip}: event={event}")
553            .with_trace("BT announce: {json_pretty(.)}"),
554    )
555});
556
557pub static TRACKER_SCRAPE_REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
558    EventType::new(
559        "tracker_scrape_request",
560        "BitTorrent client asked for torrent statistics",
561        // The array form, and a concrete info_hash. A `{{...}}` placeholder used as an
562        // object *key* is worse than one used as a value: `hex::decode` rejects it, the
563        // entry is dropped, and the client gets an empty `files` dictionary under a 200 OK.
564        // Nothing errors, so the model has no way to learn it did not work. Copy the
565        // event's own 40-character `info_hash` into the value here.
566        json!({
567            "type": "send_scrape_response",
568            "files": [{
569                "info_hash": "1111111111111111111111111111111111111111",
570                "complete": 1, "downloaded": 1, "incomplete": 0
571            }]
572        }),
573    )
574    .with_parameters(common_request_parameters())
575    .with_actions(vec![
576        SEND_SCRAPE_RESPONSE_ACTION.clone(),
577        SEND_ERROR_RESPONSE_ACTION.clone(),
578    ])
579    .with_log_template(
580        LogTemplate::new()
581            .with_info("{client_ip} BT scrape ({duration_ms}ms)")
582            .with_debug("BT tracker scrape from {client_ip}")
583            .with_trace("BT scrape: {json_pretty(.)}"),
584    )
585});
586
587pub static SEND_ANNOUNCE_RESPONSE_ACTION: LazyLock<ActionDefinition> = LazyLock::new(|| {
588    ActionDefinition {
589        name: "send_announce_response".to_string(),
590        description: "Send announce response with peer list".to_string(),
591        parameters: vec![
592            Parameter {
593                name: "interval".to_string(),
594                type_hint: "number".to_string(),
595                description: "Announce interval in seconds (default: 1800)".to_string(),
596                required: false,
597            },
598            Parameter {
599                name: "complete".to_string(),
600                type_hint: "number".to_string(),
601                description: "Number of seeders (default: 0)".to_string(),
602                required: false,
603            },
604            Parameter {
605                name: "incomplete".to_string(),
606                type_hint: "number".to_string(),
607                description: "Number of leechers (default: 0)".to_string(),
608                required: false,
609            },
610            Parameter {
611                name: "compact".to_string(),
612                // Not "boolean". The documented usage is to interpolate `{{event.compact}}`,
613                // and the event declares that field a number because clients send
614                // `compact=1` in the query string - so declaring boolean here told the model
615                // to convert the one value it is being instructed to pass through verbatim.
616                // `is_compact` accepts both spellings (and the string forms), so the union is
617                // what the executor actually implements.
618                type_hint: "boolean|number".to_string(),
619                description: "Encode peers in BEP 23 compact form (4-byte IPv4 + 2-byte \
620                              port). Send 1 or 0 (`true`/`false` also work). Most real \
621                              clients require compact form, which carries no peer_id and \
622                              drops IPv6 peers, so 1 is usually right. In a *static* handler \
623                              — and only there — \"{{event.compact}}\" passes the request's \
624                              own value through; on this path that placeholder is not \
625                              interpolated and would be read as text."
626                    .to_string(),
627                required: false,
628            },
629            Parameter {
630                name: "peers".to_string(),
631                type_hint: "array".to_string(),
632                description: "Array of peer objects with `ip` and `port`, plus an optional \
633                              `peer_id` used only in non-compact form. A `peer_id` of 40 hex \
634                              characters is decoded to the 20 bytes BEP 3 wants — that is \
635                              the form the announce event reports it in — and anything else \
636                              is sent as literal text."
637                    .to_string(),
638                required: false,
639            },
640        ],
641        // `"{{event.compact}}"` is NOT usable here. `{{...}}` is interpolated only for
642        // *static* handlers; on the LLM path the model copies the example verbatim and the
643        // executor receives that literal string, which `is_compact` does not match — so it
644        // answered in the dictionary form this action's own comment says real clients
645        // refuse. Accepted, wrong bytes, no error raised, which is why it survived. The
646        // example shows a concrete 1; the parameter description explains interpolation for
647        // handler authors.
648        example: json!({"type": "send_announce_response", "interval": 1800, "complete": 10, "incomplete": 5, "compact": 1, "peers": [{"peer_id": "2d5452303030312d787878787878787878787878", "ip": "192.168.1.100", "port": 51413}]}),
649        log_template: Some(
650            LogTemplate::new()
651                .with_info("-> BT announce: {peers_len} peers, interval={interval}s")
652                .with_debug("BT announce response: {peers_len} peers, complete={complete}, incomplete={incomplete}"),
653        ),
654    }
655});
656
657pub static SEND_SCRAPE_RESPONSE_ACTION: LazyLock<ActionDefinition> = LazyLock::new(|| {
658    ActionDefinition {
659        name: "send_scrape_response".to_string(),
660        description: "Send scrape response with torrent statistics".to_string(),
661        parameters: vec![Parameter {
662            name: "files".to_string(),
663            type_hint: "object".to_string(),
664            description: "Either an object keyed by hex info_hash -> \
665                          {complete, downloaded, incomplete}, or an array of objects each \
666                          carrying an `info_hash` field plus those counts. Keys that are \
667                          not valid hex are dropped."
668                .to_string(),
669            required: false,
670        }],
671        // Not `{"{{event.info_hash}}": ...}`. On the LLM path that key arrives literally,
672        // `hex::decode` rejects it, the entry is `continue`d, and the client gets an empty
673        // `files` dictionary under a 200 OK — silently, which is why it survived. The array
674        // form is shown instead: it puts the info_hash in a value rather than a key, so a
675        // static handler can interpolate it there and the LLM path has an obvious place to
676        // copy the event's hex into.
677        example: json!({"type": "send_scrape_response", "files": [{"info_hash": "1111111111111111111111111111111111111111", "complete": 10, "downloaded": 100, "incomplete": 5}]}),
678        log_template: Some(
679            LogTemplate::new()
680                .with_info("-> BT scrape: {files_len} torrents")
681                .with_debug("BT scrape response: {files_len} torrents"),
682        ),
683    }
684});
685
686pub static SEND_ERROR_RESPONSE_ACTION: LazyLock<ActionDefinition> =
687    LazyLock::new(|| ActionDefinition {
688        name: "send_error_response".to_string(),
689        description: "Refuse the request with a bencode `failure reason` (BEP 3). Clients \
690                      display this and stop announcing to this tracker."
691            .to_string(),
692        parameters: vec![Parameter {
693            name: "failure_reason".to_string(),
694            type_hint: "string".to_string(),
695            description: "Message shown to the client, which then stops announcing to this \
696                          tracker (alias: `error`). Genuinely required — omitting it is an \
697                          error, not a generic refusal."
698                .to_string(),
699            required: true,
700        }],
701        example: json!({"type": "send_error_response", "failure_reason": "Torrent not found"}),
702        log_template: Some(
703            LogTemplate::new()
704                .with_info("-> BT error: {failure_reason}")
705                .with_debug("BT tracker error: {failure_reason}"),
706        ),
707    });