1use ahash::{HashMapExt as _, RandomState};
2use dialoguer::theme::ColorfulTheme;
3use dialoguer::{Input, Select};
4use plugin_builder::Listen;
5use serde::{Deserialize, Serialize};
6use serde_json::{self, Value};
7use std::collections::{HashMap, HashSet};
8use std::env;
9use std::fmt::{Debug, Display};
10use std::future::Future;
11use std::io::Write as _;
12use std::net::{Ipv4Addr, Ipv6Addr};
13use std::pin::Pin;
14use std::{fs, net::IpAddr, sync::Arc};
15use tokio::sync::mpsc::{self};
16use tokio::sync::{oneshot, watch};
17use tokio::task::JoinHandle;
18
19use crate::error::{BotBuildError, BotError};
20use crate::task::TASK_MANAGER;
21
22#[cfg(feature = "plugin-access-control")]
23pub use crate::bot::runtimebot::kovi_api::AccessControlMode;
24
25pub(crate) mod connect;
26pub(crate) mod handler;
27pub(crate) mod run;
28
29pub mod message;
30pub mod plugin_builder;
31pub mod runtimebot;
32
33tokio::task_local! {
34 pub static PLUGIN_BUILDER: crate::PluginBuilder;
35}
36
37tokio::task_local! {
38 pub static PLUGIN_NAME: Arc<String>;
39}
40
41#[derive(Debug, Clone, Deserialize, Serialize)]
43pub struct KoviConf {
44 pub config: Config,
45 pub server: Server,
46}
47
48impl AsRef<KoviConf> for KoviConf {
49 fn as_ref(&self) -> &KoviConf {
50 self
51 }
52}
53
54#[derive(Debug, Clone, Deserialize, Serialize)]
55pub struct Config {
56 pub main_admin: i64,
57 pub admins: Vec<i64>,
58 pub debug: bool,
59}
60
61impl KoviConf {
62 pub fn new(main_admin: i64, admins: Option<Vec<i64>>, server: Server, debug: bool) -> Self {
63 KoviConf {
64 config: Config {
65 main_admin,
66 admins: admins.unwrap_or_default(),
67 debug,
68 },
69 server,
70 }
71 }
72}
73
74type KoviAsyncFn = dyn Fn() -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync;
75
76impl Drop for Bot {
77 fn drop(&mut self) {
78 for i in self.run_abort.iter() {
79 i.abort();
80 }
81 }
82}
83
84#[derive(Clone)]
86pub struct Bot {
87 pub information: BotInformation,
88 pub(crate) plugins: HashMap<String, BotPlugin, RandomState>,
89 pub(crate) run_abort: Vec<tokio::task::AbortHandle>,
90}
91
92#[derive(Clone)]
93pub(crate) struct BotPlugin {
94 pub(crate) enable_on_startup: bool,
95 pub(crate) enabled: watch::Sender<bool>,
96
97 pub(crate) name: String,
98 pub(crate) version: String,
99 pub(crate) main: Arc<KoviAsyncFn>,
100 pub(crate) listen: Listen,
101
102 #[cfg(feature = "plugin-access-control")]
103 pub(crate) access_control: bool,
104 #[cfg(feature = "plugin-access-control")]
105 pub(crate) list_mode: AccessControlMode,
106 #[cfg(feature = "plugin-access-control")]
107 pub(crate) access_list: AccessList,
108}
109
110#[cfg(feature = "plugin-access-control")]
111#[derive(Clone, Debug, Default, Deserialize, Serialize)]
112pub struct AccessList {
113 pub friends: HashSet<i64>,
114 pub groups: HashSet<i64>,
115}
116
117#[derive(Clone, Debug, Deserialize, Serialize)]
118pub struct PluginInfo {
119 pub name: String,
120 pub version: String,
121 pub enabled: bool,
123 pub enable_on_startup: bool,
125 #[cfg(feature = "plugin-access-control")]
127 pub access_control: bool,
128 #[cfg(feature = "plugin-access-control")]
130 pub list_mode: AccessControlMode,
131 #[cfg(feature = "plugin-access-control")]
133 pub access_list: AccessList,
134}
135
136#[derive(Debug, Clone, Deserialize, Serialize)]
137struct PluginStatus {
138 enable_on_startup: bool,
139 #[cfg(feature = "plugin-access-control")]
140 access_control: bool,
141 #[cfg(feature = "plugin-access-control")]
142 list_mode: AccessControlMode,
143 #[cfg(feature = "plugin-access-control")]
144 access_list: AccessList,
145}
146
147#[derive(Debug, Clone)]
149pub struct BotInformation {
150 pub main_admin: i64,
151 pub deputy_admins: HashSet<i64>,
152 pub server: Server,
153}
154#[derive(Deserialize, Serialize, Debug, Clone)]
156pub struct Server {
157 pub host: Host,
158 pub port: u16,
159 pub access_token: String,
160 pub secure: bool,
161}
162
163#[derive(Deserialize, Serialize, Debug, Clone)]
164#[serde(untagged)]
165pub enum Host {
166 IpAddr(IpAddr),
167 Domain(String),
168}
169
170impl Display for Host {
171 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
172 match self {
173 Host::IpAddr(ip) => write!(f, "{}", ip),
174 Host::Domain(domain) => write!(f, "{}", domain),
175 }
176 }
177}
178
179impl Server {
180 pub fn new(host: Host, port: u16, access_token: String, secure: bool) -> Self {
181 Server {
182 host,
183 port,
184 access_token,
185 secure,
186 }
187 }
188}
189
190#[derive(Debug, Deserialize, Serialize, Clone)]
191pub struct SendApi {
192 pub action: String,
193 pub params: Value,
194 pub echo: String,
195}
196
197#[derive(Debug, Clone, Deserialize, Serialize)]
198pub struct ApiReturn {
199 pub status: String,
200 pub retcode: i32,
201 pub data: Value,
202 pub echo: String,
203}
204
205pub(crate) type ApiAndOneshot = (
206 SendApi,
207 Option<oneshot::Sender<Result<ApiReturn, ApiReturn>>>,
208);
209
210impl std::fmt::Display for ApiReturn {
211 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
212 write!(
213 f,
214 "status: {}, retcode: {}, data: {}, echo: {}",
215 self.status, self.retcode, self.data, self.echo
216 )
217 }
218}
219
220impl std::fmt::Display for SendApi {
221 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
222 write!(f, "{}", serde_json::to_string(self).unwrap())
223 }
224}
225
226impl SendApi {
227 pub fn new(action: &str, params: Value, echo: &str) -> Self {
228 SendApi {
229 action: action.to_string(),
230 params,
231 echo: echo.to_string(),
232 }
233 }
234}
235
236impl BotPlugin {
237 fn shutdown(&mut self) -> JoinHandle<()> {
238 log::debug!("Plugin '{}' is dropping.", self.name,);
239
240 let plugin_name_ = Arc::new(self.name.clone());
241
242 let mut task_vec = Vec::new();
243
244 for listen in &self.listen.drop {
245 let listen_clone = listen.clone();
246 let plugin_name_ = plugin_name_.clone();
247 let task = tokio::spawn(async move {
248 PLUGIN_NAME
249 .scope(plugin_name_, Bot::handler_drop(listen_clone))
250 .await;
251 });
252 task_vec.push(task);
253 }
254
255 TASK_MANAGER.disable_plugin(&self.name);
256
257 self.enabled.send_modify(|v| {
258 *v = false;
259 });
260 self.listen.clear();
261 tokio::spawn(async move {
262 for task in task_vec {
263 let _ = task.await;
264 }
265 })
266 }
267}
268
269impl Bot {
270 pub fn build<C>(conf: C) -> Bot
288 where
289 C: AsRef<KoviConf>,
290 {
291 let conf = conf.as_ref();
292 Bot {
293 information: BotInformation {
294 main_admin: conf.config.main_admin,
295 deputy_admins: conf.config.admins.iter().cloned().collect(),
296 server: conf.server.clone(),
297 },
298 plugins: HashMap::<_, _, RandomState>::new(),
299 run_abort: Vec::new(),
300 }
301 }
302
303 pub fn mount_main<T>(&mut self, name: T, version: T, main: Arc<KoviAsyncFn>)
305 where
306 String: From<T>,
307 {
308 let name = String::from(name);
309 let version = String::from(version);
310 let (tx, _rx) = watch::channel(true);
311 let bot_plugin = BotPlugin {
312 enable_on_startup: true,
313 enabled: tx,
314 name: name.clone(),
315 version,
316 main,
317 listen: Listen::default(),
318
319 #[cfg(feature = "plugin-access-control")]
320 access_control: false,
321 #[cfg(feature = "plugin-access-control")]
322 list_mode: AccessControlMode::WhiteList,
323 #[cfg(feature = "plugin-access-control")]
324 access_list: AccessList::default(),
325 };
326 self.plugins.insert(name, bot_plugin);
327 }
328
329 pub fn load_local_conf() -> Result<KoviConf, BotBuildError> {
331 let kovi_conf_file_exist = fs::metadata("kovi.conf.toml").is_ok();
333
334 let conf_json: KoviConf = if kovi_conf_file_exist {
335 match fs::read_to_string("kovi.conf.toml") {
336 Ok(v) => match toml::from_str(&v) {
337 Ok(conf) => conf,
338 Err(err) => {
339 eprintln!("Configuration file parsing error: {}", err);
340 config_file_write_and_return()
341 .map_err(|e| BotBuildError::FileCreateError(e.to_string()))?
342 }
343 },
344 Err(err) => {
345 return Err(BotBuildError::FileReadError(err.to_string()));
346 }
347 }
348 } else {
349 config_file_write_and_return()
350 .map_err(|e| BotBuildError::FileCreateError(e.to_string()))?
351 };
352
353 unsafe {
354 if env::var("RUST_LOG").is_err() {
355 if conf_json.config.debug {
356 env::set_var("RUST_LOG", "debug");
357 } else {
358 env::set_var("RUST_LOG", "info");
359 }
360 }
361 }
362
363 Ok(conf_json)
364 }
365}
366
367impl Bot {
368 pub fn set_plugin_startup_use_file(mut self) -> Self {
374 let file_path = "kovi.plugin.toml";
375 let content = match fs::read_to_string(file_path) {
376 Ok(v) => {
377 log::debug!("Set plugin startup use file successfully");
378 v
379 }
380 Err(e) => {
381 log::debug!("Failed to read file: {}", e);
382 return self;
383 }
384 };
385 let mut plugin_status_map: HashMap<String, PluginStatus> = match toml::from_str(&content) {
386 Ok(v) => v,
387 Err(e) => {
388 log::debug!("Failed to parse toml: {}", e);
389 return self;
390 }
391 };
392
393 for (name, plugin) in self.plugins.iter_mut() {
394 if let Some(plugin_status) = plugin_status_map.remove(name) {
395 plugin.enable_on_startup = plugin_status.enable_on_startup;
396 plugin.enabled.send_modify(|v| {
397 *v = plugin_status.enable_on_startup;
398 });
399 #[cfg(feature = "plugin-access-control")]
400 {
401 plugin.access_control = plugin_status.access_control;
402 plugin.list_mode = plugin_status.list_mode;
403 plugin.access_list = plugin_status.access_list;
404 }
405 }
406 }
407
408 self
409 }
410
411 pub fn set_plugin_startup_use_file_ref(&mut self) {
417 let file_path = "kovi.plugin.toml";
418 let content = match fs::read_to_string(file_path) {
419 Ok(v) => {
420 log::debug!("Set plugin startup use file successfully");
421 v
422 }
423 Err(e) => {
424 log::debug!("Failed to read file: {}", e);
425 return;
426 }
427 };
428 let mut plugin_status_map: HashMap<String, PluginStatus> = match toml::from_str(&content) {
429 Ok(v) => v,
430 Err(e) => {
431 log::debug!("Failed to parse toml: {}", e);
432 return;
433 }
434 };
435
436 for (name, plugin) in self.plugins.iter_mut() {
437 if let Some(plugin_status) = plugin_status_map.remove(name) {
438 plugin.enable_on_startup = plugin_status.enable_on_startup;
439 plugin.enabled.send_modify(|v| {
440 *v = plugin_status.enable_on_startup;
441 });
442 #[cfg(feature = "plugin-access-control")]
443 {
444 plugin.access_control = plugin_status.access_control;
445 plugin.list_mode = plugin_status.list_mode;
446 plugin.access_list = plugin_status.access_list;
447 }
448 }
449 }
450 }
451
452 pub fn set_all_plugin_startup(mut self, enabled: bool) -> Self {
454 for plugin in self.plugins.values_mut() {
455 plugin.enable_on_startup = enabled;
456 plugin.enabled.send_modify(|v| {
457 *v = enabled;
458 });
459 }
460 self
461 }
462
463 pub fn set_all_plugin_startup_ref(&mut self, enabled: bool) {
465 for plugin in self.plugins.values_mut() {
466 plugin.enable_on_startup = enabled;
467 plugin.enabled.send_modify(|v| {
468 *v = enabled;
469 });
470 }
471 }
472
473 pub fn set_plugin_startup<T: AsRef<str>>(
475 mut self,
476 name: T,
477 enabled: bool,
478 ) -> Result<Self, BotError> {
479 let name = name.as_ref();
480 if let Some(plugin) = self.plugins.get_mut(name) {
481 plugin.enable_on_startup = enabled;
482 plugin.enabled.send_modify(|v| {
483 *v = enabled;
484 });
485 Ok(self)
486 } else {
487 Err(BotError::PluginNotFound(format!(
488 "Plugin {} not found",
489 name
490 )))
491 }
492 }
493
494 pub fn set_plugin_startup_ref<T: AsRef<str>>(
496 &mut self,
497 name: T,
498 enabled: bool,
499 ) -> Result<(), BotError> {
500 let name = name.as_ref();
501 if let Some(plugin) = self.plugins.get_mut(name) {
502 plugin.enable_on_startup = enabled;
503 plugin.enabled.send_modify(|v| {
504 *v = enabled;
505 });
506 Ok(())
507 } else {
508 Err(BotError::PluginNotFound(format!(
509 "Plugin {} not found",
510 name
511 )))
512 }
513 }
514
515 #[cfg(any(feature = "save_plugin_status", feature = "save_bot_admin"))]
516 pub(crate) fn save_bot_status(&self) {
517 #[cfg(feature = "save_plugin_status")]
518 {
519 let _file_path = "kovi.plugin.toml";
520
521 let mut plugin_status = HashMap::new();
522 for (name, plugin) in self.plugins.iter() {
523 plugin_status.insert(name.clone(), PluginStatus {
524 enable_on_startup: *plugin.enabled.borrow(),
525 #[cfg(feature = "plugin-access-control")]
526 access_control: plugin.access_control,
527 #[cfg(feature = "plugin-access-control")]
528 list_mode: plugin.list_mode,
529 #[cfg(feature = "plugin-access-control")]
530 access_list: plugin.access_list.clone(),
531 });
532 }
533
534 let serialized = match toml::to_string(&plugin_status) {
535 Ok(s) => s,
536 Err(e) => {
537 log::error!("Failed to serialize plugin status: {}", e);
538 return;
539 }
540 };
541 if let Err(e) = fs::write(_file_path, serialized) {
542 log::error!("Failed to write plugin status to file: {}", e);
543 }
544 }
545
546 #[cfg(feature = "save_bot_admin")]
547 {
548 let file_path = "kovi.conf.toml";
549 let existing_content = fs::read_to_string(file_path).unwrap_or_default();
550
551 let mut doc = existing_content
552 .parse::<toml_edit::DocumentMut>()
553 .unwrap_or_else(|_| toml_edit::DocumentMut::new());
554
555 if !doc.contains_key("config") {
557 doc["config"] = toml_edit::table();
558 }
559
560 doc["config"]["main_admin"] = toml_edit::value(self.information.main_admin);
562 doc["config"]["admins"] = toml_edit::Item::Value(toml_edit::Value::Array(
563 self.information
564 .deputy_admins
565 .iter()
566 .map(|&x| toml_edit::Value::from(x))
567 .collect(),
568 ));
569
570 match fs::File::create(file_path) {
571 Ok(file) => {
572 let mut writer = std::io::BufWriter::new(file);
573 if let Err(e) = writer.write_all(doc.to_string().as_bytes()) {
574 log::error!("Failed to write to file: {}", e);
575 }
576 }
577 Err(e) => {
578 log::error!("Failed to create file: {}", e);
579 }
580 }
581 }
582 }
583}
584
585fn config_file_write_and_return() -> Result<KoviConf, std::io::Error> {
587 enum HostType {
588 IPv4,
589 IPv6,
590 Domain,
591 }
592
593 let host_type: HostType = {
594 let items = ["IPv4", "IPv6", "Domain"];
595 let select = Select::with_theme(&ColorfulTheme::default())
596 .with_prompt("What is the type of the host of the OneBot server?")
597 .items(&items)
598 .default(0)
599 .interact()
600 .unwrap();
601
602 match select {
603 0 => HostType::IPv4,
604 1 => HostType::IPv6,
605 2 => HostType::Domain,
606 _ => panic!(), }
608 };
609
610 let host = match host_type {
611 HostType::IPv4 => {
612 let ip = Input::with_theme(&ColorfulTheme::default())
613 .with_prompt("What is the IP of the OneBot server?")
614 .default(Ipv4Addr::new(127, 0, 0, 1))
615 .interact_text()
616 .unwrap();
617 Host::IpAddr(IpAddr::V4(ip))
618 }
619 HostType::IPv6 => {
620 let ip = Input::with_theme(&ColorfulTheme::default())
621 .with_prompt("What is the IP of the OneBot server?")
622 .default(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 1))
623 .interact_text()
624 .unwrap();
625 Host::IpAddr(IpAddr::V6(ip))
626 }
627 HostType::Domain => {
628 let domain = Input::with_theme(&ColorfulTheme::default())
629 .with_prompt("What is the domain of the OneBot server?")
630 .default("localhost".to_string())
631 .interact_text()
632 .unwrap();
633 Host::Domain(domain)
634 }
635 };
636
637 let port: u16 = Input::with_theme(&ColorfulTheme::default())
638 .with_prompt("What is the port of the OneBot server?")
639 .default(8081)
640 .interact_text()
641 .unwrap();
642
643 let access_token: String = Input::with_theme(&ColorfulTheme::default())
644 .with_prompt("What is the access_token of the OneBot server? (Optional)")
645 .default("".to_string())
646 .show_default(false)
647 .interact_text()
648 .unwrap();
649
650 let main_admin: i64 = Input::with_theme(&ColorfulTheme::default())
651 .with_prompt("What is the ID of the main administrator? (Not used yet)")
652 .allow_empty(true)
653 .interact_text()
654 .unwrap();
655
656 let more: bool = {
658 let items = ["No", "Yes"];
659 let select = Select::with_theme(&ColorfulTheme::default())
660 .with_prompt("Do you want to view more optional options?")
661 .items(&items)
662 .default(0)
663 .interact()
664 .unwrap();
665
666 match select {
667 0 => false,
668 1 => true,
669 _ => panic!(), }
671 };
672
673 let mut secure = false;
674 if more {
675 secure = {
677 let items = vec!["No", "Yes"];
678 let select = Select::with_theme(&ColorfulTheme::default())
679 .with_prompt("Enable secure connection? (WSS)")
681 .items(&items)
682 .default(0)
683 .interact()
684 .unwrap();
685
686 match select {
687 0 => false,
688 1 => true,
689 _ => panic!(), }
691 };
692 }
693
694 let config = KoviConf::new(
695 main_admin,
696 None,
697 Server::new(host, port, access_token, secure),
698 false,
699 );
700
701 let mut doc = toml_edit::DocumentMut::new();
702 doc["config"] = toml_edit::table();
703 doc["config"]["main_admin"] = toml_edit::value(config.config.main_admin);
704 doc["config"]["admins"] = toml_edit::Item::Value(toml_edit::Value::Array(
705 config
706 .config
707 .admins
708 .iter()
709 .map(|&x| toml_edit::Value::from(x))
710 .collect(),
711 ));
712 doc["config"]["debug"] = toml_edit::value(config.config.debug);
713
714 doc["server"] = toml_edit::table();
715 doc["server"]["host"] = match &config.server.host {
716 Host::IpAddr(ip) => toml_edit::value(ip.to_string()),
717 Host::Domain(domain) => toml_edit::value(domain.clone()),
718 };
719 doc["server"]["port"] = toml_edit::value(config.server.port as i64);
720 doc["server"]["access_token"] = toml_edit::value(config.server.access_token.clone());
721 doc["server"]["secure"] = toml_edit::value(config.server.secure);
722
723 let file = fs::File::create("kovi.conf.toml")?;
724 let mut writer = std::io::BufWriter::new(file);
725 writer.write_all(doc.to_string().as_bytes())?;
726
727 Ok(config)
728}
729
730#[macro_export]
731macro_rules! build_bot {
732 ($( $plugin:ident ),* $(,)* ) => {
733 {
734 let conf = match kovi::bot::Bot::load_local_conf() {
735 Ok(c) => c,
736 Err(e) => {
737 eprintln!("Error loading config: {}", e);
738 panic!("Failed to load config");
739 }
740 };
741 kovi::logger::try_set_logger();
742 let mut bot = kovi::bot::Bot::build(&conf);
743
744 $(
745 let (crate_name, crate_version) = $plugin::__kovi_get_plugin_info();
746 kovi::log::info!("Mounting plugin: {}", crate_name);
747 bot.mount_main(crate_name, crate_version, std::sync::Arc::new($plugin::__kovi_run_async_plugin));
748 )*
749
750 bot.set_plugin_startup_use_file_ref();
751 bot
752 }
753 };
754}
755
756#[test]
757fn build_bot() {
758 let conf = KoviConf::new(
759 123456,
760 None,
761 Server {
762 host: Host::IpAddr("127.0.0.1".parse().unwrap()),
763 port: 8081,
764 access_token: "".to_string(),
765 secure: false,
766 },
767 false,
768 );
769 let _ = Bot::build(conf);
770}