1use crate::event::{
32 Alphabet, Coordinate, Event, EventBuilder, EventId, Kind, SingleLetterTag, Tag, TagKind, Tags,
33};
34use crate::key::PublicKey;
35use crate::types::RelayUrl;
36
37#[derive(Debug, thiserror::Error)]
43#[non_exhaustive]
44#[allow(
45 variant_size_differences,
46 reason = "Serialize variant is already boxed; its 8-byte pointer is the smallest sound representation against the 2-byte NotATextNote variant"
47)]
48pub enum RepostError {
49 #[error("kind:6 reposts are reserved for kind:1 notes; got kind {0}")]
54 NotATextNote(u16),
55 #[error(transparent)]
58 Serialize(Box<serde_json::Error>),
59}
60
61impl From<serde_json::Error> for RepostError {
62 fn from(value: serde_json::Error) -> Self {
63 Self::Serialize(Box::new(value))
64 }
65}
66
67impl EventBuilder {
68 pub fn repost(target: &Event, relay: &RelayUrl) -> Result<Self, RepostError> {
83 if target.kind != Kind::TEXT_NOTE {
84 return Err(RepostError::NotATextNote(target.kind.as_u16()));
85 }
86 let content = if target.is_protected() {
87 String::new()
88 } else {
89 serde_json::to_string(target)?
90 };
91 let mut builder = Self::new(Kind::REPOST, content);
92 builder = builder.tag(repost_e_tag(target.id, relay));
93 builder = builder.tag(Tag::p_with_relay(target.pubkey, relay));
94 Ok(builder)
95 }
96
97 pub fn generic_repost(target: &Event, relay: &RelayUrl) -> Result<Self, RepostError> {
112 let coord = if target.kind.is_addressable() {
113 extract_d_tag(&target.tags)
114 .map(|d| Coordinate::new(target.kind, target.pubkey, d.to_owned()))
115 } else {
116 None
117 };
118 let content = if coord.is_some() || target.is_protected() {
119 String::new()
120 } else {
121 serde_json::to_string(target)?
122 };
123 let mut builder = Self::new(Kind::GENERIC_REPOST, content);
124 builder = builder.tag(repost_e_tag(target.id, relay));
125 builder = builder.tag(Tag::p_with_relay(target.pubkey, relay));
126 builder = builder.tag(Tag::k(target.kind));
127 if let Some(coord) = coord {
128 builder = builder.tag(Tag::a_with_relay(&coord, relay));
129 }
130 Ok(builder)
131 }
132}
133
134fn repost_e_tag(event_id: EventId, relay: &RelayUrl) -> Tag {
135 let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::E));
137 Tag::with(&head, [event_id.to_hex(), relay.as_str().to_owned()])
138}
139
140fn extract_d_tag(tags: &Tags) -> Option<&str> {
141 for tag in tags {
142 if matches!(tag.kind(), TagKind::SingleLetter(s) if s.character == Alphabet::D && !s.uppercase)
143 {
144 return tag.values().get(1).map(String::as_str);
145 }
146 }
147 None
148}
149
150#[must_use]
157pub fn reposted_event_id(tags: &Tags) -> Option<EventId> {
158 for tag in tags {
159 if matches!(tag.kind(), TagKind::SingleLetter(s) if s.character == Alphabet::E && !s.uppercase)
160 {
161 return tag.values().get(1).and_then(|v| EventId::parse(v).ok());
162 }
163 }
164 None
165}
166
167#[must_use]
170pub fn reposted_event_pubkey(tags: &Tags) -> Option<PublicKey> {
171 for tag in tags {
172 if matches!(tag.kind(), TagKind::SingleLetter(s) if s.character == Alphabet::P && !s.uppercase)
173 {
174 return tag.values().get(1).and_then(|v| PublicKey::parse(v).ok());
175 }
176 }
177 None
178}
179
180#[must_use]
183pub fn reposted_event_kind(tags: &Tags) -> Option<Kind> {
184 for tag in tags {
185 if matches!(tag.kind(), TagKind::SingleLetter(s) if s.character == Alphabet::K && !s.uppercase)
186 {
187 return tag
188 .values()
189 .get(1)
190 .and_then(|v| v.parse::<u16>().ok())
191 .map(Kind::new);
192 }
193 }
194 None
195}
196
197#[must_use]
200pub fn reposted_event_coordinate(tags: &Tags) -> Option<Coordinate> {
201 for tag in tags {
202 let TagKind::SingleLetter(s) = tag.kind() else {
203 continue;
204 };
205 if s.character != Alphabet::A || s.uppercase {
206 continue;
207 }
208 if let Some(value) = tag.values().get(1) {
209 return Coordinate::parse(value).ok();
210 }
211 }
212 None
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218 use crate::Keys;
219
220 fn fixture_keys() -> Keys {
221 Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
222 }
223
224 fn fixture_relay() -> RelayUrl {
225 RelayUrl::parse("wss://relay.example/").unwrap()
226 }
227
228 #[test]
229 fn repost_rejects_non_text_note_kinds() {
230 let keys = fixture_keys();
231 let target = EventBuilder::new(Kind::CONTACTS, "")
232 .sign_with_keys(&keys)
233 .unwrap();
234
235 let err = EventBuilder::repost(&target, &fixture_relay()).unwrap_err();
236 assert!(matches!(err, RepostError::NotATextNote(3)));
237 }
238
239 #[test]
240 fn repost_carries_target_json_in_content_and_e_p_tags() {
241 let keys = fixture_keys();
242 let target = EventBuilder::text_note("hello, world")
243 .sign_with_keys(&keys)
244 .unwrap();
245
246 let repost = EventBuilder::repost(&target, &fixture_relay())
247 .unwrap()
248 .sign_with_keys(&keys)
249 .unwrap();
250
251 assert_eq!(repost.kind, Kind::REPOST);
252 assert!(
253 repost.content.contains(r#""content":"hello, world""#),
254 "repost content must embed the reposted note's JSON: {}",
255 repost.content,
256 );
257 assert_eq!(reposted_event_id(&repost.tags), Some(target.id));
258 assert_eq!(reposted_event_pubkey(&repost.tags), Some(target.pubkey));
259 assert_eq!(reposted_event_kind(&repost.tags), None);
262 repost.verify().unwrap();
263 }
264
265 #[test]
266 fn repost_e_tag_carries_relay_at_index_2() {
267 let keys = fixture_keys();
268 let target = EventBuilder::text_note("post")
269 .sign_with_keys(&keys)
270 .unwrap();
271 let relay = fixture_relay();
272
273 let repost = EventBuilder::repost(&target, &relay)
274 .unwrap()
275 .sign_with_keys(&keys)
276 .unwrap();
277
278 let e_tag = repost
279 .tags
280 .iter()
281 .find(|t| matches!(t.kind(), TagKind::SingleLetter(s) if s.character == Alphabet::E))
282 .unwrap();
283 assert_eq!(e_tag.get(2), Some(relay.as_str()));
284 }
285
286 #[test]
287 fn generic_repost_emits_k_tag_for_arbitrary_kind() {
288 let keys = fixture_keys();
289 let target = EventBuilder::new(Kind::CONTACTS, "")
290 .sign_with_keys(&keys)
291 .unwrap();
292
293 let repost = EventBuilder::generic_repost(&target, &fixture_relay())
294 .unwrap()
295 .sign_with_keys(&keys)
296 .unwrap();
297
298 assert_eq!(repost.kind, Kind::GENERIC_REPOST);
299 assert_eq!(reposted_event_kind(&repost.tags), Some(Kind::CONTACTS));
300 assert!(repost.content.contains(r#""kind":3"#));
302 }
303
304 #[test]
305 fn generic_repost_uses_a_tag_and_empty_content_for_addressable() {
306 let keys = fixture_keys();
307 let target = EventBuilder::new(Kind::LONG_FORM_TEXT_NOTE, "post body")
308 .tag(Tag::d("article-1"))
309 .sign_with_keys(&keys)
310 .unwrap();
311
312 let repost = EventBuilder::generic_repost(&target, &fixture_relay())
313 .unwrap()
314 .sign_with_keys(&keys)
315 .unwrap();
316
317 assert_eq!(repost.content, "");
319 let coord = reposted_event_coordinate(&repost.tags).expect("a-tag must be present");
320 assert_eq!(coord.kind, Kind::LONG_FORM_TEXT_NOTE);
321 assert_eq!(coord.author, target.pubkey);
322 assert_eq!(coord.identifier, "article-1");
323 }
324
325 #[test]
326 fn quote_repost_q_tag_round_trips() {
327 let keys = fixture_keys();
328 let target = EventBuilder::text_note("quoted")
329 .sign_with_keys(&keys)
330 .unwrap();
331 let relay = fixture_relay();
332
333 let event = EventBuilder::text_note("see this:")
334 .tag(Tag::q(target.id, &relay, target.pubkey))
335 .sign_with_keys(&keys)
336 .unwrap();
337
338 let q_tag = event
339 .tags
340 .iter()
341 .find(|t| matches!(t.kind(), TagKind::SingleLetter(s) if s.character == Alphabet::Q))
342 .unwrap();
343 assert_eq!(q_tag.values().len(), 4);
344 assert_eq!(q_tag.get(1), Some(target.id.to_hex().as_str()));
345 assert_eq!(q_tag.get(2), Some(relay.as_str()));
346 assert_eq!(q_tag.get(3), Some(target.pubkey.to_hex().as_str()));
347 }
348
349 #[test]
350 fn quote_repost_addressable_q_tag_uses_coordinate() {
351 let keys = fixture_keys();
352 let target = EventBuilder::new(Kind::LONG_FORM_TEXT_NOTE, "post")
353 .tag(Tag::d("ident"))
354 .sign_with_keys(&keys)
355 .unwrap();
356 let coord = Coordinate::new(target.kind, target.pubkey, "ident");
357 let relay = fixture_relay();
358
359 let event = EventBuilder::text_note("see this article:")
360 .tag(Tag::q_addressable(&coord, &relay))
361 .sign_with_keys(&keys)
362 .unwrap();
363
364 let q_tag = event
365 .tags
366 .iter()
367 .find(|t| matches!(t.kind(), TagKind::SingleLetter(s) if s.character == Alphabet::Q))
368 .unwrap();
369 assert_eq!(q_tag.values().len(), 3);
372 assert!(q_tag.get(1).unwrap().starts_with("30023:"));
373 assert_eq!(q_tag.get(2), Some(relay.as_str()));
374 }
375}