A Discord bot, made with Gleam, designed to help manage a pixel art server.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Boy howdy it works!

Isaac 6ffd40bc f5174af4

+157 -35
+7
.gitignore
··· 2 2 *.ez 3 3 /build 4 4 erl_crash.dump 5 + 6 + # Database 7 + db.json 8 + posted.txt 9 + 10 + # Dev config 11 + .env
+1
db.example.json
··· 1 + {"lastUpdate":0,"posts":[]}
+144 -30
src/pablo_pixarto.gleam
··· 1 + import dotenv_gleam 2 + import envoy 1 3 import gleam/dynamic/decode 4 + import gleam/erlang/process 5 + import gleam/float 2 6 import gleam/hackney 3 7 import gleam/http/request 4 8 import gleam/http/response 5 9 import gleam/int 6 10 import gleam/json 7 11 import gleam/list 12 + import gleam/option 13 + import gleam/order 8 14 import gleam/result 9 15 import gleam/set.{type Set} 10 16 import gleam/string 17 + import gleam/time/timestamp.{type Timestamp} 11 18 import grom 19 + import grom/gateway 20 + import grom/gateway/intent 21 + import grom/message 12 22 import logging 13 23 import simplifile 14 24 15 25 const bluesky_api_base = "https://public.api.bsky.app" 16 26 17 - const uris_file = "posted.txt" 18 - 19 - const bluesky_actor = "pixeldailies.bsky.app" 27 + const bluesky_actor = "pixeldailies.bsky.social" 20 28 21 29 const check_interval_ms = 60_000 22 30 23 31 pub fn main() -> Nil { 24 - let actor = "isaacary.com" 25 - let limit = 1 32 + logging.configure() 33 + 34 + // We can safely void this result and not care if it's an error 35 + // because we are using dotenv in development, but not production. 36 + let _ = dotenv_gleam.config() 26 37 27 - let assert Ok(feed) = 28 - latest_bsky_posts(actor, limit) 29 - |> echo 38 + let assert Ok(token) = envoy.get("BOT_TOKEN") 39 + let assert Ok(channel_id) = envoy.get("CHANNEL_ID") 40 + 41 + // Load the cached values fro last updated and the list of posted URIs 42 + let #(uris, last_updated) = load_cache() 43 + 44 + let client = grom.Client(token:) 45 + let identify = 46 + client 47 + |> gateway.identify(intents: [intent.Guilds, intent.GuildMessages]) 48 + 49 + let state = AppState(client, last_updated, uris, channel_id) 50 + let gateway_start_result = 51 + gateway.new(identify, state) 52 + |> gateway.start() 53 + 54 + case gateway_start_result { 55 + Ok(_) -> { 56 + logging.log(logging.Info, "Started the gateway!") 57 + 58 + let state = check_and_post(state) 59 + schedule_check(state) 60 + process.sleep_forever() 61 + } 62 + Error(err) -> { 63 + logging.log( 64 + logging.Error, 65 + "Couldn't start the gateway: " <> string.inspect(err), 66 + ) 67 + } 68 + } 30 69 31 70 Nil 71 + } 72 + 73 + fn schedule_check(state: AppState) -> Nil { 74 + // Sleep for the interval 75 + process.sleep(check_interval_ms) 76 + 77 + // Check for new posts 78 + let new_state = check_and_post(state) 79 + 80 + // Schedule next check 81 + schedule_check(new_state) 32 82 } 33 83 34 84 pub type BlueskyPost { 35 85 BlueskyPost( 36 86 uri: String, 37 87 text: String, 38 - created_at: String, 88 + created_at: Timestamp, 39 89 author_handle: String, 40 90 ) 41 91 } ··· 48 98 use text <- decode.subfield(["post", "record", "text"], decode.string) 49 99 use created_at <- decode.subfield( 50 100 ["post", "record", "createdAt"], 51 - decode.string, 101 + decode.then(decode.string, fn(str) { 102 + case timestamp.parse_rfc3339(str) { 103 + Ok(ts) -> decode.success(ts) 104 + Error(_) -> 105 + decode.failure( 106 + timestamp.from_unix_seconds(0), 107 + "could not parse RFD3339 timestamp", 108 + ) 109 + } 110 + }), 52 111 ) 53 112 use author_handle <- decode.subfield( 54 113 ["post", "author", "handle"], ··· 59 118 } 60 119 61 120 type AppState { 62 - AppState(client: grom.Client, posted_uris: Set(String)) 121 + AppState( 122 + client: grom.Client, 123 + last_updated: Timestamp, 124 + posted_uris: Set(String), 125 + channel_id: String, 126 + ) 127 + } 128 + 129 + fn post_to_discord(state: AppState, post: BlueskyPost) { 130 + let content = 131 + message.Create(..message.new_create(), content: option.Some(post.uri)) 132 + 133 + case message.create(state.client, state.channel_id, content) { 134 + Ok(_) -> logging.log(logging.Info, "[discord] message sent") 135 + Error(err) -> 136 + logging.log( 137 + logging.Error, 138 + "[discord] failed to send message: " <> string.inspect(err), 139 + ) 140 + } 63 141 } 64 142 65 143 fn check_and_post(state: AppState) -> AppState { ··· 69 147 list.filter(posts, fn(post) { 70 148 has_theme_and_tag(post.text) 71 149 && !set.contains(state.posted_uris, post.uri) 150 + && { 151 + timestamp.compare(post.created_at, state.last_updated) == order.Gt 152 + } 72 153 }) 73 154 74 155 case matched { ··· 81 162 "Found a matching post: " <> matching.text <> " at " <> matching.uri, 82 163 ) 83 164 84 - // TODO: Make this real 85 - // post_to_discord(state.client, matching) 165 + post_to_discord(state, matching) 166 + 167 + case write_cache(matching) { 168 + Ok(_) -> 169 + logging.log( 170 + logging.Info, 171 + "[io] saved uri and latest timestamp for post", 172 + ) 173 + Error(err) -> 174 + logging.log( 175 + logging.Error, 176 + "[io] failed to save new uri: " <> string.inspect(err), 177 + ) 178 + } 86 179 87 180 AppState( 88 181 ..state, 182 + last_updated: matching.created_at, 89 183 posted_uris: set.insert(state.posted_uris, matching.uri), 90 184 ) 91 185 } 92 186 } 93 - 94 - state 95 187 } 96 188 Error(err) -> { 97 189 logging.log( ··· 161 253 } 162 254 } 163 255 164 - pub fn load_posted_uris() -> Set(String) { 165 - case simplifile.read(uris_file) { 166 - Ok(content) -> 167 - content 168 - |> string.split("\n") 169 - |> list.map(string.trim) 170 - |> list.filter(string.is_empty) 171 - |> set.from_list() 256 + const cache_file = "db.json" 257 + 258 + pub fn load_cache() -> #(Set(String), Timestamp) { 259 + case simplifile.read(cache_file) { 260 + Ok(content) -> { 261 + let decoder = { 262 + use posts <- decode.field("posts", decode.list(decode.string)) 263 + use last_updated <- decode.field( 264 + "lastUpdate", 265 + decode.map(decode.int, timestamp.from_unix_seconds), 266 + ) 267 + 268 + decode.success(#(set.from_list(posts), last_updated)) 269 + } 270 + 271 + let assert Ok(data) = json.parse(content, decoder) 272 + 273 + data 274 + } 172 275 Error(_) -> { 173 276 logging.log( 174 277 logging.Info, 175 - "No existing uris text file (" 176 - <> uris_file 177 - <> "), starting a fresh list!", 278 + "[cache] no existing cache file, creating " <> cache_file, 178 279 ) 179 - set.new() 280 + let _ = simplifile.write(cache_file, "{\"lastUpdate\": 0,\n\"posts\":[]}") 281 + 282 + #(set.new(), timestamp.from_unix_seconds(0)) 180 283 } 181 284 } 182 285 } 183 286 184 - pub fn save_new_uri(post_uri: String) -> Result(Nil, simplifile.FileError) { 185 - let line = post_uri <> "\n" 287 + pub fn write_cache(post: BlueskyPost) -> Result(Nil, simplifile.FileError) { 288 + let #(posts, _) = load_cache() 289 + let posts = set.insert(posts, post.uri) 290 + 291 + let updated = 292 + json.object([ 293 + #( 294 + "lastUpdate", 295 + json.int(float.round(timestamp.to_unix_seconds(post.created_at))), 296 + ), 297 + #("posts", json.array(set.to_list(posts), json.string)), 298 + ]) 299 + |> json.to_string 186 300 187 - simplifile.append(uris_file, line) 301 + simplifile.write(cache_file, updated) 188 302 }
+5 -5
test/pablo_pixarto_test.gleam
··· 1 - import gleam/http/request 2 - import gleam/httpc 3 1 import gleeunit 4 2 import pablo_pixarto 5 3 ··· 21 19 let actor = "isaacary.com" 22 20 let limit = 1 23 21 24 - let assert Ok(feed) = 25 - pablo_pixarto.latest_bsky_posts(actor, limit) 26 - |> echo 22 + let assert Ok(feed) = pablo_pixarto.latest_bsky_posts(actor, limit) 23 + 24 + echo feed 25 + 26 + assert feed != [] 27 27 }