Something went wrong. Try again.
Rust Framework for building Bluesky Bots
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251use ab_glyph::FontRef;use amsel::*;use csscolorparser::Color;use image::{ImageBuffer, Rgb};use imageproc::{ drawing::{draw_filled_rect_mut, draw_text_mut}, rect::Rect,};use indexmap::IndexSet;use regex::Regex;use serde::{Deserialize, Serialize};use std::{collections::HashSet, io::Cursor, sync::LazyLock};
const CANVAS_WIDTH: u32 = 20;const CANVAS_HEIGHT: u32 = 10;
const CANVAS_IMAGE_PIXEL_SIZE: u32 = 100;const CANVAS_IMAGE_FONT_SIZE: f32 = 25.0;
#[tokio::main]async fn main() -> Result<()> { env_logger::init(); let session = BskySession::new_from_env().await?; let mut bot = session.init_bot_with_state("state", |agent, state| PlaceBot { agent, bot_did: "".to_owned(), state, font: FontRef::try_from_slice(include_bytes!("../assets/Inter-Regular.otf")) .expect("invalid font"), field_markers: { let mut set = IndexSet::new(); for x in 'A'..='T' { for y in '0'..='9' { set.insert(format!("{x}{y}")); } } set }, })?;
bot.bot_did = bot.agent.api.did().await.unwrap().into();
if bot.current_place_post_uri.is_empty() { bot.canvas = vec![Pixel::default(); bot.field_markers.len()]; bot.post_current_canvas_image().await; }
bot.run().await}
struct PlaceBot { agent: Agent, bot_did: String, field_markers: IndexSet<String>, font: FontRef<'static>, state: PlaceBotState,}
#[derive(Serialize, Deserialize, Default, PartialEq, Eq, Clone)]struct PlaceBotState { current_place_post_uri: String, #[serde(default)] current_place_post_cid: ATProtoCID, seen_dids: HashSet<String>, canvas: Vec<Pixel>,}
impl std::ops::Deref for PlaceBot { type Target = PlaceBotState; fn deref(&self) -> &Self::Target { &self.state }}impl std::ops::DerefMut for PlaceBot { fn deref_mut(&mut self) -> &mut Self::Target { &mut self.state }}
#[derive(Serialize, Deserialize, Default, PartialEq, Eq, Clone)]struct Pixel(u8, u8, u8);
impl Into<Rgb<u8>> for &Pixel { fn into(self) -> Rgb<u8> { Rgb([self.0, self.1, self.2]) }}
impl AmselBot for PlaceBot { async fn on_post(&mut self, post: BskyPost) { if let Some(reply) = &post.reply { if reply.parent.uri == reply.root.uri && reply .root .uri .starts_with(&format!("at://{}/", self.bot_did)) && post.author_did() != self.bot_did { debug!("try_place_pixel"); if let Err(err) = self.try_place_pixel(&post) { let reply = BskyPost::new(format!("Error: {}", err)).as_reply_to(&post); // TODO ensure this has internal retry logic self.agent.create(reply).await.unwrap(); } else { self.seen_dids.insert(post.author_did().to_string()); // TODO ensure this has internal retry logic self.agent.create(BskyLike::new(post.into())).await.unwrap(); } } } }}
#[use_cron]impl PlaceBot { // amsel cron hooks don't return a Result because there's no sensible way // the bot can recover and continue processing events while also ensuring that // the bot state doesn't get corrupted // // if calls that could fail due to temporary conditions like bad network connectivity // are performed in this hook, they should implement their own retry logic #[on_cron("* * * * *")] pub async fn every_minute(&mut self, _: DateTime) { if !self.seen_dids.is_empty() { self.post_current_canvas_image().await; self.seen_dids.clear(); } }
pub async fn post_current_canvas_image(&mut self) { info!("posting current canvas image");
let mut img: ImageBuffer<Rgb<u8>, _> = ImageBuffer::from_pixel( CANVAS_WIDTH * CANVAS_IMAGE_PIXEL_SIZE, CANVAS_HEIGHT * CANVAS_IMAGE_PIXEL_SIZE, Rgb([0, 0, 0]), );
for i in 0..self.canvas.len() { let pixel = &self.canvas[i]; let color: Rgb<u8> = pixel.into(); let reversed = Rgb::<u8>([255 - pixel.0, 255 - pixel.1, 255 - pixel.2]);
let x = ((i as u32 / CANVAS_HEIGHT) * CANVAS_IMAGE_PIXEL_SIZE) as i32; let y = ((i as u32 % CANVAS_HEIGHT) * CANVAS_IMAGE_PIXEL_SIZE) as i32;
let pos = Rect::at(x, y); let rect = pos.of_size(CANVAS_IMAGE_PIXEL_SIZE, CANVAS_IMAGE_PIXEL_SIZE); draw_filled_rect_mut(&mut img, rect, color);
let pos_name = self.field_markers.get_index(i).unwrap(); draw_text_mut( &mut img, reversed, x + (CANVAS_IMAGE_PIXEL_SIZE as f32 / 2.0 - CANVAS_IMAGE_FONT_SIZE / 2.0) as i32, y + (CANVAS_IMAGE_PIXEL_SIZE as f32 / 2.0 - CANVAS_IMAGE_FONT_SIZE / 2.0) as i32, CANVAS_IMAGE_FONT_SIZE, &self.font, pos_name, ); }
let mut image_bytes: Vec<u8> = Vec::new(); img.write_to(&mut Cursor::new(&mut image_bytes), image::ImageFormat::Png) .unwrap();
// TODO ensure this has internal retry logic let image = self .agent .upload_image_bytes(image_bytes, img.width(), img.height(), "") .await .unwrap();
let post = BskyPost::new( "reply with position and color to place a pixel\ncanvas updates every 60 seconds", ) .with_image(image);
// TODO ensure this has internal retry logic let post_ref = self.agent.create(post).await.unwrap();
let info_post = BskyPost::new("updated canvas (click quoted post):") .as_reply_to_root_post(BskyRecordRef { uri: self.current_place_post_uri.clone(), cid: AtriumCIDWrapper::new(self.current_place_post_cid), }) .with_quote(post_ref.clone());
self.agent.create(info_post).await.unwrap();
self.agent .create(BskyThreadgate::new(self.current_place_post_uri.to_string())) .await .unwrap();
self.agent .put( BskyProfile::new() .with_pinned_post(post_ref.clone()) .with_display_name("pixel place bot".to_owned()), RKEY_SELF, ) .await .unwrap();
self.current_place_post_uri = post_ref.uri; self.current_place_post_cid = post_ref.cid.as_ref().into(); }
pub fn try_place_pixel(&mut self, post: &BskyPost) -> Result<()> { if self.seen_dids.contains(post.author_did()) { return custom_error( "you already placed a pixel on this iteration, wait for the next one to place another one :)", ); } let color = Self::extract_color(&post.text)?; let position = self.extract_pos(&post.text)?; info!( "pixel with color {} placed at position {}", color.to_css_hex(), position ); let rgba = color.to_rgba8(); let pixel = Pixel(rgba[0], rgba[1], rgba[2]); self.canvas[position] = pixel; Ok(()) }
fn extract_pos(&self, text: &str) -> Result<usize> { for part in TEXT_SPLIT_REGEX.split(text) { if let Some(index) = self.field_markers.get_index_of(&part.to_uppercase()) { return Ok(index); } } return custom_error("no canvas field position found in text"); }
fn extract_color(text: &str) -> Result<Color> { for part in TEXT_SPLIT_REGEX.split(&text.to_lowercase()) { if part.len() > 2 && let Ok(c) = csscolorparser::parse(part) { return Ok(c); } } return custom_error("no color found in text"); }}
static TEXT_SPLIT_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(\n|\s|,)").unwrap());