sys/sysmod/
discord.rs

1//! Discord クライアント (bot) 機能。
2
3use 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
39/// メッセージの最大文字数。 (Unicode codepoint)
40const MSG_MAX_LEN: usize = 2000;
41
42/// Discord 設定データ。toml 設定に対応する。
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct DiscordConfig {
45    /// 機能を有効化するなら true。
46    enabled: bool,
47    /// アクセストークン。Developer Portal で入手できる。
48    token: String,
49    /// メッセージの発言先チャネル。
50    /// Discord の詳細設定で開発者モードを有効にして、チャネルを右クリックで
51    /// ID をコピーできる。
52    notif_channel: u64,
53    /// 自動削除機能の対象とするチャネル ID のリスト。
54    auto_del_chs: Vec<u64>,
55    /// オーナーのユーザ ID。
56    /// Discord bot から得られるものは使わない。
57    owner_ids: Vec<u64>,
58    /// パーミッションエラーメッセージ。
59    /// オーナーのみ使用可能なコマンドを実行しようとした。
60    perm_err_msg: String,
61    /// OpenAI プロンプト。
62    #[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    /// 最初に一度だけ与えられるシステムメッセージ。
83    pub instructions: Vec<String>,
84    /// 個々のメッセージの直前に一度ずつ与えらえるシステムメッセージ。
85    pub each: Vec<String>,
86    /// 会話履歴をクリアするまでの時間。
87    pub history_timeout_min: u32,
88}
89
90/// [DiscordPrompt] のデフォルト値。
91const 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
101/// Discord システムモジュール。
102///
103/// [Option] は遅延初期化。
104pub struct Discord {
105    /// 設定データ。
106    config: DiscordConfig,
107    /// 定期実行の時刻リスト。
108    wakeup_list: Vec<NaiveTime>,
109    /// 現在有効な Discord Client コンテキスト。
110    ///
111    /// 起動直後は None で、[event_handler] イベントの度に置き換わる。
112    ctx: Option<Context>,
113    /// [Self::ctx] が None の間に発言しようとしたメッセージのキュー。
114    ///
115    /// Some になるタイミングで全て送信する。
116    postponed_msgs: Vec<String>,
117
118    /// 自動削除機能の設定データ。
119    auto_del_config: BTreeMap<ChannelId, AutoDeleteConfig>,
120
121    /// ai コマンドの会話履歴。
122    chat_history: Option<ChatHistory>,
123    /// [Self::chat_history] の有効期限。
124    chat_timeout: Option<Instant>,
125    /// OpenAI function 機能テーブル
126    func_table: Option<FunctionTable<()>>,
127}
128
129/// 自動削除設定。チャネルごとに保持される。
130#[derive(Clone, Copy)]
131pub struct AutoDeleteConfig {
132    /// 残す数。0 は無効。
133    keep_count: u32,
134    /// 残す時間 (単位は分)。0 は無効。
135    keep_dur_min: u32,
136}
137
138impl Display for AutoDeleteConfig {
139    /// to_string 可能にする。
140    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    /// コンストラクタ。
159    ///
160    /// 設定データの読み込みのみ行い、実際の初期化は async が有効になる
161    /// [discord_main] で行う。
162    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        // トークン上限を算出
193        // Function 定義 + 前文 + (使用可能上限) + 出力
194        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    /*
239    fn func_table_mut(&mut self) -> &mut FunctionTable<()> {
240           self.func_table.as_mut().unwrap()
241       }
242    */
243
244    /// 発言を投稿する。
245    ///
246    /// 接続前の場合、接続後まで遅延する。
247    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    /// [Self::chat_history] にタイムアウトを適用する。
271    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
283/// システムを初期化し開始する。
284///
285/// [Discord::on_start] から spawn される。
286async 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    // owner_ids を HashSet に変換 (0 は panic するので禁止)
295    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        // owner は手動で設定する
307        .initialize_owners(false)
308        // その他オプション
309        .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 command
315            prefix_options: poise::PrefixFrameworkOptions {
316                prefix: None,
317                mention_as_prefix: true,
318                ..Default::default()
319            },
320            // owner は手動で設定する (builder の方から設定されるようだがデフォルトが true なので念のためこちらも)
321            initialize_owners: false,
322            owners,
323            // コマンドリスト
324            commands: command_list(),
325            ..Default::default()
326        })
327        // ハンドラ
328        .setup(|ctx, _ready, framework| {
329            // 最初の ready イベントで呼ばれる
330            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                // construct user data here (invoked when bot connects to Discord)
339                Ok(PoiseData {
340                    ctrl: Arc::clone(&ctrl_for_setup),
341                })
342            })
343        })
344        .build();
345
346    // クライアントの初期化
347    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    // システムシャットダウンに対応してクライアントにシャットダウン要求を送る
353    // 別タスクを立ち上げる
354    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        // shutdown_all が完了した後は ready は呼ばれないはずなので
362        // ここで ctx を処分する
363        // ctx.data に Control を持たせているので ctx がリークしたままだと
364        // 終了処理が完了しない
365        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    // 定期チェックタスクを立ち上げる
373    taskserver::spawn_periodic_task(&ctrl, "discord-periodic", &wakeup_list, periodic_main);
374
375    // システムスタート
376    client.start().await?;
377    info!("[discord] client exit");
378
379    Ok(())
380}
381
382/// 文字数制限に気を付けつつ分割して送信する。
383async fn reply_long(ctx: &PoiseContext<'_>, content: &str) -> Result<()> {
384    // mention 関連でのずれが少し怖いので余裕を持たせる
385    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
408/// Markdown エスケープしながら Markdown 引用する。
409/// 文字数制限に気を付けつつ分割して送信する。
410async fn reply_long_mdquote(ctx: &PoiseContext<'_>, content: &str) -> Result<()> {
411    // mention 関連でのずれが少し怖いので余裕を持たせる
412    // 引用符の分も含める
413    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
448/// 分を (日, 時間, 分) に変換する。
449fn 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
458/// 日時分からなる文字列を分に変換する。
459///
460/// 例: 1d2h3m
461fn 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
482/// チャネル内の全メッセージを取得し、フィルタ関数が true を返したものを
483/// すべて削除する。
484///
485/// Bulk delete 機能で一気に複数を消せるが、2週間以上前のメッセージが
486/// 含まれていると BAD REQUEST になる等扱いが難しいので rate limit は
487/// 気になるが1つずつ消す。
488///
489/// * `ctx` - HTTP コンテキスト。
490/// * `ch` - Channel ID。
491/// * `filter` - (メッセージ, 番号, 総数) から消すならば true を返す関数。
492///
493/// (消した数, 総メッセージ数) を返す。
494async 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    // id=0 から 100 件ずつすべてのメッセージを取得する
500    let mut allmsgs = BTreeMap::<MessageId, Message>::new();
501    const GET_MSG_LIMIT: u8 = 100;
502    let mut after = None;
503    loop {
504        // https://discord.com/developers/docs/resources/channel#get-channel-messages
505        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        // 空配列ならば完了
512        if msgs.is_empty() {
513            break;
514        }
515        // 降順で送られてくるので ID でソートし直す
516        allmsgs.extend(msgs.iter().map(|m| (m.id, m.clone())));
517        // 最後の message id を次回の after に設定する
518        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        // ch, msg ID はログに残す
528        info!("Delete: ch={ch}, msg={mid}");
529        // https://discord.com/developers/docs/resources/channel#delete-message
530        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
538/// 自動削除周期タスク。
539async 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            // ready 前なら何もせず正常終了する
544            return Ok(());
545        }
546        (
547            discord.ctx.as_ref().unwrap().clone(),
548            discord.auto_del_config.clone(),
549        )
550    };
551
552    // UNIX timestamp [sec]
553    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                // u64 [sec] 同士の減算で経過時間を計算する
572                // オーバーフローは代わりに 0 とする
573                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
584//------------------------------------------------------------------------------
585// command
586// https://docs.rs/poise/latest/poise/macros/attr.command.html
587//------------------------------------------------------------------------------
588
589fn 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/// `help <command>` shows detailed command help.
606/// `help` shows all available commands briefly.
607#[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        // その人だけに見える返信にするかどうか
625        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/// Show system information.
636#[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    // 親コマンドはスラッシュコマンドでは使用不可
655    Ok(())
656}
657
658/// Get the auto-delete status in this channel.
659#[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/// Enable/Disable/Config auto-delete feature in this channel.
684///
685/// "0 0" disables the feature.
686#[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/// Flip coin(s).
726#[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/// Roll dice.
757#[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/// Order the assistant to say something.
790///
791/// You can specify target user(s).
792#[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/// Take a picture.
813#[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/// AI assistant.
844///
845/// The owner of the assistant will pay the usage fee for ChatGPT.
846#[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    // 画像 URL の解決
860    let mut image_list = vec![];
861    if let Some(image_url) = image_url {
862        // URL verify
863        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        // HTTP クライアントの準備
873        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            // URL から画像をダウンロード
889            match download(url).await {
890                Ok(bin) => {
891                    let attach = CreateAttachment::bytes(bin.clone(), "ai_input.png");
892                    // 画像を添付して返信
893                    // 本文は URL ("<url>" でプレビュー無効)
894                    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    // そのまま引用返信
913    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    // タイムアウト処理
919    discord.check_history_timeout();
920
921    // 今回の発言をヒストリに追加 (システムメッセージ + 本体)
922    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    // システムメッセージ
936    let inst = discord.config.prompt.instructions.join("");
937    // ツール (function + built-in tools)
938    let mut tools = vec![];
939    // web search
940    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    // function
953    for f in discord.func_table().function_list() {
954        tools.push(Tool::Function(f.clone()));
955    }
956
957    // AI 返答まで関数呼び出しを繰り返す
958    let result = loop {
959        // 入力をヒストリの内容から作成
960        let input = Vec::from_iter(discord.chat_history().iter().cloned());
961        // ChatGPT API
962        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                // function 呼び出しがあれば履歴に追加
969                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                    // call function
975                    let func_out = discord.func_table().call((), func_name, func_args).await;
976                    // debug trace
977                    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                    // function の結果を履歴に追加
989                    discord
990                        .chat_history_mut()
991                        .push_function(call_id, func_name, func_args, &func_out)?;
992                }
993                // アシスタント応答と web search があれば履歴に追加
994                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                // エラーが発生した
1006                error!("{err:#?}");
1007                break Err(err);
1008            }
1009        }
1010    };
1011
1012    // discord 返信
1013    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            // タイムアウト延長
1019            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    // 親コマンドはスラッシュコマンドでは使用不可
1056    Ok(())
1057}
1058
1059/// Show AI rate limit and chat history status.
1060#[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/// Clear AI chat history status.
1099#[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/// Show AI function list.
1113/// You can request the assistant to call these functions.
1114#[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/// AI image generation.
1128#[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    // そのまま引用返信
1137    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/// AI text to speech.
1171#[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    // そのまま引用返信
1199    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    /// async 使用可能になってからの初期化。
1217    ///
1218    /// 設定有効ならば [discord_main] を spawn する。
1219    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
1227/// Poise イベントハンドラ。
1228async 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
1247/// Poise イベントハンドラ。
1248///
1249/// [poise::builtins::on_error] のままでまずい部分を自分でやる。
1250async fn on_error(error: poise::FrameworkError<'_, PoiseData, PoiseError>) {
1251    // エラーを返していないはずのものは panic にする
1252    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
1297/// Serenity の全イベントハンドラ。
1298///
1299/// Poise のコンテキストが渡されるので、Serenity ではなく Poise の
1300/// FrameworkOptions 経由で設定する。
1301async 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            // このタイミングで [Discord::ctx] に ctx をクローンして保存する。
1318            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                // notif_channel が有効の場合しかキューされない
1331                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        // should not panic
1353        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}