docs/october-dolly.png
docs/october-dolly.png
This is a binary file and will not be displayed.
+7
license
+7
license
···
···
1
+
Copyright (c) 2025 @bad-example.com
2
+
3
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+32
readme.md
+32
readme.md
···
···
1
+
# ๐ Happy hacktober! ๐ง๐ผโโ๏ธ
2
+
3
+

4
+
5
+
[This bot](https://bsky.app/profile/hacktober.tngl.sh) listens to the [jetstream](github.com/bluesky-social/jetstream) firehose, filters for labels added to issues on [tangled.org](https://tangled.org/), checks if they are the official [`good-first-issue`](https://tangled.org/goodfirstissues) label, and then [posts](https://bsky.app/profile/hacktober.tngl.sh/post/3m2oflabdmc2u) about it!
6
+
7
+
8
+
### It's made with:
9
+
10
+
- [jacquard](https://docs.rs/jacquard/latest/jacquard/): auth and posting
11
+
- [microcosm slingshot](https://slingshot.microcosm.blue/): identity resolution and record fetching
12
+
- [microcosm jetstream](https://tangled.org/@microcosm.blue/microcosm-rs/tree/main/jetstream): firehose listener
13
+
- [tangled's](https://tangled.org/) PDS hosts the bot's account!
14
+
15
+
### It's made by:
16
+
17
+
- [@bad-example.com](https://bsky.app/profile/bad-example.com): [ko-fi](https://ko-fi.com/bad_example), [github sponsors](https://github.com/sponsors/uniphil/)
18
+
19
+
20
+
### It would be nice if this bot would:
21
+
22
+
- [ ] pull [OG repo images](https://bsky.app/profile/oppi.li/post/3m2orohxal22j) so it can post with an external link embed
23
+
24
+
### It would be nice if this bot *could*:
25
+
26
+
- [ ] link directly to the issue, instead of the repo's all-issues page. i don't think it's possible right now because the issue page URL needs the issue id number, which is only kept in tangled's appview at the moment.
27
+
- [ ] reply to its posts when issues are closed as complete! again currently the open/closed state for tangled is only in the appview, so this is not currently possible to detect.
28
+
29
+
30
+
### Things to watch out for if you hack on it
31
+
32
+
- [ ] The microcosm jetstream package isn't published, so this currently uses a horrible local path reference for it, and that reference uses a very old folder name that you won't get by default from cloning [microcosm-rs](https://tangled.org/@microcosm.blue/microcosm-rs). If you rename microcosm-rs's folder name to `links`, it should work! or ping me and i'll fix it.
+209
-181
src/main.rs
+209
-181
src/main.rs
···
50
/// don't actually post
51
#[arg(long, action)]
52
dry_run: bool,
53
}
54
55
-
async fn post(
56
-
client: &BasicClient,
57
-
identifier: &AtIdentifier<'_>,
58
-
repo_name: &str,
59
-
repo_url: &str,
60
-
title: &str,
61
-
repo_issues_url: &str,
62
-
) -> Result<()> {
63
-
let message = format!(
64
-
r#"good-first-issue added for {repo_name}:
65
-
66
-
> {title}"#
67
-
);
68
-
69
-
let repo_feature = serde_json::json!({
70
-
"$type": "app.bsky.richtext.facet#link",
71
-
"uri": repo_url,
72
-
});
73
-
let repo_facet = Facet {
74
-
features: vec![Data::from_json(&repo_feature)?],
75
-
index: ByteSlice {
76
-
byte_start: 27,
77
-
byte_end: 29 + repo_name.len() as i64,
78
-
extra_data: Default::default(),
79
-
},
80
-
extra_data: Default::default(),
81
-
};
82
-
83
-
let title_starts_at = (29 + repo_name.len() + 5) as i64;
84
-
85
-
let repo_issues_feature = serde_json::json!({
86
-
"$type": "app.bsky.richtext.facet#link",
87
-
"uri": repo_issues_url,
88
-
});
89
-
let issues_facet = Facet {
90
-
features: vec![Data::from_json(&repo_issues_feature)?],
91
-
index: ByteSlice {
92
-
byte_start: title_starts_at,
93
-
byte_end: title_starts_at + title.len() as i64,
94
-
extra_data: Default::default(),
95
-
},
96
-
extra_data: Default::default(),
97
-
};
98
-
99
-
// Make a post
100
-
let post = Post {
101
-
created_at: Datetime::now(),
102
-
langs: Some(vec![Language::new("en")?]),
103
-
text: message.into(),
104
-
facets: Some(vec![repo_facet, issues_facet]),
105
-
embed: Default::default(),
106
-
entities: Default::default(),
107
-
labels: Default::default(),
108
-
reply: Default::default(),
109
-
tags: Default::default(),
110
-
extra_data: Default::default(),
111
-
};
112
-
113
-
let json = serde_json::to_value(post)?;
114
-
let data = Data::from_json(&json)?;
115
-
116
-
log::info!("\nposting...");
117
-
client
118
-
.send(
119
-
CreateRecord::new()
120
-
.repo(identifier.clone())
121
-
.collection(Post::nsid())
122
-
.record(data)
123
-
.build(),
124
-
)
125
-
.await?
126
-
.into_output()?;
127
-
128
-
Ok(())
129
-
}
130
-
131
-
fn event_to_create_label<T: for<'a> Deserialize<'a>>(event: JetstreamEvent) -> Result<(T, Cursor)> {
132
-
if event.kind != EventKind::Commit {
133
-
return Err("not a commit".into());
134
-
}
135
-
let commit = event.commit.ok_or("commit event missing commit data")?;
136
-
if commit.operation != CommitOp::Create {
137
-
return Err("not a create event".into());
138
-
}
139
-
140
-
let raw = commit.record.ok_or("commit missing record")?;
141
-
142
-
// todo: delete post if label is removed
143
-
// delete sample: at://did:plc:hdhoaan3xa3jiuq4fg4mefid/sh.tangled.label.op/3m2jvx4c6wf22
144
-
// tldr: has a "delete" array just like "add" on the same op collection
145
-
let t = serde_json::from_str(raw.get())?;
146
-
Ok((t, event.cursor))
147
}
148
149
/// com.bad-example.identity.resolveMiniDoc bit we care about
···
221
}
222
}
223
224
#[tokio::main]
225
async fn main() -> Result<()> {
226
env_logger::init();
···
229
// Create HTTP client and session
230
let client = BasicClient::new(args.pds);
231
let bot_id = AtIdentifier::new(&args.identifier)?;
232
-
let session = Session::from(
233
-
client
234
-
.send(
235
-
CreateSession::new()
236
-
.identifier(bot_id.to_string())
237
-
.password(&args.app_password)
238
-
.build(),
239
-
)
240
-
.await?
241
-
.into_output()?,
242
-
);
243
log::debug!("logged in as {} ({})", session.handle, session.did);
244
client.set_session(session).await?;
245
···
261
.connect_cursor(args.jetstream_cursor.map(Cursor::from_raw_u64))
262
.await?;
263
264
log::info!("receiving jetstream messages...");
265
loop {
266
let Some(event) = receiver.recv().await else {
267
log::error!("consumer: could not receive event, bailing");
268
break;
269
};
270
271
-
let Ok((CreateLabelRecord { add, subject }, cursor)) = event_to_create_label(event) else {
272
-
continue;
273
-
};
274
-
275
-
let mut added_good_first_issue = false;
276
-
for added in add {
277
-
if added.key
278
-
== "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue"
279
-
{
280
-
log::info!("found a good first issue label!! {:?}", cursor);
281
-
added_good_first_issue = true;
282
-
break; // inner
283
-
}
284
-
log::debug!("found a label but it wasn't good-first-issue, ignoring...");
285
-
}
286
-
if !added_good_first_issue {
287
-
continue;
288
-
}
289
-
290
-
let IssueRecord { title, repo } = match get_record(&slingshot_client, &subject).await {
291
-
Ok(m) => m,
292
Err(e) => {
293
-
log::warn!("failed to get issue record: {e} for {subject}");
294
continue;
295
}
296
};
297
298
-
let Ok(repo_uri) = AtUri::new(&repo) else {
299
-
log::warn!("failed to parse repo to aturi for {subject}");
300
-
continue;
301
-
};
302
-
303
-
let RepoRecord { name: repo_name } = match get_record(&slingshot_client, &repo).await {
304
-
Ok(m) => m,
305
Err(e) => {
306
-
log::warn!("failed to get repo record: {e} for {subject}");
307
continue;
308
}
309
};
310
311
-
let nice_tangled_repo_id = match repo_uri.authority() {
312
-
AtIdentifier::Handle(h) => format!("@{h}"),
313
-
AtIdentifier::Did(did) => match get_handle(&slingshot_client, did.as_str()).await {
314
-
Err(e) => {
315
-
log::warn!("failed to get mini doc from repo identifier: {e} for {subject}");
316
-
continue;
317
-
}
318
-
Ok(None) => did.to_string(),
319
-
Ok(Some(h)) => format!("@{h}"),
320
-
},
321
-
};
322
-
323
-
let repo_full_name = format!("{nice_tangled_repo_id}/{repo_name}");
324
-
let repo_url = format!("https://tangled.org/{nice_tangled_repo_id}/{repo_name}");
325
-
326
-
let issues_url = format!("https://tangled.org/{nice_tangled_repo_id}/{repo_name}/issues");
327
-
328
if args.dry_run {
329
log::info!(
330
r#"--dry-run, but would have posted:
331
···
336
continue;
337
}
338
339
-
if let Err(e) = post(
340
-
&client,
341
-
&bot_id,
342
-
&repo_full_name,
343
-
&repo_url,
344
-
&title,
345
-
&issues_url,
346
-
)
347
-
.await
348
-
{
349
log::warn!("failed to post for {subject}: {e}, refreshing session for one retry...");
350
-
let session = Session::from(
351
-
client
352
-
.send(
353
-
CreateSession::new()
354
-
.identifier(bot_id.to_string())
355
-
.password(&args.app_password)
356
-
.build(),
357
-
)
358
-
.await?
359
-
.into_output()?,
360
-
);
361
log::debug!("logged in as {} ({})", session.handle, session.did);
362
client.set_session(session).await?;
363
364
-
if let Err(e) = post(
365
-
&client,
366
-
&bot_id,
367
-
&repo_full_name,
368
-
&repo_url,
369
-
&title,
370
-
&issues_url,
371
-
)
372
-
.await
373
-
{
374
log::error!(
375
"failed to post after a session refresh: {e:?}, something is wrong. bye."
376
);
···
50
/// don't actually post
51
#[arg(long, action)]
52
dry_run: bool,
53
+
/// send a checkin to this url every 5 mins
54
+
#[arg(long)]
55
+
healthcheck_ping: Option<Url>,
56
}
57
58
+
struct IssueDetails {
59
+
repo_full_name: String,
60
+
repo_url: String,
61
+
title: String,
62
+
issues_url: String,
63
}
64
65
/// com.bad-example.identity.resolveMiniDoc bit we care about
···
137
}
138
}
139
140
+
fn event_to_create_label<T: for<'a> Deserialize<'a>>(event: JetstreamEvent) -> Result<T> {
141
+
if event.kind != EventKind::Commit {
142
+
return Err("not a commit".into());
143
+
}
144
+
let commit = event.commit.ok_or("commit event missing commit data")?;
145
+
if commit.operation != CommitOp::Create {
146
+
return Err("not a create event".into());
147
+
}
148
+
149
+
let raw = commit.record.ok_or("commit missing record")?;
150
+
151
+
// todo: delete post if label is removed
152
+
// delete sample: at://did:plc:hdhoaan3xa3jiuq4fg4mefid/sh.tangled.label.op/3m2jvx4c6wf22
153
+
// tldr: has a "delete" array just like "add" on the same op collection
154
+
Ok(serde_json::from_str(raw.get())?)
155
+
}
156
+
157
+
async fn extract_issue_info(
158
+
client: &reqwest::Client,
159
+
adds: Vec<AddLabel>,
160
+
subject: String,
161
+
) -> Result<IssueDetails> {
162
+
let mut added_good_first_issue = false;
163
+
for added in adds {
164
+
if added.key
165
+
== "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue"
166
+
{
167
+
log::info!("found a good first issue label!!");
168
+
added_good_first_issue = true;
169
+
break; // inner
170
+
}
171
+
log::debug!("found a label but it wasn't good-first-issue, ignoring...");
172
+
}
173
+
if !added_good_first_issue {
174
+
return Err("good-first-issue label not found in added labels".into());
175
+
}
176
+
177
+
let IssueRecord { title, repo } = match get_record(client, &subject).await {
178
+
Ok(m) => m,
179
+
Err(e) => return Err(format!("failed to get issue record: {e} for {subject}").into()),
180
+
};
181
+
182
+
let Ok(repo_uri) = AtUri::new(&repo) else {
183
+
return Err("failed to parse repo to aturi for {subject}".into());
184
+
};
185
+
186
+
let RepoRecord { name: repo_name } = match get_record(client, &repo).await {
187
+
Ok(m) => m,
188
+
Err(e) => return Err(format!("failed to get repo record: {e} for {subject}").into()),
189
+
};
190
+
191
+
let nice_tangled_repo_id = match repo_uri.authority() {
192
+
AtIdentifier::Handle(h) => format!("@{h}"),
193
+
AtIdentifier::Did(did) => match get_handle(client, did.as_str()).await {
194
+
Err(e) => {
195
+
return Err(format!(
196
+
"failed to get mini doc from repo identifier: {e} for {subject}"
197
+
)
198
+
.into());
199
+
}
200
+
Ok(None) => did.to_string(),
201
+
Ok(Some(h)) => format!("@{h}"),
202
+
},
203
+
};
204
+
205
+
let repo_full_name = format!("{nice_tangled_repo_id}/{repo_name}");
206
+
let repo_url = format!("https://tangled.org/{nice_tangled_repo_id}/{repo_name}");
207
+
208
+
let issues_url = format!("https://tangled.org/{nice_tangled_repo_id}/{repo_name}/issues");
209
+
210
+
Ok(IssueDetails {
211
+
repo_full_name,
212
+
repo_url,
213
+
title,
214
+
issues_url,
215
+
})
216
+
}
217
+
218
+
async fn post(
219
+
client: &BasicClient,
220
+
identifier: &AtIdentifier<'_>,
221
+
IssueDetails {
222
+
repo_full_name,
223
+
repo_url,
224
+
title,
225
+
issues_url,
226
+
}: &IssueDetails,
227
+
) -> Result<()> {
228
+
let message = format!(
229
+
r#"New from {repo_full_name}:
230
+
231
+
> {title}"#
232
+
);
233
+
234
+
let pre_len = 9;
235
+
236
+
let repo_feature = serde_json::json!({
237
+
"$type": "app.bsky.richtext.facet#link",
238
+
"uri": repo_url,
239
+
});
240
+
let repo_facet = Facet {
241
+
features: vec![Data::from_json(&repo_feature)?],
242
+
index: ByteSlice {
243
+
byte_start: pre_len,
244
+
byte_end: pre_len + repo_full_name.len() as i64,
245
+
extra_data: Default::default(),
246
+
},
247
+
extra_data: Default::default(),
248
+
};
249
+
250
+
let title_starts_at = pre_len + (repo_full_name.len() + 5) as i64;
251
+
252
+
let repo_issues_feature = serde_json::json!({
253
+
"$type": "app.bsky.richtext.facet#link",
254
+
"uri": issues_url,
255
+
});
256
+
let issues_facet = Facet {
257
+
features: vec![Data::from_json(&repo_issues_feature)?],
258
+
index: ByteSlice {
259
+
byte_start: title_starts_at,
260
+
byte_end: title_starts_at + title.len() as i64,
261
+
extra_data: Default::default(),
262
+
},
263
+
extra_data: Default::default(),
264
+
};
265
+
266
+
// Make a post
267
+
let post = Post {
268
+
created_at: Datetime::now(),
269
+
langs: Some(vec![Language::new("en")?]),
270
+
text: message.into(),
271
+
facets: Some(vec![repo_facet, issues_facet]),
272
+
embed: Default::default(),
273
+
entities: Default::default(),
274
+
labels: Default::default(),
275
+
reply: Default::default(),
276
+
tags: Default::default(),
277
+
extra_data: Default::default(),
278
+
};
279
+
280
+
let json = serde_json::to_value(post)?;
281
+
let data = Data::from_json(&json)?;
282
+
283
+
log::info!("\nposting...");
284
+
client
285
+
.send(
286
+
CreateRecord::new()
287
+
.repo(identifier.clone())
288
+
.collection(Post::nsid())
289
+
.record(data)
290
+
.build(),
291
+
)
292
+
.await?
293
+
.into_output()?;
294
+
295
+
Ok(())
296
+
}
297
+
298
+
async fn hc_ping(url: Url, client: reqwest::Client) {
299
+
let mut interval = tokio::time::interval(Duration::from_secs(5 * 60));
300
+
loop {
301
+
interval.tick().await;
302
+
log::trace!("sending healthcheck ping...");
303
+
if let Err(e) = client
304
+
.get(url.clone())
305
+
.send()
306
+
.await
307
+
.and_then(reqwest::Response::error_for_status)
308
+
{
309
+
log::warn!("error sending healthcheck ping: {e}");
310
+
}
311
+
}
312
+
}
313
+
314
#[tokio::main]
315
async fn main() -> Result<()> {
316
env_logger::init();
···
319
// Create HTTP client and session
320
let client = BasicClient::new(args.pds);
321
let bot_id = AtIdentifier::new(&args.identifier)?;
322
+
let create_session = CreateSession::new()
323
+
.identifier(bot_id.to_string())
324
+
.password(&args.app_password)
325
+
.build();
326
+
let session = Session::from(client.send(create_session.clone()).await?.into_output()?);
327
log::debug!("logged in as {} ({})", session.handle, session.did);
328
client.set_session(session).await?;
329
···
345
.connect_cursor(args.jetstream_cursor.map(Cursor::from_raw_u64))
346
.await?;
347
348
+
if let Some(hc) = args.healthcheck_ping {
349
+
log::info!("starting healthcheck ping task...");
350
+
tokio::task::spawn(hc_ping(hc.clone(), slingshot_client.clone()));
351
+
}
352
+
353
log::info!("receiving jetstream messages...");
354
loop {
355
let Some(event) = receiver.recv().await else {
356
log::error!("consumer: could not receive event, bailing");
357
break;
358
};
359
+
let cursor = event.cursor;
360
361
+
let CreateLabelRecord { add: adds, subject } = match event_to_create_label(event) {
362
+
Ok(clr) => clr,
363
Err(e) => {
364
+
log::debug!("ignoring unparseable event (at {cursor:?}): {e}");
365
continue;
366
}
367
};
368
369
+
let issue_details = match extract_issue_info(&slingshot_client, adds, subject.clone()).await
370
+
{
371
+
Ok(deets) => deets,
372
Err(e) => {
373
+
log::warn!("failed to extract issue details (at {cursor:?}): {e}");
374
continue;
375
}
376
};
377
378
if args.dry_run {
379
+
let IssueDetails {
380
+
repo_full_name,
381
+
repo_url,
382
+
title,
383
+
issues_url,
384
+
} = issue_details;
385
log::info!(
386
r#"--dry-run, but would have posted:
387
···
392
continue;
393
}
394
395
+
if let Err(e) = post(&client, &bot_id, &issue_details).await {
396
log::warn!("failed to post for {subject}: {e}, refreshing session for one retry...");
397
+
let session = Session::from(client.send(create_session.clone()).await?.into_output()?);
398
log::debug!("logged in as {} ({})", session.handle, session.did);
399
client.set_session(session).await?;
400
401
+
if let Err(e) = post(&client, &bot_id, &issue_details).await {
402
log::error!(
403
"failed to post after a session refresh: {e:?}, something is wrong. bye."
404
);