1use 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";
19pub const DEFAULT_USERNAME: &str = "netget";
21pub 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}