Skip to main content

netget/client/torrent_tracker/
actions.rs

1//! BitTorrent Tracker client protocol actions implementation
2
3use crate::llm::actions::{
4    client_trait::{Client, ClientActionResult},
5    protocol_trait::Protocol,
6    ActionDefinition, Parameter,
7};
8use crate::protocol::EventType;
9use crate::state::app_state::AppState;
10use anyhow::{Context, Result};
11use serde_json::json;
12use std::sync::LazyLock;
13
14/// Tracker announce response event
15pub static TRACKER_ANNOUNCE_RESPONSE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
16    EventType::new(
17        "tracker_announce_response",
18        "Received announce response from BitTorrent tracker",
19        json!({
20            "type": "disconnect"
21        }),
22    )
23    .with_parameters(vec![
24        Parameter {
25            name: "interval".to_string(),
26            type_hint: "number".to_string(),
27            description: "Time in seconds to wait before next announce".to_string(),
28            required: false,
29        },
30        Parameter {
31            name: "complete".to_string(),
32            type_hint: "number".to_string(),
33            description: "Number of seeders".to_string(),
34            required: false,
35        },
36        Parameter {
37            name: "incomplete".to_string(),
38            type_hint: "number".to_string(),
39            description: "Number of leechers".to_string(),
40            required: false,
41        },
42        Parameter {
43            name: "peers".to_string(),
44            type_hint: "string".to_string(),
45            description: "Peer list from tracker".to_string(),
46            required: false,
47        },
48    ])
49});
50
51/// Tracker scrape response event
52pub static TRACKER_SCRAPE_RESPONSE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
53    EventType::new(
54        "tracker_scrape_response",
55        "Received scrape response from BitTorrent tracker",
56        json!({
57            "type": "disconnect"
58        }),
59    )
60    .with_parameters(vec![Parameter {
61        name: "files".to_string(),
62        type_hint: "string".to_string(),
63        description: "File statistics from tracker".to_string(),
64        required: false,
65    }])
66});
67
68/// BitTorrent Tracker client protocol action handler
69pub struct TorrentTrackerClientProtocol;
70
71impl TorrentTrackerClientProtocol {
72    pub fn new() -> Self {
73        Self
74    }
75}
76
77// Implement Protocol trait (common functionality)
78impl Protocol for TorrentTrackerClientProtocol {
79    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
80        vec![
81            ActionDefinition {
82                name: "tracker_announce".to_string(),
83                description: "Announce to the BitTorrent tracker".to_string(),
84                parameters: vec![
85                    Parameter {
86                        name: "info_hash".to_string(),
87                        type_hint: "string".to_string(),
88                        description: "The torrent's 20-byte info_hash. Percent-encoding is \
89                                      applied for you, so send it however you have it \
90                                      (40 hex characters is the usual form); a value that \
91                                      is already percent-encoded is also accepted."
92                            .to_string(),
93                        required: true,
94                    },
95                    Parameter {
96                        name: "peer_id".to_string(),
97                        type_hint: "string".to_string(),
98                        description: "Our 20-byte peer id, e.g. \"-NG0001-abcdefghijkl\". \
99                                      Percent-encoding is applied for you."
100                            .to_string(),
101                        required: true,
102                    },
103                    Parameter {
104                        name: "port".to_string(),
105                        type_hint: "number".to_string(),
106                        description: "Port number for peer connections".to_string(),
107                        required: true,
108                    },
109                    Parameter {
110                        name: "uploaded".to_string(),
111                        type_hint: "number".to_string(),
112                        description: "Bytes uploaded".to_string(),
113                        required: false,
114                    },
115                    Parameter {
116                        name: "downloaded".to_string(),
117                        type_hint: "number".to_string(),
118                        description: "Bytes downloaded".to_string(),
119                        required: false,
120                    },
121                    Parameter {
122                        name: "left".to_string(),
123                        type_hint: "number".to_string(),
124                        description: "Bytes still to download. BEP 3 reads 0 as \"I have the \
125                                      complete torrent\", which is what makes a tracker \
126                                      treat us as a seeder -- so send a real figure. Omit \
127                                      it and no completeness is claimed."
128                            .to_string(),
129                        required: false,
130                    },
131                    Parameter {
132                        name: "event".to_string(),
133                        type_hint: "string".to_string(),
134                        description: "Event type: started, completed, stopped".to_string(),
135                        required: false,
136                    },
137                ],
138                example: json!({
139                    "type": "tracker_announce",
140                    "info_hash": "%12%34%56%78%90%AB%CD%EF%12%34%56%78%90%AB%CD%EF%12%34%56%78",
141                    "peer_id": "-TR2940-abcdefghijkl",
142                    "port": 6881,
143                    "uploaded": 0,
144                    "downloaded": 0,
145                    "left": 1024000,
146                    "event": "started"
147                }),
148                log_template: None,
149            },
150            ActionDefinition {
151                name: "tracker_scrape".to_string(),
152                description: "Scrape statistics from the BitTorrent tracker".to_string(),
153                parameters: vec![Parameter {
154                    name: "info_hash".to_string(),
155                    type_hint: "string".to_string(),
156                    description: "The torrent's 20-byte info_hash to scrape. \
157                                  Percent-encoding is applied for you."
158                        .to_string(),
159                    required: true,
160                }],
161                example: json!({
162                    "type": "tracker_scrape",
163                    "info_hash": "%12%34%56%78%90%AB%CD%EF%12%34%56%78%90%AB%CD%EF%12%34%56%78"
164                }),
165                log_template: None,
166            },
167            ActionDefinition {
168                name: "disconnect".to_string(),
169                description: "Disconnect from the tracker".to_string(),
170                parameters: vec![],
171                example: json!({
172                    "type": "disconnect"
173                }),
174                log_template: None,
175            },
176        ]
177    }
178    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
179        vec![]
180    }
181    fn protocol_name(&self) -> &'static str {
182        "BitTorrent Tracker"
183    }
184    /// Clones of the statics the client actually emits, so the declaration cannot drift from
185    /// what is raised.
186    ///
187    /// These used to be rebuilt by hand here, and the two copies had already diverged: the
188    /// rebuilt pair carried no `with_parameters`, so the model was never told an announce
189    /// response contains `interval`, `complete`, `incomplete` and `peers` — the entire content
190    /// of the event it was being asked about — and their example action was a literal
191    /// `{"type": "placeholder"}`, which is not a verb this protocol can execute.
192    fn get_event_types(&self) -> Vec<EventType> {
193        vec![
194            TRACKER_ANNOUNCE_RESPONSE_EVENT.clone(),
195            TRACKER_SCRAPE_RESPONSE_EVENT.clone(),
196        ]
197    }
198    fn stack_name(&self) -> &'static str {
199        "ETH>IP>TCP>HTTP>BitTorrent-Tracker"
200    }
201    fn keywords(&self) -> Vec<&'static str> {
202        vec![
203            "bittorrent",
204            "tracker",
205            "torrent tracker",
206            "announce",
207            "scrape",
208        ]
209    }
210    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
211        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
212
213        ProtocolMetadataV2::builder()
214            .state(DevelopmentState::Experimental)
215            .implementation("HTTP GET with bencode response parsing")
216            .llm_control("Full control over tracker announces and scrapes")
217            .e2e_testing("Mock tracker server")
218            .build()
219    }
220    fn description(&self) -> &'static str {
221        "BitTorrent tracker client for peer discovery"
222    }
223    fn example_prompt(&self) -> &'static str {
224        "Connect to tracker at http://tracker.example.com:6969/announce and announce with info_hash xyz"
225    }
226    fn group_name(&self) -> &'static str {
227        "P2P"
228    }
229    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
230        use crate::llm::actions::StartupExamples;
231        use serde_json::json;
232
233        StartupExamples::new(
234            // LLM mode: LLM handles tracker communication
235            json!({
236                "type": "open_client",
237                "remote_addr": "tracker.example.com:6969",
238                "base_stack": "torrent-tracker",
239                "instruction": "Announce to the tracker with info_hash and get peer list"
240            }),
241            // Script mode: Code-based tracker handling
242            json!({
243                "type": "open_client",
244                "remote_addr": "tracker.example.com:6969",
245                "base_stack": "torrent-tracker",
246                "event_handlers": [{
247                    "event_pattern": "tracker_announce_response",
248                    "handler": {
249                        "type": "script",
250                        "language": "python",
251                        "code": "<tracker_client_handler>"
252                    }
253                }]
254            }),
255            // Static mode: Fixed tracker action
256            json!({
257                "type": "open_client",
258                "remote_addr": "tracker.example.com:6969",
259                "base_stack": "torrent-tracker",
260                "event_handlers": [{
261                    "event_pattern": "tracker_announce_response",
262                    "handler": {
263                        "type": "static",
264                        "actions": [{
265                            "type": "disconnect"
266                        }]
267                    }
268                }]
269            }),
270        )
271    }
272}
273
274// Implement Client trait (client-specific functionality)
275impl Client for TorrentTrackerClientProtocol {
276    fn connect(
277        &self,
278        ctx: crate::protocol::ConnectContext,
279    ) -> std::pin::Pin<
280        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
281    > {
282        Box::pin(async move {
283            use crate::client::torrent_tracker::TorrentTrackerClient;
284            TorrentTrackerClient::connect_with_llm_actions(
285                ctx.remote_addr,
286                ctx.llm_client,
287                ctx.state,
288                ctx.status_tx,
289                ctx.client_id,
290            )
291            .await
292        })
293    }
294    fn execute_action(&self, action: serde_json::Value) -> Result<ClientActionResult> {
295        let action_type = action
296            .get("type")
297            .and_then(|v| v.as_str())
298            .context("Missing 'type' field in action")?;
299
300        match action_type {
301            "tracker_announce" => Ok(ClientActionResult::Custom {
302                name: "tracker_announce".to_string(),
303                data: action,
304            }),
305            "tracker_scrape" => Ok(ClientActionResult::Custom {
306                name: "tracker_scrape".to_string(),
307                data: action,
308            }),
309            "disconnect" => Ok(ClientActionResult::Disconnect),
310            _ => Err(anyhow::anyhow!(
311                "Unknown Tracker client action: {}",
312                action_type
313            )),
314        }
315    }
316}