1use super::SystemModule;
4
5use crate::sysmod::camera::{self, TakePicOption};
6use crate::sysmod::openai::chat_history::ChatHistory;
7use crate::sysmod::openai::function::FUNCTION_TOKEN;
8use crate::sysmod::openai::{self, OpenAi, OpenAiErrorKind, SearchContextSize, Tool, UserLocation};
9use crate::sysmod::openai::{Role, function::FunctionTable};
10use crate::taskserver;
11use crate::{config, taskserver::Control};
12use utils::netutil;
13use utils::playtools::dice::{self};
14
15use anyhow::Context as _;
16use anyhow::{Result, anyhow, bail, ensure};
17use chrono::{NaiveTime, Utc};
18use log::{error, info, warn};
19use poise::{CreateReply, FrameworkContext, serenity_prelude as serenity};
20use serde::{Deserialize, Serialize};
21use serenity::Client;
22use serenity::all::{CreateAttachment, FullEvent};
23use serenity::http::MessagePagination;
24use serenity::model::prelude::*;
25use serenity::prelude::*;
26
27use std::collections::{BTreeMap, HashSet};
28use std::fmt::Display;
29use std::sync::Arc;
30use std::time::Duration;
31use std::time::Instant;
32
33struct PoiseData {
34 ctrl: Control,
35}
36type PoiseError = anyhow::Error;
37type PoiseContext<'a> = poise::Context<'a, PoiseData, PoiseError>;
38
39const MSG_MAX_LEN: usize = 2000;
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct DiscordConfig {
45 enabled: bool,
47 token: String,
49 notif_channel: u64,
53 auto_del_chs: Vec<u64>,
55 owner_ids: Vec<u64>,
58 perm_err_msg: String,
61 #[serde(default)]
63 prompt: DiscordPrompt,
64}
65
66impl Default for DiscordConfig {
67 fn default() -> Self {
68 Self {
69 enabled: false,
70 token: "".to_string(),
71 notif_channel: 0,
72 auto_del_chs: Default::default(),
73 owner_ids: Default::default(),
74 perm_err_msg: "バカジャネーノ".to_string(),
75 prompt: Default::default(),
76 }
77 }
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct DiscordPrompt {
82 pub instructions: Vec<String>,
84 pub each: Vec<String>,
86 pub history_timeout_min: u32,
88}
89
90const DEFAULT_TOML: &str = include_str!(concat!(
92 env!("CARGO_MANIFEST_DIR"),
93 "/res/openai_discord.toml"
94));
95impl Default for DiscordPrompt {
96 fn default() -> Self {
97 toml::from_str(DEFAULT_TOML).unwrap()
98 }
99}
100
101pub struct Discord {
105 config: DiscordConfig,
107 wakeup_list: Vec<NaiveTime>,
109 ctx: Option<Context>,
113 postponed_msgs: Vec<String>,
117
118 auto_del_config: BTreeMap<ChannelId, AutoDeleteConfig>,
120
121 chat_history: Option<ChatHistory>,
123 chat_timeout: Option<Instant>,
125 func_table: Option<FunctionTable<()>>,
127}
128
129#[derive(Clone, Copy)]
131pub struct AutoDeleteConfig {
132 keep_count: u32,
134 keep_dur_min: u32,
136}
137
138impl Display for AutoDeleteConfig {
139 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141 let count_str = if self.keep_count != 0 {
142 self.keep_count.to_string()
143 } else {
144 "Disabled".to_string()
145 };
146 let time_str = if self.keep_dur_min != 0 {
147 let (d, h, m) = convert_duration(self.keep_dur_min);
148 format!("{d} day(s) {h} hour(s) {m} minute(s)")
149 } else {
150 "Disabled".to_string()
151 };
152
153 write!(f, "Keep Count: {count_str}\nKeep Time: {time_str}")
154 }
155}
156
157impl Discord {
158 pub fn new(wakeup_list: Vec<NaiveTime>) -> Result<Self> {
163 info!("[discord] initialize");
164
165 let config = config::get(|cfg| cfg.discord.clone());
166
167 let mut auto_del_config = BTreeMap::new();
168 for &ch in &config.auto_del_chs {
169 ensure!(ch != 0);
170 auto_del_config.insert(
171 ChannelId::new(ch),
172 AutoDeleteConfig {
173 keep_count: 0,
174 keep_dur_min: 0,
175 },
176 );
177 }
178
179 Ok(Self {
180 config,
181 wakeup_list,
182 ctx: None,
183 postponed_msgs: Default::default(),
184 auto_del_config,
185 chat_history: None,
186 chat_timeout: None,
187 func_table: None,
188 })
189 }
190
191 async fn init_openai(&mut self, ctrl: &Control) {
192 let (model_info, reserved) = {
195 let openai = ctrl.sysmods().openai.lock().await;
196
197 (
198 openai.model_info_offline(),
199 openai.get_output_reserved_token(),
200 )
201 };
202
203 let mut chat_history = ChatHistory::new(model_info.name);
204 assert!(chat_history.get_total_limit() == model_info.context_window);
205 let inst_token: usize = self
206 .config
207 .prompt
208 .instructions
209 .iter()
210 .map(|text| chat_history.token_count(text))
211 .sum();
212 let reserved = FUNCTION_TOKEN + inst_token + reserved;
213 chat_history.reserve_tokens(reserved);
214 info!("[discord] OpenAI token limit");
215 info!("[discord] {:6} total", model_info.context_window);
216 info!("[discord] {reserved:6} reserved");
217 info!("[discord] {:6} chat history", chat_history.usage().1);
218
219 let mut func_table = FunctionTable::new(Arc::clone(ctrl), Some("discord"));
220 func_table.register_basic_functions();
221
222 let _ = self.chat_history.insert(chat_history);
223 let _ = self.func_table.insert(func_table);
224 }
225
226 fn chat_history(&mut self) -> &ChatHistory {
227 self.chat_history.as_ref().unwrap()
228 }
229
230 fn chat_history_mut(&mut self) -> &mut ChatHistory {
231 self.chat_history.as_mut().unwrap()
232 }
233
234 fn func_table(&self) -> &FunctionTable<()> {
235 self.func_table.as_ref().unwrap()
236 }
237
238 pub async fn say(&mut self, msg: &str) -> Result<()> {
248 if !self.config.enabled {
249 info!("[discord] disabled - msg: {msg}");
250 return Ok(());
251 }
252 if self.config.notif_channel == 0 {
253 info!("[discord] notification disabled - msg: {msg}");
254 return Ok(());
255 }
256 if self.ctx.is_none() {
257 info!("[discord] not ready, postponed - msg: {msg}");
258 self.postponed_msgs.push(msg.to_string());
259 return Ok(());
260 }
261
262 info!("[discord] say msg: {msg}");
263 let ch = ChannelId::new(self.config.notif_channel);
264 let ctx = self.ctx.as_ref().unwrap();
265 ch.say(ctx, msg).await?;
266
267 Ok(())
268 }
269
270 fn check_history_timeout(&mut self) {
272 let now = Instant::now();
273
274 if let Some(timeout) = self.chat_timeout
275 && now > timeout
276 {
277 self.chat_history_mut().clear();
278 self.chat_timeout = None;
279 }
280 }
281}
282
283async fn discord_main(ctrl: Control) -> Result<()> {
287 let (config, wakeup_list) = {
288 let mut discord = ctrl.sysmods().discord.lock().await;
289 discord.init_openai(&ctrl).await;
290
291 (discord.config.clone(), discord.wakeup_list.clone())
292 };
293
294 let mut owners = HashSet::new();
296 for id in config.owner_ids {
297 if id == 0 {
298 bail!("owner id must not be 0");
299 }
300 owners.insert(UserId::new(id));
301 }
302 info!("[discord] owners: {owners:?}");
303
304 let ctrl_for_setup = ctrl.clone();
305 let framework = poise::Framework::builder()
306 .initialize_owners(false)
308 .options(poise::FrameworkOptions {
310 on_error: |err| Box::pin(on_error(err)),
311 pre_command: |ctx| Box::pin(pre_command(ctx)),
312 post_command: |ctx| Box::pin(post_command(ctx)),
313 event_handler: |ctx, ev, fctx, data| Box::pin(event_handler(ctx, ev, fctx, data)),
314 prefix_options: poise::PrefixFrameworkOptions {
316 prefix: None,
317 mention_as_prefix: true,
318 ..Default::default()
319 },
320 initialize_owners: false,
322 owners,
323 commands: command_list(),
325 ..Default::default()
326 })
327 .setup(|ctx, _ready, framework| {
329 Box::pin(async move {
331 let mut discord = ctrl_for_setup.sysmods().discord.lock().await;
332 discord.ctx = Some(ctx.clone());
333
334 info!("[discord] register commands...");
335 poise::builtins::register_globally(ctx, &framework.options().commands).await?;
336 info!("[discord] register commands OK");
337
338 Ok(PoiseData {
340 ctrl: Arc::clone(&ctrl_for_setup),
341 })
342 })
343 })
344 .build();
345
346 let intents = GatewayIntents::non_privileged() | GatewayIntents::MESSAGE_CONTENT;
348 let mut client = Client::builder(config.token.clone(), intents)
349 .framework(framework)
350 .await?;
351
352 let ctrl_for_cancel = Arc::clone(&ctrl);
355 let shard_manager = client.shard_manager.clone();
356 taskserver::spawn_oneshot_fn(&ctrl, "discord-exit", async move {
357 ctrl_for_cancel.wait_cancel_rx().await;
358 info!("[discord-exit] recv cancel");
359 shard_manager.shutdown_all().await;
360 info!("[discord-exit] shutdown_all ok");
361 let ctx = ctrl_for_cancel.sysmods().discord.lock().await.ctx.take();
366 drop(ctx);
367 info!("[discord-exit] context dropped");
368
369 Ok(())
370 });
371
372 taskserver::spawn_periodic_task(&ctrl, "discord-periodic", &wakeup_list, periodic_main);
374
375 client.start().await?;
377 info!("[discord] client exit");
378
379 Ok(())
380}
381
382async fn reply_long(ctx: &PoiseContext<'_>, content: &str) -> Result<()> {
384 const LEN: usize = MSG_MAX_LEN - 128;
386
387 let mut remain = content;
388 loop {
389 let (chunk, fin) = match remain.char_indices().nth(LEN) {
390 Some((ind, _c)) => {
391 let (a, b) = remain.split_at(ind);
392 remain = b;
393
394 (a, false)
395 }
396 None => (remain, true),
397 };
398 if !chunk.is_empty() {
399 ctx.reply(chunk).await?;
400 }
401 if fin {
402 break;
403 }
404 }
405 Ok(())
406}
407
408async fn reply_long_mdquote(ctx: &PoiseContext<'_>, content: &str) -> Result<()> {
411 const LEN: usize = MSG_MAX_LEN - 128;
414 const QUOTE_PRE: &str = "```\n";
415 const QUOTE_PST: &str = "\n```";
416 const SPECIALS: &str = "\\`";
417
418 let mut count = 0;
419 let mut buf = String::from(QUOTE_PRE);
420 for c in content.chars() {
421 if count >= LEN {
422 buf.push_str(QUOTE_PST);
423 ctx.reply(buf).await?;
424
425 count = 0;
426 buf = String::from(QUOTE_PRE);
427 }
428 if SPECIALS.find(c).is_some() {
429 buf.push('\\');
430 }
431 buf.push(c);
432 count += 1;
433 }
434 if !buf.is_empty() {
435 buf.push_str(QUOTE_PST);
436 ctx.reply(buf).await?;
437 }
438 Ok(())
439}
440
441fn remove_empty_lines(src: &str) -> String {
442 src.lines()
443 .filter(|line| !line.is_empty())
444 .collect::<Vec<_>>()
445 .join("\n")
446}
447
448fn convert_duration(mut min: u32) -> (u32, u32, u32) {
450 let day = min / (60 * 24);
451 min %= 60 * 24;
452 let hour = min / 60;
453 min %= 60;
454
455 (day, hour, min)
456}
457
458fn parse_duration(src: &str) -> Result<u32> {
462 let mut min = 0u32;
463 let mut buf = String::new();
464 for c in src.chars() {
465 if c == 'd' || c == 'h' || c == 'm' {
466 let n: u32 = buf.parse()?;
467 let n = match c {
468 'd' => n.saturating_mul(24 * 60),
469 'h' => n.saturating_mul(60),
470 'm' => n,
471 _ => panic!(),
472 };
473 min = min.saturating_add(n);
474 buf.clear();
475 } else {
476 buf.push(c);
477 }
478 }
479 Ok(min)
480}
481
482async fn delete_msgs_in_channel<F: Fn(&Message, usize, usize) -> bool>(
495 ctx: &Context,
496 ch: ChannelId,
497 filter: F,
498) -> Result<(usize, usize)> {
499 let mut allmsgs = BTreeMap::<MessageId, Message>::new();
501 const GET_MSG_LIMIT: u8 = 100;
502 let mut after = None;
503 loop {
504 info!("get_messages: after={after:?}");
506 let target = after.map(MessagePagination::After);
507 let msgs = ctx
508 .http
509 .get_messages(ch, target, Some(GET_MSG_LIMIT))
510 .await?;
511 if msgs.is_empty() {
513 break;
514 }
515 allmsgs.extend(msgs.iter().map(|m| (m.id, m.clone())));
517 after = Some(*allmsgs.keys().next_back().unwrap());
519 }
520 info!("obtained {} messages", allmsgs.len());
521
522 let mut delcount = 0_usize;
523 for (i, (&mid, msg)) in allmsgs.iter().enumerate() {
524 if !filter(msg, i, allmsgs.len()) {
525 continue;
526 }
527 info!("Delete: ch={ch}, msg={mid}");
529 ctx.http.delete_message(ch, mid, None).await?;
531 delcount += 1;
532 }
533 info!("deleted {delcount} messages");
534
535 Ok((delcount, allmsgs.len()))
536}
537
538async fn periodic_main(ctrl: Control) -> Result<()> {
540 let (ctx, config_map) = {
541 let discord = ctrl.sysmods().discord.lock().await;
542 if discord.ctx.is_none() {
543 return Ok(());
545 }
546 (
547 discord.ctx.as_ref().unwrap().clone(),
548 discord.auto_del_config.clone(),
549 )
550 };
551
552 let now = Utc::now().timestamp() as u64;
554
555 for (ch, config) in config_map {
556 let AutoDeleteConfig {
557 keep_count,
558 keep_dur_min,
559 } = config;
560 if keep_count == 0 && keep_dur_min == 0 {
561 continue;
562 }
563 let keep_dur_sec = (keep_dur_min as u64).saturating_mul(60);
564 let (_delcount, _total) = delete_msgs_in_channel(&ctx, ch, |msg, i, len| {
565 let mut keep = true;
566 if keep_count != 0 {
567 keep = keep && i + (keep_count as usize) < len;
568 }
569 if keep_dur_min != 0 {
570 let created = msg.timestamp.timestamp() as u64;
571 let duration = now.saturating_sub(created);
574 keep = keep && duration <= keep_dur_sec;
575 }
576 !keep
577 })
578 .await?;
579 }
580
581 Ok(())
582}
583
584fn command_list() -> Vec<poise::Command<PoiseData, PoiseError>> {
590 vec![
591 help(),
592 sysinfo(),
593 autodel(),
594 coin(),
595 dice(),
596 attack(),
597 camera(),
598 ai(),
599 aistatus(),
600 aiimg(),
601 aispeech(),
602 ]
603}
604
605#[poise::command(slash_command, prefix_command, category = "General")]
608pub async fn help(
609 ctx: PoiseContext<'_>,
610 #[description = "Command name"] command: Option<String>,
611) -> Result<(), PoiseError> {
612 let extra_text = "
613New slash command style
614 /command params...
615Compatible style (you can use \"double quote\" to use spaces in a parameter)
616 @bot_name command params...
617
618Parameter help will be displayed if you start to type slash command.
619If you use old style,
620 @bot_name help command_name
621to show detailed command help.
622";
623 let config = poise::builtins::HelpConfiguration {
624 ephemeral: false,
626 show_subcommands: true,
627 extra_text_at_bottom: extra_text,
628 ..Default::default()
629 };
630 poise::builtins::help(ctx, command.as_deref(), config).await?;
631
632 Ok(())
633}
634
635#[poise::command(slash_command, prefix_command, category = "General")]
637async fn sysinfo(ctx: PoiseContext<'_>) -> Result<(), PoiseError> {
638 let ver_info: &str = verinfo::version_info();
639 ctx.reply(ver_info).await?;
640
641 Ok(())
642}
643
644const AUTODEL_INVALID_CH_MSG: &str = "Auto delete feature is not enabled for this channel.
645Please contact my owner.";
646
647#[poise::command(
648 slash_command,
649 prefix_command,
650 category = "Auto Delete",
651 subcommands("autodel_status", "autodel_set")
652)]
653async fn autodel(_ctx: PoiseContext<'_>) -> Result<(), PoiseError> {
654 Ok(())
656}
657
658#[poise::command(
660 slash_command,
661 prefix_command,
662 category = "Auto Delete",
663 rename = "status"
664)]
665async fn autodel_status(ctx: PoiseContext<'_>) -> Result<(), PoiseError> {
666 let ch = ctx.channel_id();
667 let config = {
668 let data = ctx.data();
669 let discord = data.ctrl.sysmods().discord.lock().await;
670
671 discord.auto_del_config.get(&ch).copied()
672 };
673
674 if let Some(config) = config {
675 ctx.reply(format!("{config}")).await?;
676 } else {
677 ctx.reply(AUTODEL_INVALID_CH_MSG).await?;
678 }
679
680 Ok(())
681}
682
683#[poise::command(
687 slash_command,
688 prefix_command,
689 category = "Auto Delete",
690 rename = "set"
691)]
692async fn autodel_set(
693 ctx: PoiseContext<'_>,
694 #[description = "Delete old messages other than this count of newer ones (0: disable)"]
695 keep_count: u32,
696 #[description = "Delete messages after this time (e.g. 1d, 3h, 30m, 1d23h59m, etc.) (0: disable)"]
697 keep_duration: String,
698) -> Result<(), PoiseError> {
699 let ch = ctx.channel_id();
700 let keep_duration = parse_duration(&keep_duration);
701 if keep_duration.is_err() {
702 ctx.reply("keep_duration parse error.").await?;
703 return Ok(());
704 }
705 let keep_duration = keep_duration.unwrap();
706
707 let msg = {
708 let mut discord = ctx.data().ctrl.sysmods().discord.lock().await;
709
710 let config = discord.auto_del_config.get_mut(&ch);
711 match config {
712 Some(config) => {
713 config.keep_count = keep_count;
714 config.keep_dur_min = keep_duration;
715 format!("OK\n{config}")
716 }
717 None => AUTODEL_INVALID_CH_MSG.to_string(),
718 }
719 };
720 ctx.reply(msg).await?;
721
722 Ok(())
723}
724
725#[poise::command(slash_command, prefix_command, category = "Play Tools")]
727async fn coin(
728 ctx: PoiseContext<'_>,
729 #[description = "Dice count (default=1)"] count: Option<u32>,
730) -> Result<(), PoiseError> {
731 let count = count.unwrap_or(1);
732
733 let msg = match dice::roll(2, count) {
734 Ok(v) => {
735 let mut buf = format!("Flip {count} coin(s)\n");
736 buf.push('[');
737 let mut first = true;
738 for n in v {
739 if first {
740 first = false;
741 } else {
742 buf.push(',');
743 }
744 buf.push_str(if n == 1 { "\"H\"" } else { "\"T\"" });
745 }
746 buf.push(']');
747 buf
748 }
749 Err(err) => err.to_string(),
750 };
751 ctx.reply(msg).await?;
752
753 Ok(())
754}
755
756#[poise::command(slash_command, prefix_command, category = "Play Tools")]
758async fn dice(
759 ctx: PoiseContext<'_>,
760 #[description = "Face count (default=6)"] face: Option<u64>,
761 #[description = "Dice count (default=1)"] count: Option<u32>,
762) -> Result<(), PoiseError> {
763 let face = face.unwrap_or(6);
764 let count = count.unwrap_or(1);
765
766 let msg = match dice::roll(face, count) {
767 Ok(v) => {
768 let mut buf = format!("Roll {count} dice with {face} face(s)\n");
769 buf.push('[');
770 let mut first = true;
771 for n in v {
772 if first {
773 first = false;
774 } else {
775 buf.push(',');
776 }
777 buf.push_str(&n.to_string());
778 }
779 buf.push(']');
780 buf
781 }
782 Err(err) => err.to_string(),
783 };
784 ctx.reply(msg).await?;
785
786 Ok(())
787}
788
789#[poise::command(slash_command, prefix_command, category = "Manipulation", owners_only)]
793async fn attack(
794 ctx: PoiseContext<'_>,
795 #[description = "Target user"] target: Option<UserId>,
796 #[description = "Chat message to be said"]
797 #[min_length = 1]
798 #[max_length = 1024]
799 chat_msg: String,
800) -> Result<(), PoiseError> {
801 let text = if let Some(user) = target {
802 format!("{} {}", user.mention(), chat_msg)
803 } else {
804 chat_msg
805 };
806
807 info!("[discord] reply: {text}");
808 ctx.reply(text).await?;
809 Ok(())
810}
811
812#[poise::command(slash_command, prefix_command, category = "Manipulation", owners_only)]
814async fn camera(ctx: PoiseContext<'_>) -> Result<(), PoiseError> {
815 ctx.reply("Taking a picture...").await?;
816
817 let pic = camera::take_a_pic(TakePicOption::new()).await?;
818
819 let attach = CreateAttachment::bytes(pic, "camera.jpg");
820 ctx.send(
821 CreateReply::default()
822 .content("camera.jpg")
823 .attachment(attach),
824 )
825 .await?;
826
827 Ok(())
828}
829
830#[derive(Default, poise::ChoiceParameter)]
831enum WebSearchQuality {
832 #[name = "High Quality"]
833 High,
834 #[default]
835 #[name = "Medium Quality"]
836 Medium,
837 #[name = "Low Quality"]
838 Low,
839 #[name = "Disabled"]
840 Disabled,
841}
842
843#[poise::command(slash_command, prefix_command, category = "AI")]
847async fn ai(
848 ctx: PoiseContext<'_>,
849 #[description = "Chat message to AI assistant"]
850 #[min_length = 1]
851 #[max_length = 6000]
852 chat_msg: String,
853 #[description = "Image URL(s) separated by whitespace. You can copy the URL by right-clicking an image on Discord."]
854 image_url: Option<String>,
855 web_search_quality: Option<WebSearchQuality>,
856 #[description = "Show internal details when AI calls a function. (default=False)"]
857 trace_function_call: Option<bool>,
858) -> Result<(), PoiseError> {
859 let mut image_list = vec![];
861 if let Some(image_url) = image_url {
862 let mut urls = vec![];
864 for (idx, url) in image_url.split_whitespace().enumerate() {
865 if !url.chars().all(is_url_char) {
866 ctx.reply(format!("Invalid URL [{idx}]")).await?;
867 return Ok(());
868 }
869 urls.push(url);
870 }
871
872 const TIMEOUT: Duration = Duration::from_secs(30);
874 let client = reqwest::ClientBuilder::new().timeout(TIMEOUT).build()?;
875 let download = async move |url| {
876 let resp = client
877 .get(url)
878 .send()
879 .await
880 .with_context(|| "URL get network error".to_string())?;
881
882 netutil::check_http_resp_bin(resp)
883 .await
884 .with_context(|| "URL get network error".to_string())
885 };
886
887 for (idx, &url) in urls.iter().enumerate() {
888 match download(url).await {
890 Ok(bin) => {
891 let attach = CreateAttachment::bytes(bin.clone(), "ai_input.png");
892 ctx.send(
895 CreateReply::default()
896 .content(format!("Input image [{idx}]: <{url}>"))
897 .attachment(attach),
898 )
899 .await?;
900 image_list.push(OpenAi::to_image_input(&bin)?);
901 }
902 Err(err) => {
903 error!("{err:#?}");
904 ctx.reply(format!("Download failed [{idx}]: <{url}>"))
905 .await?;
906 return Ok(());
907 }
908 }
909 }
910 }
911
912 reply_long_mdquote(&ctx, &chat_msg).await?;
914
915 let data = ctx.data();
916 let mut discord = data.ctrl.sysmods().discord.lock().await;
917
918 discord.check_history_timeout();
920
921 let sysmsg = discord
923 .config
924 .prompt
925 .each
926 .join("")
927 .replace("${user}", &ctx.author().name);
928 discord
929 .chat_history_mut()
930 .push_input_message(Role::Developer, &sysmsg)?;
931 discord
932 .chat_history_mut()
933 .push_input_and_images(Role::User, &chat_msg, image_list)?;
934
935 let inst = discord.config.prompt.instructions.join("");
937 let mut tools = vec![];
939 let web_csize = match web_search_quality.unwrap_or_default() {
941 WebSearchQuality::High => Some(SearchContextSize::High),
942 WebSearchQuality::Medium => Some(SearchContextSize::Medium),
943 WebSearchQuality::Low => Some(SearchContextSize::Low),
944 WebSearchQuality::Disabled => None,
945 };
946 if let Some(web_csize) = web_csize {
947 tools.push(Tool::WebSearchPreview {
948 search_context_size: Some(web_csize),
949 user_location: Some(UserLocation::default()),
950 });
951 }
952 for f in discord.func_table().function_list() {
954 tools.push(Tool::Function(f.clone()));
955 }
956
957 let result = loop {
959 let input = Vec::from_iter(discord.chat_history().iter().cloned());
961 let resp = {
963 let mut ai = data.ctrl.sysmods().openai.lock().await;
964 ai.chat_with_tools(Some(&inst), input, &tools).await
965 };
966 match resp {
967 Ok(resp) => {
968 for fc in resp.func_call_iter() {
970 let call_id = &fc.call_id;
971 let func_name = &fc.name;
972 let func_args = &fc.arguments;
973
974 let func_out = discord.func_table().call((), func_name, func_args).await;
976 if discord.func_table.as_ref().unwrap().debug_mode()
978 || trace_function_call.unwrap_or(false)
979 {
980 reply_long(
981 &ctx,
982 &format!(
983 "function call: {func_name}\nparameters: {func_args}\nresult: {func_out}"
984 ),
985 )
986 .await?;
987 }
988 discord
990 .chat_history_mut()
991 .push_function(call_id, func_name, func_args, &func_out)?;
992 }
993 let text = resp.output_text();
995 let text = if text.is_empty() { None } else { Some(text) };
996 discord
997 .chat_history_mut()
998 .push_output_and_tools(text.as_deref(), resp.web_search_iter().cloned())?;
999
1000 if let Some(text) = text {
1001 break Ok(text);
1002 }
1003 }
1004 Err(err) => {
1005 error!("{err:#?}");
1007 break Err(err);
1008 }
1009 }
1010 };
1011
1012 match result {
1014 Ok(reply_msg) => {
1015 info!("[discord] openai reply: {reply_msg}");
1016 reply_long(&ctx, &remove_empty_lines(&reply_msg)).await?;
1017
1018 discord.chat_timeout = Some(
1020 Instant::now()
1021 + Duration::from_secs(discord.config.prompt.history_timeout_min as u64 * 60),
1022 );
1023 }
1024 Err(err) => {
1025 error!("[discord] openai error: {err:#?}");
1026 let errmsg = match OpenAi::error_kind(&err) {
1027 OpenAiErrorKind::Timeout => "Server timed out.".to_string(),
1028 OpenAiErrorKind::RateLimit => {
1029 "Rate limit exceeded. Please retry after a while.".to_string()
1030 }
1031 OpenAiErrorKind::QuotaExceeded => "Quota exceeded. Charge the credit.".to_string(),
1032 OpenAiErrorKind::HttpError(status) => format!("Error {status}"),
1033 _ => "Error".to_string(),
1034 };
1035
1036 warn!("[discord] openai reply: {errmsg}");
1037 ctx.reply(errmsg).await?;
1038 }
1039 }
1040
1041 Ok(())
1042}
1043
1044fn is_url_char(c: char) -> bool {
1045 matches!(c, 'a'..='z' | 'A'..='Z' | '0'..='9' | ':' | '/' | '.' | '-' | '_' | '~' | '?' | '&' | '=' | '%' | '+')
1046}
1047
1048#[poise::command(
1049 slash_command,
1050 prefix_command,
1051 category = "AI",
1052 subcommands("aistatus_show", "aistatus_reset", "aistatus_funclist")
1053)]
1054async fn aistatus(_ctx: PoiseContext<'_>) -> Result<(), PoiseError> {
1055 Ok(())
1057}
1058
1059#[poise::command(slash_command, prefix_command, category = "AI", rename = "show")]
1061async fn aistatus_show(ctx: PoiseContext<'_>) -> Result<(), PoiseError> {
1062 let rate_limit = {
1063 let ctrl = &ctx.data().ctrl;
1064 let ai = ctrl.sysmods().openai.lock().await;
1065
1066 ai.get_expected_rate_limit().map_or_else(
1067 || "No rate limit data".to_string(),
1068 |exp| {
1069 format!(
1070 "Remaining\nRequests: {} / {}\nTokens: {} / {}",
1071 exp.remaining_requests,
1072 exp.limit_requests,
1073 exp.remaining_tokens,
1074 exp.limit_tokens,
1075 )
1076 },
1077 )
1078 };
1079 let chat_history = {
1080 let ctrl = &ctx.data().ctrl;
1081 let mut discord = ctrl.sysmods().discord.lock().await;
1082
1083 discord.check_history_timeout();
1084 format!(
1085 "History: {}\nToken: {} / {}, Timeout: {} min",
1086 discord.chat_history().len(),
1087 discord.chat_history().usage().0,
1088 discord.chat_history().usage().1,
1089 discord.config.prompt.history_timeout_min
1090 )
1091 };
1092
1093 ctx.reply(format!("{rate_limit}\n\n{chat_history}")).await?;
1094
1095 Ok(())
1096}
1097
1098#[poise::command(slash_command, prefix_command, category = "AI", rename = "reset")]
1100async fn aistatus_reset(ctx: PoiseContext<'_>) -> Result<(), PoiseError> {
1101 {
1102 let ctrl = &ctx.data().ctrl;
1103 let mut discord = ctrl.sysmods().discord.lock().await;
1104
1105 discord.chat_history_mut().clear();
1106 }
1107 ctx.reply("OK").await?;
1108
1109 Ok(())
1110}
1111
1112#[poise::command(slash_command, prefix_command, category = "AI", rename = "funclist")]
1115async fn aistatus_funclist(ctx: PoiseContext<'_>) -> Result<(), PoiseError> {
1116 let help = {
1117 let discord = ctx.data().ctrl.sysmods().discord.lock().await;
1118
1119 discord.func_table().create_help()
1120 };
1121 let text = format!("```\n{help}\n```");
1122 ctx.reply(text).await?;
1123
1124 Ok(())
1125}
1126
1127#[poise::command(slash_command, prefix_command, category = "AI")]
1129async fn aiimg(
1130 ctx: PoiseContext<'_>,
1131 #[description = "Prompt string"]
1132 #[min_length = 1]
1133 #[max_length = 1024]
1134 prompt: String,
1135) -> Result<(), PoiseError> {
1136 reply_long_mdquote(&ctx, &prompt).await?;
1138
1139 let img_url = {
1140 let ctrl = &ctx.data().ctrl;
1141 let mut ai = ctrl.sysmods().openai.lock().await;
1142
1143 let mut resp = ai.generate_image(&prompt, 1).await?;
1144 resp.pop().ok_or_else(|| anyhow!("image array too short"))?
1145 };
1146
1147 ctx.reply(img_url).await?;
1148
1149 Ok(())
1150}
1151
1152#[derive(poise::ChoiceParameter)]
1153enum SpeechModelChoice {
1154 #[name = "speed"]
1155 Tts1,
1156 #[name = "quality"]
1157 Tts1Hd,
1158}
1159
1160#[derive(poise::ChoiceParameter)]
1161enum SpeechVoiceChoice {
1162 Alloy,
1163 Echo,
1164 Fable,
1165 Onyx,
1166 Nova,
1167 Shimmer,
1168}
1169
1170#[poise::command(slash_command, prefix_command, category = "AI")]
1172async fn aispeech(
1173 ctx: PoiseContext<'_>,
1174 #[description = "text to say"]
1175 #[min_length = 1]
1176 #[max_length = 4096]
1177 input: String,
1178 #[description = "voice (default to Nova)"] voice: Option<SpeechVoiceChoice>,
1179 #[description = "0.25 <= speed <= 4.00 (default to 1.0)"]
1180 #[min = 0.25]
1181 #[max = 4.0]
1182 speed: Option<f32>,
1183 #[description = "model (default to speed)"] model: Option<SpeechModelChoice>,
1184) -> Result<(), PoiseError> {
1185 let model = model.map_or(openai::SpeechModel::Tts1, |model| match model {
1186 SpeechModelChoice::Tts1 => openai::SpeechModel::Tts1,
1187 SpeechModelChoice::Tts1Hd => openai::SpeechModel::Tts1Hd,
1188 });
1189 let voice = voice.map_or(openai::SpeechVoice::Nova, |voice| match voice {
1190 SpeechVoiceChoice::Alloy => openai::SpeechVoice::Alloy,
1191 SpeechVoiceChoice::Echo => openai::SpeechVoice::Echo,
1192 SpeechVoiceChoice::Fable => openai::SpeechVoice::Fable,
1193 SpeechVoiceChoice::Onyx => openai::SpeechVoice::Onyx,
1194 SpeechVoiceChoice::Nova => openai::SpeechVoice::Nova,
1195 SpeechVoiceChoice::Shimmer => openai::SpeechVoice::Shimmer,
1196 });
1197
1198 reply_long_mdquote(&ctx, &input).await?;
1200
1201 let audio_bin = {
1202 let ctrl = &ctx.data().ctrl;
1203 let mut ai = ctrl.sysmods().openai.lock().await;
1204
1205 ai.text_to_speech(model, &input, voice, Some(openai::SpeechFormat::Mp3), speed)
1206 .await?
1207 };
1208
1209 let attach = CreateAttachment::bytes(audio_bin, "speech.mp3");
1210 ctx.send(CreateReply::default().attachment(attach)).await?;
1211
1212 Ok(())
1213}
1214
1215impl SystemModule for Discord {
1216 fn on_start(&mut self, ctrl: &Control) {
1220 info!("[discord] on_start");
1221 if self.config.enabled {
1222 taskserver::spawn_oneshot_task(ctrl, "discord", discord_main);
1223 }
1224 }
1225}
1226
1227async fn pre_command(ctx: PoiseContext<'_>) {
1229 info!(
1230 "[discord] command {} from {:?} {:?}",
1231 ctx.command().name,
1232 ctx.author().name,
1233 ctx.author().global_name.as_deref().unwrap_or("?")
1234 );
1235 info!("[discord] {:?}", ctx.invocation_string());
1236}
1237
1238async fn post_command(ctx: PoiseContext<'_>) {
1239 info!(
1240 "[discord] command {} from {:?} {:?} OK",
1241 ctx.command().name,
1242 ctx.author().name,
1243 ctx.author().global_name.as_deref().unwrap_or("?")
1244 );
1245}
1246
1247async fn on_error(error: poise::FrameworkError<'_, PoiseData, PoiseError>) {
1251 match error {
1253 poise::FrameworkError::Setup { error, .. } => {
1254 panic!("Failed on setup: {error:#?}")
1255 }
1256 poise::FrameworkError::EventHandler { error, .. } => {
1257 panic!("Failed on eventhandler: {error:#?}")
1258 }
1259 poise::FrameworkError::Command { error, ctx, .. } => {
1260 error!(
1261 "[discord] error in command `{}`: {:#?}",
1262 ctx.command().name,
1263 error
1264 );
1265 }
1266 poise::FrameworkError::NotAnOwner { ctx, .. } => {
1267 let errmsg = ctx
1268 .data()
1269 .ctrl
1270 .sysmods()
1271 .discord
1272 .lock()
1273 .await
1274 .config
1275 .perm_err_msg
1276 .clone();
1277 info!("[discord] not an owner: {}", ctx.author());
1278 info!("[discord] reply: {errmsg}");
1279 if let Err(why) = ctx.reply(errmsg).await {
1280 error!("[discord] reply error: {why:#?}")
1281 }
1282 }
1283 poise::FrameworkError::UnknownInteraction { interaction, .. } => {
1284 warn!(
1285 "[discord] received unknown interaction \"{}\"",
1286 interaction.data.name
1287 );
1288 }
1289 error => {
1290 if let Err(why) = poise::builtins::on_error(error).await {
1291 error!("[discord] error while handling error: {why:#?}")
1292 }
1293 }
1294 }
1295}
1296
1297async fn event_handler(
1302 ctx: &Context,
1303 ev: &FullEvent,
1304 _fctx: FrameworkContext<'_, PoiseData, PoiseError>,
1305 data: &PoiseData,
1306) -> Result<(), PoiseError> {
1307 match ev {
1308 FullEvent::Ready { data_about_bot } => {
1309 info!("[discord] connected as {}", data_about_bot.user.name);
1310 Ok(())
1311 }
1312 FullEvent::Resume { event: _ } => {
1313 info!("[discord] resumed");
1314 Ok(())
1315 }
1316 FullEvent::CacheReady { guilds } => {
1317 info!("[discord] cache ready - guild: {}", guilds.len());
1319
1320 let mut discord = data.ctrl.sysmods().discord.lock().await;
1321 let ctx_clone = ctx.clone();
1322 discord.ctx = Some(ctx_clone);
1323
1324 info!(
1325 "[discord] send postponed msgs ({})",
1326 discord.postponed_msgs.len()
1327 );
1328 for msg in &discord.postponed_msgs {
1329 let ch = discord.config.notif_channel;
1330 assert_ne!(0, ch);
1332
1333 info!("[discord] say msg: {msg}");
1334 let ch = ChannelId::new(ch);
1335 if let Err(why) = ch.say(&ctx, msg).await {
1336 error!("{why:#?}");
1337 }
1338 }
1339 discord.postponed_msgs.clear();
1340 Ok(())
1341 }
1342 _ => Ok(()),
1343 }
1344}
1345
1346#[cfg(test)]
1347mod tests {
1348 use super::*;
1349
1350 #[test]
1351 fn parse_default_toml() {
1352 let obj: DiscordPrompt = Default::default();
1354 assert_ne!(obj.instructions.len(), 0);
1355 assert_ne!(obj.each.len(), 0);
1356 }
1357
1358 #[test]
1359 fn test_remove_empty_lines() {
1360 let src = "\n\nabc\n\n\ndef\n\n";
1361 let result = remove_empty_lines(src);
1362 assert_eq!(result, "abc\ndef");
1363 }
1364
1365 #[test]
1366 fn convert_auto_del_time() {
1367 let (d, h, m) = convert_duration(0);
1368 assert_eq!(d, 0);
1369 assert_eq!(h, 0);
1370 assert_eq!(m, 0);
1371
1372 let (d, h, m) = convert_duration(3 * 24 * 60 + 23 * 60 + 59);
1373 assert_eq!(d, 3);
1374 assert_eq!(h, 23);
1375 assert_eq!(m, 59);
1376 }
1377}