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 = netutil::send_with_retry(|| client.get(url))
877 .await
878 .with_context(|| "URL get network error".to_string())?;
879
880 netutil::check_http_resp_bin(resp)
881 .await
882 .with_context(|| "URL get network error".to_string())
883 };
884
885 for (idx, &url) in urls.iter().enumerate() {
886 match download(url).await {
888 Ok(bin) => {
889 let attach = CreateAttachment::bytes(bin.clone(), "ai_input.png");
890 ctx.send(
893 CreateReply::default()
894 .content(format!("Input image [{idx}]: <{url}>"))
895 .attachment(attach),
896 )
897 .await?;
898 image_list.push(OpenAi::to_image_input(&bin)?);
899 }
900 Err(err) => {
901 error!("{err:#?}");
902 ctx.reply(format!("Download failed [{idx}]: <{url}>"))
903 .await?;
904 return Ok(());
905 }
906 }
907 }
908 }
909
910 reply_long_mdquote(&ctx, &chat_msg).await?;
912
913 let data = ctx.data();
914 let mut discord = data.ctrl.sysmods().discord.lock().await;
915
916 discord.check_history_timeout();
918
919 let sysmsg = discord
921 .config
922 .prompt
923 .each
924 .join("")
925 .replace("${user}", &ctx.author().name);
926 discord
927 .chat_history_mut()
928 .push_input_message(Role::Developer, &sysmsg)?;
929 discord
930 .chat_history_mut()
931 .push_input_and_images(Role::User, &chat_msg, image_list)?;
932
933 let inst = discord.config.prompt.instructions.join("");
935 let mut tools = vec![];
937 let web_csize = match web_search_quality.unwrap_or_default() {
939 WebSearchQuality::High => Some(SearchContextSize::High),
940 WebSearchQuality::Medium => Some(SearchContextSize::Medium),
941 WebSearchQuality::Low => Some(SearchContextSize::Low),
942 WebSearchQuality::Disabled => None,
943 };
944 if let Some(web_csize) = web_csize {
945 tools.push(Tool::WebSearchPreview {
946 search_context_size: Some(web_csize),
947 user_location: Some(UserLocation::default()),
948 });
949 }
950 for f in discord.func_table().function_list() {
952 tools.push(Tool::Function(f.clone()));
953 }
954
955 let result = loop {
957 let input = Vec::from_iter(discord.chat_history().iter().cloned());
959 let resp = {
961 let mut ai = data.ctrl.sysmods().openai.lock().await;
962 ai.chat_with_tools(Some(&inst), input, &tools).await
963 };
964 match resp {
965 Ok(resp) => {
966 for fc in resp.func_call_iter() {
968 let call_id = &fc.call_id;
969 let func_name = &fc.name;
970 let func_args = &fc.arguments;
971
972 let func_out = discord.func_table().call((), func_name, func_args).await;
974 if discord.func_table.as_ref().unwrap().debug_mode()
976 || trace_function_call.unwrap_or(false)
977 {
978 reply_long(
979 &ctx,
980 &format!(
981 "function call: {func_name}\nparameters: {func_args}\nresult: {func_out}"
982 ),
983 )
984 .await?;
985 }
986 discord
988 .chat_history_mut()
989 .push_function(call_id, func_name, func_args, &func_out)?;
990 }
991 let text = resp.output_text();
993 let text = if text.is_empty() { None } else { Some(text) };
994 discord
995 .chat_history_mut()
996 .push_output_and_tools(text.as_deref(), resp.web_search_iter().cloned())?;
997
998 if let Some(text) = text {
999 break Ok(text);
1000 }
1001 }
1002 Err(err) => {
1003 error!("{err:#?}");
1005 break Err(err);
1006 }
1007 }
1008 };
1009
1010 match result {
1012 Ok(reply_msg) => {
1013 info!("[discord] openai reply: {reply_msg}");
1014 reply_long(&ctx, &remove_empty_lines(&reply_msg)).await?;
1015
1016 discord.chat_timeout = Some(
1018 Instant::now()
1019 + Duration::from_secs(discord.config.prompt.history_timeout_min as u64 * 60),
1020 );
1021 }
1022 Err(err) => {
1023 error!("[discord] openai error: {err:#?}");
1024 let errmsg = match OpenAi::error_kind(&err) {
1025 OpenAiErrorKind::Timeout => "Server timed out.".to_string(),
1026 OpenAiErrorKind::RateLimit => {
1027 "Rate limit exceeded. Please retry after a while.".to_string()
1028 }
1029 OpenAiErrorKind::QuotaExceeded => "Quota exceeded. Charge the credit.".to_string(),
1030 OpenAiErrorKind::HttpError(status) => format!("Error {status}"),
1031 _ => "Error".to_string(),
1032 };
1033
1034 warn!("[discord] openai reply: {errmsg}");
1035 ctx.reply(errmsg).await?;
1036 }
1037 }
1038
1039 Ok(())
1040}
1041
1042fn is_url_char(c: char) -> bool {
1043 matches!(c, 'a'..='z' | 'A'..='Z' | '0'..='9' | ':' | '/' | '.' | '-' | '_' | '~' | '?' | '&' | '=' | '%' | '+')
1044}
1045
1046#[poise::command(
1047 slash_command,
1048 prefix_command,
1049 category = "AI",
1050 subcommands("aistatus_show", "aistatus_reset", "aistatus_funclist")
1051)]
1052async fn aistatus(_ctx: PoiseContext<'_>) -> Result<(), PoiseError> {
1053 Ok(())
1055}
1056
1057#[poise::command(slash_command, prefix_command, category = "AI", rename = "show")]
1059async fn aistatus_show(ctx: PoiseContext<'_>) -> Result<(), PoiseError> {
1060 let rate_limit = {
1061 let ctrl = &ctx.data().ctrl;
1062 let ai = ctrl.sysmods().openai.lock().await;
1063
1064 ai.get_expected_rate_limit().map_or_else(
1065 || "No rate limit data".to_string(),
1066 |exp| {
1067 format!(
1068 "Remaining\nRequests: {} / {}\nTokens: {} / {}",
1069 exp.remaining_requests,
1070 exp.limit_requests,
1071 exp.remaining_tokens,
1072 exp.limit_tokens,
1073 )
1074 },
1075 )
1076 };
1077 let chat_history = {
1078 let ctrl = &ctx.data().ctrl;
1079 let mut discord = ctrl.sysmods().discord.lock().await;
1080
1081 discord.check_history_timeout();
1082 format!(
1083 "History: {}\nToken: {} / {}, Timeout: {} min",
1084 discord.chat_history().len(),
1085 discord.chat_history().usage().0,
1086 discord.chat_history().usage().1,
1087 discord.config.prompt.history_timeout_min
1088 )
1089 };
1090
1091 ctx.reply(format!("{rate_limit}\n\n{chat_history}")).await?;
1092
1093 Ok(())
1094}
1095
1096#[poise::command(slash_command, prefix_command, category = "AI", rename = "reset")]
1098async fn aistatus_reset(ctx: PoiseContext<'_>) -> Result<(), PoiseError> {
1099 {
1100 let ctrl = &ctx.data().ctrl;
1101 let mut discord = ctrl.sysmods().discord.lock().await;
1102
1103 discord.chat_history_mut().clear();
1104 }
1105 ctx.reply("OK").await?;
1106
1107 Ok(())
1108}
1109
1110#[poise::command(slash_command, prefix_command, category = "AI", rename = "funclist")]
1113async fn aistatus_funclist(ctx: PoiseContext<'_>) -> Result<(), PoiseError> {
1114 let help = {
1115 let discord = ctx.data().ctrl.sysmods().discord.lock().await;
1116
1117 discord.func_table().create_help()
1118 };
1119 let text = format!("```\n{help}\n```");
1120 ctx.reply(text).await?;
1121
1122 Ok(())
1123}
1124
1125#[poise::command(slash_command, prefix_command, category = "AI")]
1127async fn aiimg(
1128 ctx: PoiseContext<'_>,
1129 #[description = "Prompt string"]
1130 #[min_length = 1]
1131 #[max_length = 1024]
1132 prompt: String,
1133) -> Result<(), PoiseError> {
1134 reply_long_mdquote(&ctx, &prompt).await?;
1136
1137 let img_url = {
1138 let ctrl = &ctx.data().ctrl;
1139 let mut ai = ctrl.sysmods().openai.lock().await;
1140
1141 let mut resp = ai.generate_image(&prompt, 1).await?;
1142 resp.pop().ok_or_else(|| anyhow!("image array too short"))?
1143 };
1144
1145 ctx.reply(img_url).await?;
1146
1147 Ok(())
1148}
1149
1150#[derive(poise::ChoiceParameter)]
1151enum SpeechModelChoice {
1152 #[name = "speed"]
1153 Tts1,
1154 #[name = "quality"]
1155 Tts1Hd,
1156}
1157
1158#[derive(poise::ChoiceParameter)]
1159enum SpeechVoiceChoice {
1160 Alloy,
1161 Echo,
1162 Fable,
1163 Onyx,
1164 Nova,
1165 Shimmer,
1166}
1167
1168#[poise::command(slash_command, prefix_command, category = "AI")]
1170async fn aispeech(
1171 ctx: PoiseContext<'_>,
1172 #[description = "text to say"]
1173 #[min_length = 1]
1174 #[max_length = 4096]
1175 input: String,
1176 #[description = "voice (default to Nova)"] voice: Option<SpeechVoiceChoice>,
1177 #[description = "0.25 <= speed <= 4.00 (default to 1.0)"]
1178 #[min = 0.25]
1179 #[max = 4.0]
1180 speed: Option<f32>,
1181 #[description = "model (default to speed)"] model: Option<SpeechModelChoice>,
1182) -> Result<(), PoiseError> {
1183 let model = model.map_or(openai::SpeechModel::Tts1, |model| match model {
1184 SpeechModelChoice::Tts1 => openai::SpeechModel::Tts1,
1185 SpeechModelChoice::Tts1Hd => openai::SpeechModel::Tts1Hd,
1186 });
1187 let voice = voice.map_or(openai::SpeechVoice::Nova, |voice| match voice {
1188 SpeechVoiceChoice::Alloy => openai::SpeechVoice::Alloy,
1189 SpeechVoiceChoice::Echo => openai::SpeechVoice::Echo,
1190 SpeechVoiceChoice::Fable => openai::SpeechVoice::Fable,
1191 SpeechVoiceChoice::Onyx => openai::SpeechVoice::Onyx,
1192 SpeechVoiceChoice::Nova => openai::SpeechVoice::Nova,
1193 SpeechVoiceChoice::Shimmer => openai::SpeechVoice::Shimmer,
1194 });
1195
1196 reply_long_mdquote(&ctx, &input).await?;
1198
1199 let audio_bin = {
1200 let ctrl = &ctx.data().ctrl;
1201 let mut ai = ctrl.sysmods().openai.lock().await;
1202
1203 ai.text_to_speech(model, &input, voice, Some(openai::SpeechFormat::Mp3), speed)
1204 .await?
1205 };
1206
1207 let attach = CreateAttachment::bytes(audio_bin, "speech.mp3");
1208 ctx.send(CreateReply::default().attachment(attach)).await?;
1209
1210 Ok(())
1211}
1212
1213impl SystemModule for Discord {
1214 fn on_start(&mut self, ctrl: &Control) {
1218 info!("[discord] on_start");
1219 if self.config.enabled {
1220 taskserver::spawn_oneshot_task(ctrl, "discord", discord_main);
1221 }
1222 }
1223}
1224
1225async fn pre_command(ctx: PoiseContext<'_>) {
1227 info!(
1228 "[discord] command {} from {:?} {:?}",
1229 ctx.command().name,
1230 ctx.author().name,
1231 ctx.author().global_name.as_deref().unwrap_or("?")
1232 );
1233 info!("[discord] {:?}", ctx.invocation_string());
1234}
1235
1236async fn post_command(ctx: PoiseContext<'_>) {
1237 info!(
1238 "[discord] command {} from {:?} {:?} OK",
1239 ctx.command().name,
1240 ctx.author().name,
1241 ctx.author().global_name.as_deref().unwrap_or("?")
1242 );
1243}
1244
1245async fn on_error(error: poise::FrameworkError<'_, PoiseData, PoiseError>) {
1249 match error {
1251 poise::FrameworkError::Setup { error, .. } => {
1252 panic!("Failed on setup: {error:#?}")
1253 }
1254 poise::FrameworkError::EventHandler { error, .. } => {
1255 panic!("Failed on eventhandler: {error:#?}")
1256 }
1257 poise::FrameworkError::Command { error, ctx, .. } => {
1258 error!(
1259 "[discord] error in command `{}`: {:#?}",
1260 ctx.command().name,
1261 error
1262 );
1263 }
1264 poise::FrameworkError::NotAnOwner { ctx, .. } => {
1265 let errmsg = ctx
1266 .data()
1267 .ctrl
1268 .sysmods()
1269 .discord
1270 .lock()
1271 .await
1272 .config
1273 .perm_err_msg
1274 .clone();
1275 info!("[discord] not an owner: {}", ctx.author());
1276 info!("[discord] reply: {errmsg}");
1277 if let Err(why) = ctx.reply(errmsg).await {
1278 error!("[discord] reply error: {why:#?}")
1279 }
1280 }
1281 poise::FrameworkError::UnknownInteraction { interaction, .. } => {
1282 warn!(
1283 "[discord] received unknown interaction \"{}\"",
1284 interaction.data.name
1285 );
1286 }
1287 error => {
1288 if let Err(why) = poise::builtins::on_error(error).await {
1289 error!("[discord] error while handling error: {why:#?}")
1290 }
1291 }
1292 }
1293}
1294
1295async fn event_handler(
1300 ctx: &Context,
1301 ev: &FullEvent,
1302 _fctx: FrameworkContext<'_, PoiseData, PoiseError>,
1303 data: &PoiseData,
1304) -> Result<(), PoiseError> {
1305 match ev {
1306 FullEvent::Ready { data_about_bot } => {
1307 info!("[discord] connected as {}", data_about_bot.user.name);
1308 Ok(())
1309 }
1310 FullEvent::Resume { event: _ } => {
1311 info!("[discord] resumed");
1312 Ok(())
1313 }
1314 FullEvent::CacheReady { guilds } => {
1315 info!("[discord] cache ready - guild: {}", guilds.len());
1317
1318 let mut discord = data.ctrl.sysmods().discord.lock().await;
1319 let ctx_clone = ctx.clone();
1320 discord.ctx = Some(ctx_clone);
1321
1322 info!(
1323 "[discord] send postponed msgs ({})",
1324 discord.postponed_msgs.len()
1325 );
1326 for msg in &discord.postponed_msgs {
1327 let ch = discord.config.notif_channel;
1328 assert_ne!(0, ch);
1330
1331 info!("[discord] say msg: {msg}");
1332 let ch = ChannelId::new(ch);
1333 if let Err(why) = ch.say(&ctx, msg).await {
1334 error!("{why:#?}");
1335 }
1336 }
1337 discord.postponed_msgs.clear();
1338 Ok(())
1339 }
1340 _ => Ok(()),
1341 }
1342}
1343
1344#[cfg(test)]
1345mod tests {
1346 use super::*;
1347
1348 #[test]
1349 fn parse_default_toml() {
1350 let obj: DiscordPrompt = Default::default();
1352 assert_ne!(obj.instructions.len(), 0);
1353 assert_ne!(obj.each.len(), 0);
1354 }
1355
1356 #[test]
1357 fn test_remove_empty_lines() {
1358 let src = "\n\nabc\n\n\ndef\n\n";
1359 let result = remove_empty_lines(src);
1360 assert_eq!(result, "abc\ndef");
1361 }
1362
1363 #[test]
1364 fn convert_auto_del_time() {
1365 let (d, h, m) = convert_duration(0);
1366 assert_eq!(d, 0);
1367 assert_eq!(h, 0);
1368 assert_eq!(m, 0);
1369
1370 let (d, h, m) = convert_duration(3 * 24 * 60 + 23 * 60 + 59);
1371 assert_eq!(d, 3);
1372 assert_eq!(h, 23);
1373 assert_eq!(m, 59);
1374 }
1375}