Skip to main content

netget/client/activitypub/
actions.rs

1//! What the model does as one fediverse actor: look actors up, follow and unfollow, post
2//! notes, like and fetch. The actor is real: NetGet serves its document, key and inbox on a
3//! local port, because federation only works when the other side can fetch the signer's
4//! key and deliver Accepts back.
5use crate::llm::actions::{
6    client_trait::{Client, ClientActionResult},
7    protocol_trait::Protocol,
8    ActionDefinition, ParameterDefinition, StartupExamples,
9};
10use crate::protocol::{ConnectContext, EventType};
11use crate::server::activitypub::actions::{self as server, action, p, target_param};
12use crate::state::app_state::AppState;
13use anyhow::{bail, ensure, Context, Result};
14use serde_json::{json, Value};
15use std::sync::LazyLock;
16
17pub const LOOKUP: &str = "activitypub_lookup";
18pub const FETCH: &str = "activitypub_fetch";
19/// The client's actor when none is named.
20pub const DEFAULT_USERNAME: &str = "netget";
21/// Where the client's actor is served when nowhere is named.
22pub const DEFAULT_LISTEN: &str = "127.0.0.1:0";
23
24#[derive(Default)]
25pub struct ActivityPubClientProtocol;
26impl ActivityPubClientProtocol {
27    pub fn new() -> Self {
28        Self
29    }
30}
31
32pub fn actions() -> Vec<ActionDefinition> {
33    let mut v = vec![
34        action(
35            LOOKUP,
36            "Find an actor by handle (WebFinger) or URL and read its profile.",
37            vec![target_param()],
38            json!({"type": LOOKUP, "target": "alice@example.social"}),
39        ),
40        action(
41            FETCH,
42            "Fetch any ActivityStreams object by URL (signed by this actor).",
43            vec![p("url", "string", "The object's URL, e.g. a note id", true)],
44            json!({"type": FETCH, "url": "https://example.social/notes/1"}),
45        ),
46    ];
47    v.extend(server::shared_actions().into_iter().map(|mut a| {
48        a.parameters.retain(|p| p.name != "as");
49        a
50    }));
51    v.push(action(
52        "disconnect",
53        "Stop: the actor's endpoint goes away.",
54        vec![],
55        json!({"type": "disconnect"}),
56    ));
57    v
58}
59
60fn event(id: &str, description: &str, params: Vec<crate::llm::actions::Parameter>) -> EventType {
61    EventType::new(
62        id,
63        description,
64        json!({"type": server::FOLLOW, "target": "alice@example.social"}),
65    )
66    .with_parameters(params)
67    .with_actions(actions())
68}
69
70pub static READY_EVENT: LazyLock<EventType> = LazyLock::new(|| {
71    event(
72        "activitypub_ready",
73        "The actor is up: its document, key and inbox are being served.",
74        vec![
75            p("actor_id", "string", "This actor's URL", true),
76            p("handle", "string", "This actor as user@host", true),
77            p(
78                "remote",
79                "string",
80                "The remote_addr the client was opened with (a handle, URL or host)",
81                true,
82            ),
83        ],
84    )
85});
86
87pub static RESPONSE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
88    event("activitypub_response", "What an action came to.", vec![
89        p("operation", "string", "The action that was performed", true),
90        p("ok", "boolean", "Whether it succeeded", true),
91        p("result", "object", "lookup: the actor's profile; fetch: the object; follow/post/like: the actor and delivery statuses", false),
92        p("error", "string", "Why the action failed (the remote's status, or a refused signature or lookup)", false),
93    ])
94});
95
96pub static ACTIVITY_EVENT: LazyLock<EventType> = LazyLock::new(|| {
97    event("activitypub_activity", "A signed activity arrived at this actor's inbox (Accept or Reject of a Follow, a Create, a Like, …); its signature was verified.", vec![
98        p("type", "string", "The activity type, e.g. Accept, Create", true),
99        p("actor", "string", "The sender's actor URL (proven by its signature)", true),
100        p("actor_handle", "string", "The sender as user@host", false),
101        p("object_type", "string", "The type of the object", false),
102        p("object_id", "string", "The object's id", false),
103        p("content", "string", "A note's text, HTML removed", false),
104        p("in_reply_to", "string", "What a note answers", false),
105    ])
106});
107
108pub fn check(v: &Value) -> Result<()> {
109    match v["type"].as_str().unwrap_or_default() {
110        LOOKUP => {
111            let t = v["target"].as_str().context("target is required")?;
112            ensure!(
113                t.starts_with("http") || t.contains('@'),
114                "target must be an actor URL or user@host"
115            );
116            Ok(())
117        }
118        FETCH => {
119            let u = v["url"].as_str().context("url is required")?;
120            ensure!(
121                u.starts_with("http://") || u.starts_with("https://"),
122                "url must be http(s)"
123            );
124            Ok(())
125        }
126        server::ACCEPT | server::REJECT => bail!("a client answers no Follow requests"),
127        _ => server::check(v),
128    }
129}
130
131impl Protocol for ActivityPubClientProtocol {
132    fn protocol_name(&self) -> &'static str {
133        "ActivityPub"
134    }
135    fn stack_name(&self) -> &'static str {
136        "ETH>IP>TCP>HTTP>ActivityPub"
137    }
138    fn keywords(&self) -> Vec<&'static str> {
139        vec![
140            "activitypub",
141            "fediverse",
142            "follow",
143            "mastodon client",
144            "webfinger lookup",
145        ]
146    }
147    fn description(&self) -> &'static str {
148        "One fediverse actor: looks actors up, follows, posts, likes and fetches, with signed requests, and hears what arrives at its inbox"
149    }
150    fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
151        actions()
152    }
153    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
154        vec![]
155    }
156    fn get_event_types(&self) -> Vec<EventType> {
157        vec![
158            READY_EVENT.clone(),
159            RESPONSE_EVENT.clone(),
160            ACTIVITY_EVENT.clone(),
161        ]
162    }
163    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
164        vec![
165            ParameterDefinition {
166                name: "username".into(),
167                type_hint: "string".into(),
168                description: "This actor's username".into(),
169                required: false,
170                example: json!("bot"),
171                default: Some(json!(DEFAULT_USERNAME)),
172            },
173            ParameterDefinition {
174                name: "listen".into(),
175                type_hint: "string".into(),
176                description: "Address the actor's document and inbox are served on (remote servers must reach it)".into(),
177                required: false,
178                example: json!("0.0.0.0:8443"),
179                default: Some(json!(DEFAULT_LISTEN)),
180            },
181            ParameterDefinition {
182                name: "base_url".into(),
183                type_hint: "string".into(),
184                description: "The public URL remote servers reach the actor at (default: http:// and the listen address)".into(),
185                required: false,
186                example: json!("https://bot.example"),
187                default: None,
188            },
189        ]
190    }
191    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
192        use crate::protocol::metadata::*;
193        ProtocolMetadataV2::builder()
194            .state(DevelopmentState::Experimental)
195            .privilege_requirement(PrivilegeRequirement::None)
196            .implementation("The server's instance with a single actor: WebFinger resolution, signed GETs and deliveries (draft-cavage rsa-sha256), and its own document, key and verified inbox served on a local port")
197            .llm_control("Whom to look up, follow and unfollow, what to post and like, and how to answer what arrives at the inbox")
198            .e2e_testing("tests/client/activitypub: an actor built on the Fedify 2.4.2 library, which verifies NetGet's signed Follow, answers it with a signed Accept NetGet verifies, receives NetGet's signed Create, and serves the WebFinger a lookup resolves")
199            .notes("The remote must be able to reach the actor's listen address. No media, no boosts, no persistence. A handler chain stops after 8 follow-ups.")
200            .build()
201    }
202    fn example_prompt(&self) -> &'static str {
203        "Follow alice@example.social and say hello to her"
204    }
205    fn get_startup_examples(&self) -> StartupExamples {
206        let llm = json!({"type":"open_client","protocol":"activitypub","remote_addr":"alice@example.social",
207            "instruction":"Follow alice and, once she accepts, send her a hello note"});
208        let mut static_example = llm.clone();
209        static_example["event_handlers"] = json!([{"event_pattern":"activitypub_ready","handler":{"type":"static","actions":[{"type":server::FOLLOW,"target":"alice@example.social"}]}}]);
210        let mut scripted = llm.clone();
211        scripted["event_handlers"] = json!([{"event_pattern":"*","handler":{"type":"script","language":"python","code":"import json,sys\ni=json.load(sys.stdin); t=i['event_type_id']; e=i['event']\na=[]\nif t=='activitypub_ready': a=[{'type':'activitypub_follow','target':e['remote']}]\nelif t=='activitypub_activity' and e['type']=='Accept': a=[{'type':'activitypub_post','content':'hello','to':[e['actor']],'public':False}]\nprint(json.dumps({'actions':a}))"}}]);
212        StartupExamples::new(llm, scripted, static_example)
213    }
214    fn group_name(&self) -> &'static str {
215        "Application"
216    }
217}
218
219impl Client for ActivityPubClientProtocol {
220    fn connect(
221        &self,
222        ctx: ConnectContext,
223    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
224    {
225        Box::pin(super::connect(ctx))
226    }
227    fn execute_action(&self, v: Value) -> Result<ClientActionResult> {
228        let name = v["type"].as_str().unwrap_or_default().to_string();
229        if name == "disconnect" {
230            return Ok(ClientActionResult::Disconnect);
231        }
232        check(&v)?;
233        Ok(ClientActionResult::Custom { name, data: v })
234    }
235}