The world's most clever kitty cat

Message replying and learning

bwc9876.dev 5e0be501 deecaccd

verified
+553 -34
+229
Cargo.lock
··· 3 3 version = 4 4 4 5 5 [[package]] 6 + name = "aho-corasick" 7 + version = "1.1.4" 8 + source = "registry+https://github.com/rust-lang/crates.io-index" 9 + checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" 10 + dependencies = [ 11 + "memchr", 12 + ] 13 + 14 + [[package]] 6 15 name = "alloc-no-stdlib" 7 16 version = "2.0.4" 8 17 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 18 27 ] 19 28 20 29 [[package]] 30 + name = "anstream" 31 + version = "0.6.21" 32 + source = "registry+https://github.com/rust-lang/crates.io-index" 33 + checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" 34 + dependencies = [ 35 + "anstyle", 36 + "anstyle-parse", 37 + "anstyle-query", 38 + "anstyle-wincon", 39 + "colorchoice", 40 + "is_terminal_polyfill", 41 + "utf8parse", 42 + ] 43 + 44 + [[package]] 45 + name = "anstyle" 46 + version = "1.0.13" 47 + source = "registry+https://github.com/rust-lang/crates.io-index" 48 + checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" 49 + 50 + [[package]] 51 + name = "anstyle-parse" 52 + version = "0.2.7" 53 + source = "registry+https://github.com/rust-lang/crates.io-index" 54 + checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" 55 + dependencies = [ 56 + "utf8parse", 57 + ] 58 + 59 + [[package]] 60 + name = "anstyle-query" 61 + version = "1.1.5" 62 + source = "registry+https://github.com/rust-lang/crates.io-index" 63 + checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" 64 + dependencies = [ 65 + "windows-sys 0.61.2", 66 + ] 67 + 68 + [[package]] 69 + name = "anstyle-wincon" 70 + version = "3.0.11" 71 + source = "registry+https://github.com/rust-lang/crates.io-index" 72 + checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" 73 + dependencies = [ 74 + "anstyle", 75 + "once_cell_polyfill", 76 + "windows-sys 0.61.2", 77 + ] 78 + 79 + [[package]] 21 80 name = "anyhow" 22 81 version = "1.0.102" 23 82 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 68 127 version = "0.1.0" 69 128 dependencies = [ 70 129 "anyhow", 130 + "brotli", 131 + "colog", 71 132 "fastrand", 133 + "log", 72 134 "rmp-serde", 73 135 "rustls", 74 136 "serde", ··· 86 148 version = "2.11.0" 87 149 source = "registry+https://github.com/rust-lang/crates.io-index" 88 150 checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" 151 + 152 + [[package]] 153 + name = "brotli" 154 + version = "8.0.2" 155 + source = "registry+https://github.com/rust-lang/crates.io-index" 156 + checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" 157 + dependencies = [ 158 + "alloc-no-stdlib", 159 + "alloc-stdlib", 160 + "brotli-decompressor", 161 + ] 89 162 90 163 [[package]] 91 164 name = "brotli-decompressor" ··· 137 210 ] 138 211 139 212 [[package]] 213 + name = "colog" 214 + version = "1.4.0" 215 + source = "registry+https://github.com/rust-lang/crates.io-index" 216 + checksum = "df62599ba6adc9c6c04a54278c8209125343dc4775f57b9d76c9a4287e58f2bd" 217 + dependencies = [ 218 + "colored", 219 + "env_logger", 220 + "log", 221 + ] 222 + 223 + [[package]] 224 + name = "colorchoice" 225 + version = "1.0.4" 226 + source = "registry+https://github.com/rust-lang/crates.io-index" 227 + checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" 228 + 229 + [[package]] 230 + name = "colored" 231 + version = "3.1.1" 232 + source = "registry+https://github.com/rust-lang/crates.io-index" 233 + checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" 234 + dependencies = [ 235 + "windows-sys 0.61.2", 236 + ] 237 + 238 + [[package]] 140 239 name = "combine" 141 240 version = "4.6.7" 142 241 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 198 297 checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" 199 298 200 299 [[package]] 300 + name = "env_filter" 301 + version = "1.0.0" 302 + source = "registry+https://github.com/rust-lang/crates.io-index" 303 + checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f" 304 + dependencies = [ 305 + "log", 306 + "regex", 307 + ] 308 + 309 + [[package]] 310 + name = "env_logger" 311 + version = "0.11.9" 312 + source = "registry+https://github.com/rust-lang/crates.io-index" 313 + checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" 314 + dependencies = [ 315 + "anstream", 316 + "anstyle", 317 + "env_filter", 318 + "jiff", 319 + "log", 320 + ] 321 + 322 + [[package]] 201 323 name = "equivalent" 202 324 version = "1.0.2" 203 325 source = "registry+https://github.com/rust-lang/crates.io-index" 204 326 checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" 327 + 328 + [[package]] 329 + name = "errno" 330 + version = "0.3.14" 331 + source = "registry+https://github.com/rust-lang/crates.io-index" 332 + checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" 333 + dependencies = [ 334 + "libc", 335 + "windows-sys 0.52.0", 336 + ] 205 337 206 338 [[package]] 207 339 name = "fastrand" ··· 428 560 ] 429 561 430 562 [[package]] 563 + name = "is_terminal_polyfill" 564 + version = "1.70.2" 565 + source = "registry+https://github.com/rust-lang/crates.io-index" 566 + checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" 567 + 568 + [[package]] 431 569 name = "itoa" 432 570 version = "1.0.17" 433 571 source = "registry+https://github.com/rust-lang/crates.io-index" 434 572 checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" 435 573 436 574 [[package]] 575 + name = "jiff" 576 + version = "0.2.23" 577 + source = "registry+https://github.com/rust-lang/crates.io-index" 578 + checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" 579 + dependencies = [ 580 + "jiff-static", 581 + "log", 582 + "portable-atomic", 583 + "portable-atomic-util", 584 + "serde_core", 585 + ] 586 + 587 + [[package]] 588 + name = "jiff-static" 589 + version = "0.2.23" 590 + source = "registry+https://github.com/rust-lang/crates.io-index" 591 + checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" 592 + dependencies = [ 593 + "proc-macro2", 594 + "quote", 595 + "syn", 596 + ] 597 + 598 + [[package]] 437 599 name = "jni" 438 600 version = "0.21.1" 439 601 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 525 687 checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" 526 688 527 689 [[package]] 690 + name = "once_cell_polyfill" 691 + version = "1.70.2" 692 + source = "registry+https://github.com/rust-lang/crates.io-index" 693 + checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" 694 + 695 + [[package]] 528 696 name = "openssl-probe" 529 697 version = "0.2.1" 530 698 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 577 745 checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" 578 746 579 747 [[package]] 748 + name = "portable-atomic" 749 + version = "1.13.1" 750 + source = "registry+https://github.com/rust-lang/crates.io-index" 751 + checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" 752 + 753 + [[package]] 754 + name = "portable-atomic-util" 755 + version = "0.2.5" 756 + source = "registry+https://github.com/rust-lang/crates.io-index" 757 + checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5" 758 + dependencies = [ 759 + "portable-atomic", 760 + ] 761 + 762 + [[package]] 580 763 name = "powerfmt" 581 764 version = "0.2.0" 582 765 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 616 799 ] 617 800 618 801 [[package]] 802 + name = "regex" 803 + version = "1.12.3" 804 + source = "registry+https://github.com/rust-lang/crates.io-index" 805 + checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" 806 + dependencies = [ 807 + "aho-corasick", 808 + "memchr", 809 + "regex-automata", 810 + "regex-syntax", 811 + ] 812 + 813 + [[package]] 814 + name = "regex-automata" 815 + version = "0.4.14" 816 + source = "registry+https://github.com/rust-lang/crates.io-index" 817 + checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" 818 + dependencies = [ 819 + "aho-corasick", 820 + "memchr", 821 + "regex-syntax", 822 + ] 823 + 824 + [[package]] 825 + name = "regex-syntax" 826 + version = "0.8.10" 827 + source = "registry+https://github.com/rust-lang/crates.io-index" 828 + checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" 829 + 830 + [[package]] 619 831 name = "ring" 620 832 version = "0.17.14" 621 833 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 847 1059 checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 848 1060 849 1061 [[package]] 1062 + name = "signal-hook-registry" 1063 + version = "1.4.8" 1064 + source = "registry+https://github.com/rust-lang/crates.io-index" 1065 + checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" 1066 + dependencies = [ 1067 + "errno", 1068 + "libc", 1069 + ] 1070 + 1071 + [[package]] 850 1072 name = "simdutf8" 851 1073 version = "0.1.5" 852 1074 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 951 1173 "libc", 952 1174 "mio", 953 1175 "pin-project-lite", 1176 + "signal-hook-registry", 954 1177 "socket2", 955 1178 "tokio-macros", 956 1179 "windows-sys 0.61.2", ··· 1199 1422 version = "0.9.0" 1200 1423 source = "registry+https://github.com/rust-lang/crates.io-index" 1201 1424 checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" 1425 + 1426 + [[package]] 1427 + name = "utf8parse" 1428 + version = "0.2.2" 1429 + source = "registry+https://github.com/rust-lang/crates.io-index" 1430 + checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 1202 1431 1203 1432 [[package]] 1204 1433 name = "walkdir"
+9 -1
Cargo.toml
··· 5 5 6 6 [dependencies] 7 7 anyhow = "1.0.102" 8 + brotli = "8.0.2" 9 + colog = "1.4.0" 8 10 fastrand = "2.3.0" 11 + log = "0.4.29" 9 12 rmp-serde = "1.3.1" 10 13 rustls = "0.23.37" 11 14 serde = { version = "1.0.228", features = ["derive"] } 12 - tokio = { version = "1.50.0", features = ["macros", "rt-multi-thread"] } 15 + tokio = { version = "1.50.0", features = [ 16 + "macros", 17 + "rt-multi-thread", 18 + "fs", 19 + "signal", 20 + ] } 13 21 twilight-cache-inmemory = "0.17.1" 14 22 twilight-gateway = "0.17.1" 15 23 twilight-http = "0.17.1"
+37 -7
src/brain.rs
··· 1 + #![allow(unused)] 2 + 1 3 use std::collections::HashMap; 2 4 5 + use log::debug; 3 6 use serde::{Deserialize, Serialize}; 7 + use tokio::sync::oneshot; 4 8 5 9 /// Some = Word, None = End Message 6 10 pub type Token = Option<String>; ··· 11 15 12 16 #[derive(Default, Debug, Clone, Serialize, Deserialize)] 13 17 pub struct Brain(HashMap<Token, Edges>); 18 + 19 + pub type TypingSender = oneshot::Sender<bool>; 20 + pub type TypingReceiver = oneshot::Receiver<bool>; 14 21 15 22 pub fn format_token(tok: &Token) -> String { 16 23 if let Some(w) = tok { ··· 60 67 } 61 68 } 62 69 70 + const FORCE_REPLIES: bool = cfg!(test) || (option_env!("BINGUS_FORCE_REPLY").is_some()); 71 + 63 72 impl Brain { 64 73 fn normalize_token(word: &str) -> Token { 65 74 let w = if word.starts_with("http://") || word.starts_with("https://") { ··· 84 93 } 85 94 86 95 fn should_reply(rand: &mut fastrand::Rng, is_self: bool) -> bool { 87 - let chance = if is_self { 80 } else { 45 }; 96 + let chance = if is_self { 45 } else { 80 }; 88 97 let roll = rand.u8(0..=100); 89 98 90 - cfg!(test) || roll <= chance 99 + (FORCE_REPLIES && !is_self) || roll <= chance 91 100 } 92 101 93 102 fn extract_final_token(msg: &str) -> Option<Token> { ··· 106 115 } 107 116 } 108 117 109 - pub fn ingest(&mut self, msg: &str) { 118 + pub fn ingest(&mut self, msg: &str) -> bool { 119 + let mut learned_new_word = false; 110 120 // This is a silly way to do windows rust ppl :sob: 111 121 let _ = Self::parse(msg) 112 122 .map_windows(|[from, to]| { 113 - eprintln!("{from:?} {to:?}"); 114 123 if let Some(edge) = self.0.get_mut(from) { 115 124 edge.increment_token(to); 116 125 } else { 117 126 let new = Edges(HashMap::from_iter([(to.clone(), 1)]), 1); 118 127 self.0.insert(from.clone(), new); 128 + learned_new_word = true; 119 129 } 120 130 }) 121 131 .collect::<Vec<_>>(); 132 + 133 + learned_new_word 122 134 } 123 135 124 136 pub fn merge_from(&mut self, other: Self) { ··· 131 143 } 132 144 } 133 145 134 - pub fn respond(&self, msg: &str, is_self: bool) -> Option<String> { 146 + pub fn respond( 147 + &self, 148 + msg: &str, 149 + is_self: bool, 150 + mut typing_oneshot: Option<TypingSender>, 151 + ) -> Option<String> { 135 152 const MAX_TOKENS: usize = 20; 136 153 137 154 let mut rng = fastrand::Rng::new(); 138 155 139 156 // Roll if we should reply 140 157 if !Self::should_reply(&mut rng, is_self) { 158 + debug!("Failed roll"); 141 159 return None; 142 160 } 143 161 ··· 147 165 Self::extract_final_token(msg).or_else(|| self.random_token(&mut rng))?; 148 166 149 167 let mut chain = Vec::with_capacity(MAX_TOKENS); 168 + let mut has_triggered_typing = false; 150 169 151 170 while let Some(tok) = current_token 152 171 && chain.len() <= MAX_TOKENS ··· 155 174 let next = edges.sample(&mut rng).flatten(); 156 175 if let Some(ref s) = next { 157 176 chain.push(s.clone()); 177 + if !has_triggered_typing && let Some(typ) = typing_oneshot.take() { 178 + typ.send(true).ok(); 179 + } 158 180 } 159 181 current_token = next; 160 182 } else { ··· 162 184 } 163 185 } 164 186 187 + if let Some(typ) = typing_oneshot.take() { 188 + typ.send(false).ok(); 189 + } 190 + 165 191 Some(chain.join(" ")) 192 + } 193 + 194 + pub fn word_count(&self) -> usize { 195 + self.0.len() 166 196 } 167 197 168 198 pub fn get_weights(&self, tok: &str) -> Option<&Edges> { ··· 211 241 hello_edges.0, 212 242 HashMap::from_iter([(Some("world".to_string()), 1)]) 213 243 ); 214 - let reply = brain.respond("hello", false); 244 + let reply = brain.respond("hello", false, None); 215 245 assert_eq!(reply, Some("world".to_string())); 216 246 } 217 247 ··· 225 255 .join(" "); 226 256 let mut brain = Brain::default(); 227 257 brain.ingest(&msg); 228 - let reply = brain.respond("a", false); 258 + let reply = brain.respond("a", false, None); 229 259 let expected = LETTERS 230 260 .chars() 231 261 .skip(1)
+165 -26
src/main.rs
··· 1 1 #![feature(iter_map_windows)] 2 - #![allow(unused)] 3 2 4 3 mod brain; 4 + mod on_message; 5 + mod status; 5 6 6 7 pub mod prelude { 7 8 pub use anyhow::Context; ··· 9 10 pub type Result<T = (), E = anyhow::Error> = StdResult<T, E>; 10 11 } 11 12 12 - use std::{collections::HashSet, sync::Arc}; 13 + use std::{ 14 + collections::HashSet, 15 + fs::File, 16 + path::{Path, PathBuf}, 17 + sync::{ 18 + Arc, 19 + atomic::{AtomicBool, Ordering}, 20 + }, 21 + }; 13 22 23 + use brotli::enc::BrotliEncoderParams; 24 + use log::{debug, error, info, warn}; 14 25 use prelude::*; 26 + use tokio::{ 27 + sync::Mutex, 28 + time::{self, Duration}, 29 + }; 15 30 use twilight_cache_inmemory::{DefaultInMemoryCache, ResourceType}; 16 - use twilight_gateway::{Event, EventTypeFlags, Intents, Shard, ShardId, StreamExt}; 31 + use twilight_gateway::{ 32 + CloseFrame, Event, EventTypeFlags, Intents, MessageSender, Shard, ShardId, StreamExt, 33 + }; 17 34 use twilight_http::Client as HttpClient; 35 + use twilight_model::id::{Id, marker::UserMarker}; 36 + 37 + use crate::{brain::Brain, on_message::handle_discord_message, status::update_status}; 38 + 39 + pub type BrainHandle = Mutex<Brain>; 18 40 19 41 #[derive(Debug)] 20 - struct BotContext { 42 + pub struct BotContext { 21 43 http: HttpClient, 22 - reply_channels: HashSet<String>, 44 + self_id: Id<UserMarker>, 45 + brain_file_path: PathBuf, 46 + reply_channels: HashSet<u64>, 47 + brain_handle: BrainHandle, 48 + shard_sender: MessageSender, 49 + pending_save: AtomicBool, 23 50 } 24 51 25 - async fn handle_discord_event(event: Event, _ctx: Arc<BotContext>) -> Result { 52 + async fn handle_discord_event(event: Event, ctx: Arc<BotContext>) -> Result { 26 53 match event { 27 - Event::MessageCreate(msg) => { 28 - let channel_id = msg.channel_id.to_string(); 29 - eprintln!("id: {channel_id}"); 30 - } 54 + Event::MessageCreate(msg) => handle_discord_message(msg, ctx).await?, 31 55 Event::Ready(ev) => { 32 - eprintln!("Connected to gateway as {}", ev.user.name); 56 + info!("Connected to gateway as {}", ev.user.name); 57 + let brain = ctx.brain_handle.lock().await; 58 + update_status(&*brain, &ctx.shard_sender).context("Failed to update status")?; 33 59 } 34 - _ => {} 60 + _ => { 61 + debug!("Ev: {event:?}"); 62 + } 35 63 } 36 64 37 65 Ok(()) 38 66 } 39 67 68 + fn load_brain(path: &Path) -> Result<Option<Brain>> { 69 + if path.exists() { 70 + let mut file = File::open(path).context("Failed to open brain file")?; 71 + let mut brotli_stream = brotli::Decompressor::new(&mut file, 4096); 72 + rmp_serde::from_read(&mut brotli_stream) 73 + .map(|b| Some(b)) 74 + .context("Failed to decode brain file") 75 + } else { 76 + Ok(None) 77 + } 78 + } 79 + 80 + async fn save_brain(ctx: Arc<BotContext>) -> Result { 81 + let mut file = File::create(&ctx.brain_file_path).context("Failed to open brain file")?; 82 + let params = BrotliEncoderParams::default(); 83 + let mut brotli_writer = brotli::CompressorWriter::with_params(&mut file, 4096, &params); 84 + let brain = ctx.brain_handle.lock().await; 85 + rmp_serde::encode::write(&mut brotli_writer, &*brain) 86 + .context("Failed to write serialized brain")?; 87 + debug!("Saved brain file"); 88 + Ok(()) 89 + } 90 + 40 91 #[tokio::main] 41 92 async fn main() -> Result { 93 + let mut clog = colog::default_builder(); 94 + clog.filter( 95 + None, 96 + if cfg!(debug_assertions) { 97 + log::LevelFilter::Debug 98 + } else { 99 + log::LevelFilter::Info 100 + }, 101 + ); 102 + clog.try_init().context("Failed to initialize colog")?; 103 + 104 + info!("Start of bingus-bot {}", env!("CARGO_PKG_VERSION")); 105 + 42 106 // Config 43 107 let token_file = std::env::var("TOKEN_FILE").context("Missing TOKEN_FILE env var")?; 44 - let reply_channels: HashSet<String> = HashSet::from_iter( 45 - std::env::var("REPLY_CHANNELS") 46 - .context("Missing REPLY_CHANNELS env var")? 47 - .split(",") 48 - .map(|s| s.trim().to_string()), 49 - ); 108 + let reply_channels: HashSet<u64> = std::env::var("REPLY_CHANNELS") 109 + .context("Missing REPLY_CHANNELS env var")? 110 + .split(",") 111 + .map(|s| s.trim().parse::<u64>()) 112 + .collect::<Result<_, _>>() 113 + .context("Invalid channel IDs for REPLY_CHANNELS")?; 114 + let brain_file_path = 115 + PathBuf::from(std::env::var("BRAIN_FILE").unwrap_or_else(|_| "brain.msgpackz".to_string())); 50 116 let intents = Intents::GUILD_MESSAGES | Intents::MESSAGE_CONTENT; 51 117 52 118 // Read token 53 119 let token = std::fs::read_to_string(token_file).context("Failed to read bot token")?; 54 120 let token = token.trim(); 55 121 122 + // Read Brain 123 + let brain = if let Some(brain) = load_brain(&brain_file_path)? { 124 + info!("Loading brain from {brain_file_path:?}"); 125 + brain 126 + } else { 127 + info!("Creating new brain file at {brain_file_path:?}"); 128 + Brain::default() 129 + }; 130 + let brain_handle = Mutex::new(brain); 131 + 56 132 // Init 57 133 let mut shard = Shard::new(ShardId::ONE, token.to_string(), intents); 58 134 let http = HttpClient::new(token.to_string()); ··· 65 141 ) 66 142 .build(); 67 143 144 + let self_id = http 145 + .current_user_application() 146 + .await 147 + .context("Failed to get current App")? 148 + .model() 149 + .await 150 + .context("Failed to deserialize")? 151 + .bot 152 + .context("App is not a bot!")? 153 + .id; 154 + 68 155 let context = Arc::new(BotContext { 69 156 http, 157 + self_id, 70 158 reply_channels, 159 + brain_file_path, 160 + brain_handle, 161 + shard_sender: shard.sender(), 162 + pending_save: AtomicBool::new(false), 71 163 }); 72 164 73 - // Event Loop 74 - while let Some(res) = shard.next_event(EventTypeFlags::all()).await { 75 - match res { 76 - Ok(event) => { 77 - cache.update(&event); 78 - tokio::spawn(handle_discord_event(event, Arc::clone(&context))); 165 + info!("Ensuring brain is writable..."); 166 + save_brain(context.clone()) 167 + .await 168 + .context("Brain file is not writable")?; 169 + info!("Brain file saved"); 170 + 171 + let mut interval = time::interval(Duration::from_secs(60)); 172 + interval.tick().await; 173 + tokio::pin!(interval); 174 + 175 + info!("Connecting to gateway..."); 176 + 177 + loop { 178 + tokio::select! { 179 + Ok(()) = tokio::signal::ctrl_c() => { 180 + info!("SIGINT: Closing connection and saving"); 181 + shard.close(CloseFrame::NORMAL); 182 + break; 79 183 } 80 - Err(why) => { 81 - eprintln!("Failed to receive event: {why:?}"); 184 + _ = interval.tick() => { 185 + debug!("Save Interval"); 186 + if context.pending_save.load(Ordering::Relaxed) { 187 + let ctx = context.clone(); 188 + tokio::spawn(async move { 189 + if let Err(why) = save_brain(ctx.clone()).await { 190 + error!("Failed to save brain file:\n{why:?}"); 191 + } 192 + ctx.pending_save.store(true, Ordering::Relaxed); 193 + }); 194 + } 195 + }, 196 + opt = shard.next_event(EventTypeFlags::all()) => { 197 + match opt { 198 + Some(Ok(event)) => { 199 + cache.update(&event); 200 + let ctx = context.clone(); 201 + tokio::spawn(async move { 202 + if let Err(why) = handle_discord_event(event, ctx).await { 203 + error!("Error while processing Discord event:\n{why:?}"); 204 + } 205 + }); 206 + } 207 + Some(Err(why)) => { 208 + warn!("Failed to receive event:\n{why:?}"); 209 + } 210 + None => { 211 + info!("Disconnected from Discord: Saving brain and exiting"); 212 + break; 213 + } 214 + } 82 215 } 83 216 } 84 217 } 218 + 219 + save_brain(context) 220 + .await 221 + .context("Failed to write brain file on exit")?; 222 + 223 + info!("Save Complete, Exiting"); 85 224 86 225 Ok(()) 87 226 }
+72
src/on_message.rs
··· 1 + use std::{ 2 + boxed::Box, 3 + sync::{Arc, atomic::Ordering}, 4 + }; 5 + 6 + use log::warn; 7 + use twilight_model::{ 8 + channel::message::{AllowedMentions, MessageFlags, MessageType}, 9 + gateway::payload::incoming::MessageCreate, 10 + }; 11 + 12 + use crate::{BotContext, prelude::*, status::update_status}; 13 + 14 + pub async fn handle_discord_message(msg: Box<MessageCreate>, ctx: Arc<BotContext>) -> Result { 15 + let channel_id = msg.channel_id.get(); 16 + let is_self = msg.author.id == ctx.self_id; 17 + let is_normal_message = matches!(msg.kind, MessageType::Regular | MessageType::Reply); 18 + let is_ephemeral = msg 19 + .flags 20 + .is_some_and(|flags| flags.contains(MessageFlags::EPHEMERAL)); 21 + let is_dm = msg.guild_id.is_none(); 22 + 23 + // Should Ingest Message? 24 + if is_self || !is_normal_message || is_ephemeral || is_dm { 25 + return Ok(()); 26 + } 27 + 28 + let mut brain = ctx.brain_handle.lock().await; 29 + let learned_new_word = brain.ingest(&msg.content); 30 + ctx.pending_save.store(true, Ordering::Relaxed); 31 + 32 + if learned_new_word { 33 + update_status(&*brain, &ctx.shard_sender).context("Failed to update status")?; 34 + } 35 + 36 + // Should Reply to Message? 37 + if !ctx.reply_channels.contains(&channel_id) { 38 + return Ok(()); 39 + } 40 + 41 + let (typ_tx, typ_rx) = tokio::sync::oneshot::channel(); 42 + let (done_tx, done_rx) = tokio::sync::oneshot::channel(); 43 + 44 + let ctx_typ = ctx.clone(); 45 + let typ_id = msg.channel_id; 46 + tokio::spawn(async move { 47 + if typ_rx.await.ok().is_some_and(|start| start) { 48 + if let Err(why) = ctx_typ.http.create_typing_trigger(typ_id).await { 49 + warn!("Failed to set typing indicator:\n{why:?}"); 50 + } 51 + } 52 + done_tx.send(()).ok(); 53 + }); 54 + 55 + if let Some(reply_text) = brain 56 + .respond(&msg.content, is_self, Some(typ_tx)) 57 + .filter(|s| !s.trim().is_empty()) 58 + { 59 + drop(brain); 60 + done_rx.await.ok(); 61 + ctx.http 62 + .create_message(msg.channel_id) 63 + .content(&reply_text) 64 + .reply(msg.id) 65 + .fail_if_not_exists(false) 66 + .allowed_mentions(Some(&AllowedMentions::default())) 67 + .await 68 + .context("Failed to send message")?; 69 + } 70 + 71 + Ok(()) 72 + }
+41
src/status.rs
··· 1 + use log::debug; 2 + use twilight_gateway::MessageSender; 3 + use twilight_model::gateway::{ 4 + payload::outgoing::UpdatePresence, 5 + presence::{Activity, ActivityType, Status}, 6 + }; 7 + 8 + use crate::{brain::Brain, prelude::*}; 9 + 10 + pub fn update_status(brain: &Brain, sender: &MessageSender) -> Result { 11 + let words = brain.word_count(); 12 + 13 + let activity = Activity { 14 + application_id: None, 15 + assets: None, 16 + buttons: Vec::new(), 17 + created_at: None, 18 + details: None, 19 + emoji: None, 20 + flags: None, 21 + id: None, 22 + instance: None, 23 + kind: ActivityType::Custom, 24 + name: "Bingus".to_string(), 25 + party: None, 26 + secrets: None, 27 + state: Some(format!("I know {words} words!")), 28 + timestamps: None, 29 + url: None, 30 + }; 31 + 32 + let status = UpdatePresence::new(vec![activity], false, None, Status::Online) 33 + .context("Failed to make status")?; 34 + 35 + sender 36 + .command(&status) 37 + .context("Failed to send to gateway")?; 38 + 39 + debug!("Sent status update"); 40 + Ok(()) 41 + }