1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
use abi::{Call, Datetime, Did, Event, Handle, LexiconType, Run, TypedLexicon, entries};
use serde::{Deserialize, Serialize};
use serde_json::json;
use unicode_segmentation::UnicodeSegmentation;
const COLLECTION: &str = "com.vibescrobble.scrobble";
const MAX_GRAPHEMES: usize = 256;
const MAX_BYTES: usize = 2560;
const MAX_TASK_BYTES: usize = 256;
#[derive(Deserialize)]
struct Args {
did: Did,
now: String,
#[serde(default)]
text: Option<String>,
#[serde(default)]
task: Option<String>,
}
#[derive(Serialize, PartialEq)]
struct Scrobble {
text: String,
emoji: String,
#[serde(rename = "createdAt")]
created_at: Datetime,
#[serde(skip_serializing_if = "Option::is_none")]
task: Option<String>,
}
impl LexiconType for Scrobble {
fn lexicon_type() -> &'static str {
COLLECTION
}
}
fn leading_emoji(text: &str) -> Option<&str> {
let first = text.graphemes(true).next()?;
let c = first.chars().next()? as u32;
let emoji = matches!(c, 0x1F000..=0x1FAFF)
|| first.contains('\u{FE0F}')
|| first.contains('\u{20E3}')
|| emoji_presentation_bmp(c);
emoji.then_some(first)
}
fn emoji_presentation_bmp(c: u32) -> bool {
matches!(
c,
0x231A..=0x231B
| 0x23E9..=0x23EC
| 0x23F0
| 0x23F3
| 0x25FD..=0x25FE
| 0x2614..=0x2615
| 0x2648..=0x2653
| 0x267F
| 0x2693
| 0x26A1
| 0x26AA..=0x26AB
| 0x26BD..=0x26BE
| 0x26C4..=0x26C5
| 0x26CE
| 0x26D4
| 0x26EA
| 0x26F2..=0x26F3
| 0x26F5
| 0x26FA
| 0x26FD
| 0x2705
| 0x270A..=0x270B
| 0x2728
| 0x274C
| 0x274E
| 0x2753..=0x2755
| 0x2757
| 0x2795..=0x2797
| 0x27B0
| 0x27BF
| 0x2B1B..=0x2B1C
| 0x2B50
| 0x2B55
)
}
fn truncate(text: &str) -> String {
let mut out = String::new();
for grapheme in text.graphemes(true).take(MAX_GRAPHEMES) {
if out.len() + grapheme.len() > MAX_BYTES {
break;
}
out.push_str(grapheme);
}
out
}
fn validate_task(task: Option<String>) -> Result<Option<String>, String> {
let Some(task) = task.filter(|task| !task.is_empty()) else {
return Ok(None);
};
if task.len() > MAX_TASK_BYTES || task.chars().any(char::is_control) {
return Err(format!("task must be at most {MAX_TASK_BYTES} bytes without control characters"));
}
Ok(Some(task))
}
fn scrobble_handler(run: Run<Args>) -> Result<Handle, String> {
let args = run.args;
Ok(match run.event {
Event::Start => {
let text = args.text.unwrap_or_default();
let emoji = leading_emoji(&text).ok_or("text must start with an emoji")?.to_string();
let now = Datetime::new(args.now).map_err(|e| format!("invalid now: {e}"))?;
let created_at = Datetime::try_from(now.to_utc()).map_err(|e| format!("invalid now: {e}"))?;
let record = TypedLexicon::new(Scrobble { text: truncate(&text), emoji, created_at, task: validate_task(args.task)? });
let input = json!({ "repo": args.did, "collection": COLLECTION, "record": record, "validate": false });
Handle::new().call(Call::new("create", "com.atproto.repo.createRecord").input(input))
}
Event::Result { result, .. } => {
let created = result.output()?;
Handle::new().result(json!({ "uri": created["uri"], "cid": created["cid"] })).done()
}
_ => Handle::new(),
})
}
entries!(scrobble => scrobble_handler);