1use crate::llm::actions::{
6 protocol_trait::{ActionResult, Protocol, Server},
7 ActionDefinition, Parameter, ParameterDefinition, StartupExamples,
8};
9use crate::protocol::{log_template::LogTemplate, EventType, SpawnContext};
10use crate::state::app_state::AppState;
11use anyhow::{bail, ensure, Context, Result};
12use serde_json::{json, Value};
13use std::sync::LazyLock;
14
15pub const ACCEPT: &str = "activitypub_accept";
16pub const REJECT: &str = "activitypub_reject";
17pub const POST: &str = "activitypub_post";
18pub const LIKE: &str = "activitypub_like";
19pub const FOLLOW: &str = "activitypub_follow";
20pub const UNFOLLOW: &str = "activitypub_unfollow";
21pub const DEFAULT_ACTORS: &[&str] = &["netget"];
23pub const MAX_CONTENT: usize = 10_000;
25
26#[derive(Default, Clone)]
27pub struct ActivityPubProtocol;
28
29impl ActivityPubProtocol {
30 pub fn new() -> Self {
31 Self
32 }
33}
34
35pub fn p(name: &str, type_hint: &str, description: &str, required: bool) -> Parameter {
36 Parameter {
37 name: name.into(),
38 type_hint: type_hint.into(),
39 description: description.into(),
40 required,
41 }
42}
43
44pub fn action(
45 name: &str,
46 description: &str,
47 parameters: Vec<Parameter>,
48 example: Value,
49) -> ActionDefinition {
50 ActionDefinition {
51 name: name.into(),
52 description: description.into(),
53 parameters,
54 example,
55 log_template: Some(LogTemplate::new().with_info(format!(
56 "-> ActivityPub {}",
57 name.trim_start_matches("activitypub_")
58 ))),
59 }
60}
61
62fn as_param() -> Parameter {
63 p(
64 "as",
65 "string",
66 "Which local actor acts (default: the one whose inbox it was, else the first)",
67 false,
68 )
69}
70
71pub fn post_action() -> ActionDefinition {
72 action(
73 POST,
74 "Publish a note: delivered to the actor's followers and to anyone in `to`.",
75 vec![
76 p(
77 "content",
78 "string",
79 "The note's text (plain text; NetGet makes the HTML)",
80 true,
81 ),
82 p(
83 "to",
84 "array",
85 "Extra recipients: actor URLs or handles like alice@example.social",
86 false,
87 ),
88 p(
89 "public",
90 "boolean",
91 "Address it to the public as well (default true)",
92 false,
93 ),
94 p(
95 "in_reply_to",
96 "string",
97 "The id of the note this answers",
98 false,
99 ),
100 as_param(),
101 ],
102 json!({"type": POST, "content": "Hello, fediverse", "public": true}),
103 )
104}
105
106pub fn target_param() -> Parameter {
107 p(
108 "target",
109 "string",
110 "An actor URL or a handle like alice@example.social",
111 true,
112 )
113}
114
115pub fn shared_actions() -> Vec<ActionDefinition> {
116 vec![
117 post_action(),
118 action(LIKE, "Like an object, telling its author.",
119 vec![p("object", "string", "The id of the object liked", true),
120 p("to", "string", "Whom to tell: the author's actor URL or handle (default: whoever sent the event)", false), as_param()],
121 json!({"type": LIKE, "object": "https://example.social/notes/1"})),
122 action(FOLLOW, "Follow a remote actor (it answers with Accept or Reject).",
123 vec![target_param(), as_param()],
124 json!({"type": FOLLOW, "target": "alice@example.social"})),
125 action(UNFOLLOW, "Stop following a remote actor.",
126 vec![target_param(), as_param()],
127 json!({"type": UNFOLLOW, "target": "alice@example.social"})),
128 ]
129}
130
131pub fn all_actions() -> Vec<ActionDefinition> {
132 let mut v = vec![
133 action(
134 ACCEPT,
135 "Accept the Follow request being answered: the follower is added and sent an Accept.",
136 vec![],
137 json!({"type": ACCEPT}),
138 ),
139 action(
140 REJECT,
141 "Refuse the Follow request being answered: the follower is sent a Reject.",
142 vec![],
143 json!({"type": REJECT}),
144 ),
145 ];
146 v.extend(shared_actions());
147 v
148}
149
150pub static ACTIVITY_EVENT: LazyLock<EventType> = LazyLock::new(|| {
151 EventType::new(
152 "activitypub_activity",
153 "A signed activity arrived at an inbox (Follow, Undo, Create, Like, Announce, …). The signature, digest and date were verified and the actor is the signer. For a Follow, answer activitypub_accept or activitypub_reject.",
154 json!({"type": ACCEPT}),
155 )
156 .with_parameters(vec![
157 p("to_actor", "string", "The local actor whose inbox it was (absent for the shared inbox)", false),
158 p("type", "string", "The activity type, e.g. Follow, Create, Like, Undo", true),
159 p("id", "string", "The activity's id", false),
160 p("actor", "string", "The sender's actor URL (proven by its signature)", true),
161 p("actor_handle", "string", "The sender as user@host, when its document names a username", false),
162 p("object_type", "string", "The type of the object, e.g. Note", false),
163 p("object_id", "string", "The object's id (for a Follow: whom it follows)", false),
164 p("content", "string", "A note's text, HTML removed", false),
165 p("in_reply_to", "string", "What a note answers", false),
166 ])
167 .with_actions(all_actions())
168});
169
170pub fn check(v: &Value) -> Result<()> {
171 let s = |k: &str| v.get(k).filter(|x| !x.is_null());
172 match v["type"].as_str().unwrap_or_default() {
173 ACCEPT | REJECT => {}
174 POST => {
175 let c = v["content"].as_str().context("content is required")?;
176 ensure!(
177 !c.is_empty() && c.len() <= MAX_CONTENT,
178 "content must be 1-{MAX_CONTENT} bytes"
179 );
180 if let Some(to) = s("to") {
181 ensure!(
182 to.as_array()
183 .is_some_and(|a| a.iter().all(Value::is_string)),
184 "to must be a list of actors"
185 );
186 }
187 }
188 LIKE => {
189 v["object"].as_str().context("object is required")?;
190 }
191 FOLLOW | UNFOLLOW => {
192 let t = v["target"].as_str().context("target is required")?;
193 ensure!(
194 t.starts_with("http") || t.contains('@'),
195 "target must be an actor URL or user@host"
196 );
197 }
198 other => bail!("Unknown ActivityPub action {other:?}"),
199 }
200 Ok(())
201}
202
203impl Protocol for ActivityPubProtocol {
204 fn protocol_name(&self) -> &'static str {
205 "ActivityPub"
206 }
207 fn stack_name(&self) -> &'static str {
208 "ETH>IP>TCP>HTTP>ActivityPub"
209 }
210 fn keywords(&self) -> Vec<&'static str> {
211 vec![
212 "activitypub",
213 "fediverse",
214 "mastodon",
215 "webfinger",
216 "federation",
217 ]
218 }
219 fn description(&self) -> &'static str {
220 "ActivityPub instance (server-to-server): WebFinger, actors with RSA keys, signed inboxes and delivery; the model answers Follows and posts, likes and follows as its actors"
221 }
222 fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
223 Vec::new()
224 }
225 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
226 all_actions()
227 }
228 fn get_event_types(&self) -> Vec<EventType> {
229 vec![ACTIVITY_EVENT.clone()]
230 }
231 fn default_binding(&self) -> Option<crate::protocol::BindingDefaults> {
232 Some(crate::protocol::BindingDefaults::port_based("127.0.0.1", 0))
233 }
234 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
235 vec![
236 ParameterDefinition {
237 name: "actors".into(),
238 type_hint: "array".into(),
239 description: "Usernames of the actors this instance hosts".into(),
240 required: false,
241 example: json!(["netget", "bot"]),
242 default: Some(json!(DEFAULT_ACTORS)),
243 },
244 ParameterDefinition {
245 name: "base_url".into(),
246 type_hint: "string".into(),
247 description: "The public URL ids are built from, e.g. https://social.example (default: http:// and the address it listens on)".into(),
248 required: false,
249 example: json!("https://social.example"),
250 default: None,
251 },
252 ]
253 }
254 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
255 use crate::protocol::metadata::*;
256 ProtocolMetadataV2::builder()
257 .state(DevelopmentState::Experimental)
258 .privilege_requirement(PrivilegeRequirement::None)
259 .implementation("hyper HTTP/1.1 (src/server/activitypub): WebFinger, NodeInfo 2.1, Person actors with RSA-2048 keys, followers/following/outbox collections, notes; inboxes (per actor and shared) verify draft-cavage HTTP Signatures (rsa-sha256) over (request-target), host, date and digest, with the signer's key fetched and the activity's actor required to be the signer; outgoing GETs and deliveries are signed the same way (reqwest)")
260 .llm_control("Every verified activity: whether to accept Follows, and what to post, like and follow in answer")
261 .e2e_testing("tests/server/activitypub: Fedify 2.4.2 — its CLI resolves and parses NetGet's actor (webfinger, lookup), and an actor built on the Fedify library follows NetGet's actor and verifies its signed Accept and Create; raw HTTP for refused signatures and bounds")
262 .notes("No object integrity proofs, no RFC 9421 signatures (Fedify falls back to draft-cavage after a 401), no authorized-fetch enforcement on GETs, no media, no Announce/boost sending, no persistence: followers, outboxes and notes live while the server runs (bounded). Inbox POSTs and fetched documents are capped at 1 MiB, deliveries at 100 inboxes per post.")
263 .request_only("Every response answers an HTTP request; deliveries are separate requests")
264 .answers_on_failure()
265 .max_inbound_bytes(super::instance::MAX_DOCUMENT)
266 .build()
267 }
268 fn example_prompt(&self) -> &'static str {
269 "ActivityPub instance hosting @netget that accepts every follower and greets them with a post"
270 }
271 fn get_startup_examples(&self) -> StartupExamples {
272 let base = json!({"type":"open_server","base_stack":"activitypub","port":0,
273 "instruction":"Accept every Follow and post a public greeting naming the new follower"});
274 let mut static_example = base.clone();
275 static_example["event_handlers"] = json!([{"event_pattern":"activitypub_activity","handler":{"type":"static","actions":[{"type":ACCEPT}]}}]);
276 let mut scripted = base.clone();
277 scripted["event_handlers"] = json!([{"event_pattern":"*","handler":{"type":"script","language":"python",
278 "code":"import json,sys\ne=json.load(sys.stdin)['event']\na=[]\nif e['type']=='Follow': a=[{'type':'activitypub_accept'},{'type':'activitypub_post','content':'Welcome '+e['actor']}]\nprint(json.dumps({'actions':a}))"}}]);
279 StartupExamples::new(base, scripted, static_example)
280 }
281 fn group_name(&self) -> &'static str {
282 "Application"
283 }
284}
285
286impl Server for ActivityPubProtocol {
287 fn spawn(
288 &self,
289 ctx: SpawnContext,
290 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
291 {
292 Box::pin(super::spawn(ctx))
293 }
294 fn execute_action(&self, action: Value) -> Result<ActionResult> {
295 check(&action)?;
296 let name = action["type"].as_str().unwrap_or_default().to_string();
297 Ok(ActionResult::Custom { name, data: action })
298 }
299}