sys/sysmod/
twitter.rs

1//! Twitter 機能。
2
3use super::SystemModule;
4use crate::sysmod::openai::InputContent;
5use crate::sysmod::openai::InputItem;
6use crate::sysmod::openai::Role;
7use crate::taskserver::Control;
8use crate::{config, taskserver};
9use utils::graphics::FontRenderer;
10use utils::netutil;
11
12use anyhow::Result;
13use base64::{Engine as _, engine::general_purpose};
14use chrono::NaiveTime;
15use log::warn;
16use log::{debug, info};
17use rand::RngExt;
18use reqwest::multipart;
19use serde::{Deserialize, Serialize};
20use std::collections::{BTreeMap, BTreeSet, HashMap};
21use std::fs;
22use std::time::{Duration, SystemTime, UNIX_EPOCH};
23
24const LONG_TWEET_FONT_SIZE: u32 = 16;
25const LONG_TWEET_IMAGE_WIDTH: u32 = 640;
26const LONG_TWEET_FGCOLOR: (u8, u8, u8) = (255, 255, 255);
27const LONG_TWEET_BGCOLOR: (u8, u8, u8) = (0, 0, 0);
28
29const TIMEOUT: Duration = Duration::from_secs(20);
30
31// Twitter API v2
32pub const TWEET_LEN_MAX: usize = 140;
33pub const LIMIT_PHOTO_COUNT: usize = 4;
34pub const LIMIT_PHOTO_SIZE: usize = 5_000_000;
35
36const URL_USERS_ME: &str = "https://api.twitter.com/2/users/me";
37const URL_USERS_BY: &str = "https://api.twitter.com/2/users/by";
38const LIMIT_USERS_BY: usize = 100;
39
40macro_rules! URL_USERS_TIMELINES_HOME {
41    () => {
42        "https://api.twitter.com/2/users/{}/timelines/reverse_chronological"
43    };
44}
45macro_rules! URL_USERS_TWEET {
46    () => {
47        "https://api.twitter.com/2/users/{}/tweets"
48    };
49}
50
51const URL_TWEETS: &str = "https://api.twitter.com/2/tweets";
52
53const URL_UPLOAD: &str = "https://upload.twitter.com/1.1/media/upload.json";
54
55#[derive(Clone, Debug, Serialize, Deserialize)]
56struct User {
57    id: String,
58    name: String,
59    username: String,
60}
61
62#[derive(Clone, Debug, Serialize, Deserialize)]
63struct UsersMe {
64    data: User,
65}
66
67#[derive(Clone, Debug, Serialize, Deserialize)]
68struct UsersBy {
69    data: Vec<User>,
70}
71
72#[derive(Clone, Debug, Serialize, Deserialize)]
73struct Mention {
74    start: u32,
75    end: u32,
76    username: String,
77}
78
79#[derive(Clone, Debug, Serialize, Deserialize)]
80struct HashTag {
81    start: u32,
82    end: u32,
83    tag: String,
84}
85
86#[derive(Default, Clone, Debug, Serialize, Deserialize)]
87struct Entities {
88    #[serde(default)]
89    mentions: Vec<Mention>,
90    #[serde(default)]
91    hashtags: Vec<HashTag>,
92}
93
94#[derive(Clone, Debug, Serialize, Deserialize)]
95struct Includes {
96    #[serde(default)]
97    users: Vec<User>,
98}
99
100#[derive(Clone, Debug, Serialize, Deserialize)]
101struct Tweet {
102    id: String,
103    text: String,
104    author_id: Option<String>,
105    edit_history_tweet_ids: Vec<String>,
106    /// tweet.fields=entities
107    #[serde(default)]
108    entities: Entities,
109}
110
111#[derive(Clone, Debug, Serialize, Deserialize)]
112struct Meta {
113    /// ドキュメントには count とあるが、レスポンスの例は result_count になっている。
114    result_count: u64,
115    /// [Self::result_count] = 0 だと存在しない
116    newest_id: Option<String>,
117    /// [Self::result_count] = 0 だと存在しない
118    oldest_id: Option<String>,
119}
120
121#[derive(Clone, Debug, Serialize, Deserialize)]
122struct Timeline {
123    data: Vec<Tweet>,
124    /// expansions=author_id
125    includes: Option<Includes>,
126    meta: Meta,
127}
128
129#[derive(Default, Clone, Debug, Serialize, Deserialize)]
130struct TweetParamReply {
131    in_reply_to_tweet_id: String,
132}
133
134#[derive(Default, Clone, Debug, Serialize, Deserialize)]
135struct TweetParamPoll {
136    duration_minutes: u32,
137    options: Vec<String>,
138}
139
140#[derive(Default, Clone, Debug, Serialize, Deserialize)]
141struct Media {
142    #[serde(skip_serializing_if = "Option::is_none")]
143    media_ids: Option<Vec<String>>,
144    #[serde(skip_serializing_if = "Option::is_none")]
145    tagged_user_ids: Option<Vec<String>>,
146}
147
148#[derive(Default, Clone, Debug, Serialize, Deserialize)]
149struct TweetParam {
150    #[serde(skip_serializing_if = "Option::is_none")]
151    poll: Option<TweetParamPoll>,
152    #[serde(skip_serializing_if = "Option::is_none")]
153    reply: Option<TweetParamReply>,
154    /// 本文。media.media_ids が無いなら必須。
155    #[serde(skip_serializing_if = "Option::is_none")]
156    text: Option<String>,
157    /// 添付メディアデータ。
158    #[serde(skip_serializing_if = "Option::is_none")]
159    media: Option<Media>,
160}
161
162#[derive(Clone, Debug, Serialize, Deserialize)]
163struct TweetResponse {
164    data: TweetResponseData,
165}
166
167#[derive(Clone, Debug, Serialize, Deserialize)]
168struct TweetResponseData {
169    id: String,
170    text: String,
171}
172
173#[derive(Clone, Debug, Serialize, Deserialize)]
174struct UploadResponseData {
175    media_id: u64,
176    size: u64,
177    expires_after_secs: u64,
178}
179
180/// Twitter 設定データ。toml 設定に対応する。
181#[derive(Debug, Clone, Serialize, Deserialize)]
182pub struct TwitterConfig {
183    /// タイムラインの定期確認を有効にする。
184    tlcheck_enabled: bool,
185    /// 起動時に1回だけタイムライン確認タスクを起動する。デバッグ用。
186    debug_exec_once: bool,
187    /// ツイートを実際にはせずにログにのみ出力する。
188    fake_tweet: bool,
189    /// Twitter API のアカウント情報。
190    consumer_key: String,
191    /// Twitter API のアカウント情報。
192    consumer_secret: String,
193    /// Twitter API のアカウント情報。
194    access_token: String,
195    /// Twitter API のアカウント情報。
196    access_secret: String,
197    /// OpenAI API 応答を起動するハッシュタグ。
198    ai_hashtag: String,
199    /// 長文ツイートの画像化に使う ttf ファイルへのパス。
200    /// 空文字列にすると機能を無効化する。
201    ///
202    /// Debian 環境の例\
203    /// `sudo apt install fonts-ipafont`\
204    /// /usr/share/fonts/truetype/fonts-japanese-gothic.ttf
205    font_file: String,
206    // タイムラインチェックルール。
207    #[serde(default)]
208    tlcheck: TimelineCheck,
209    /// OpenAI プロンプト。
210    #[serde(default)]
211    prompt: TwitterPrompt,
212}
213
214impl Default for TwitterConfig {
215    fn default() -> Self {
216        Self {
217            tlcheck_enabled: false,
218            debug_exec_once: false,
219            fake_tweet: true,
220            consumer_key: "".to_string(),
221            consumer_secret: "".to_string(),
222            access_token: "".to_string(),
223            access_secret: "".to_string(),
224            ai_hashtag: "DollsAI".to_string(),
225            font_file: "".to_string(),
226            tlcheck: Default::default(),
227            prompt: Default::default(),
228        }
229    }
230}
231
232/// Twitter 応答設定データの要素。
233#[derive(Debug, Default, Clone, Serialize, Deserialize)]
234pub struct TimelineCheckRule {
235    /// 対象とするユーザ名 (Screen Name) のリスト。
236    pub user_names: Vec<String>,
237    /// マッチパターンと応答のリスト。
238    ///
239    /// 前者は検索する文字列の配列。どれか1つにマッチしたら応答を行う。
240    /// _^_ で始まる場合、文頭 (行頭ではない) にマッチする。
241    /// _$_ で終わる場合、文末 (行末ではない) にマッチする。
242    ///
243    /// 後者は応答候補の文字列配列。
244    /// この中からランダムに1つが選ばれ応答する。
245    pub patterns: Vec<(Vec<String>, Vec<String>)>,
246}
247
248/// Twitter 応答設定データ。
249#[derive(Debug, Clone, Serialize, Deserialize)]
250pub struct TimelineCheck {
251    /// タイムラインチェックのルール。[TimelineCheckRule] のリスト。
252    pub rules: Vec<TimelineCheckRule>,
253}
254
255/// [TimelineCheck] のデフォルト値。
256const DEFAULT_TLCHECK_TOML: &str =
257    include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/res/tlcheck.toml"));
258impl Default for TimelineCheck {
259    fn default() -> Self {
260        toml::from_str(DEFAULT_TLCHECK_TOML).unwrap()
261    }
262}
263
264/// OpenAI プロンプト設定。
265#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct TwitterPrompt {
267    pub pre: Vec<String>,
268}
269
270/// [TwitterPrompt] のデフォルト値。
271const DEFAULT_PROMPT_TOML: &str = include_str!(concat!(
272    env!("CARGO_MANIFEST_DIR"),
273    "/res/openai_twitter.toml"
274));
275impl Default for TwitterPrompt {
276    fn default() -> Self {
277        toml::from_str(DEFAULT_PROMPT_TOML).unwrap()
278    }
279}
280
281pub struct Twitter {
282    config: TwitterConfig,
283
284    wakeup_list: Vec<NaiveTime>,
285
286    font: Option<FontRenderer>,
287
288    /// タイムラインチェックの際の走査開始 tweet id。
289    ///
290    /// 初期状態は None で、未取得状態を表す。
291    /// 最初の設定は、自身の最新ツイートを取得して設定する。
292    /// ツイートを行うと最新ツイートが変わってしまうため、
293    /// ツイート時、この値が None ならばツイート前に設定を行う。
294    ///
295    /// ツイート成功後、その ID で更新する。
296    tl_check_since_id: Option<String>,
297    /// 自身の User オブジェクト。最初の1回だけ取得を行う。
298    my_user_cache: Option<User>,
299    /// screen name -> User オブジェクトのマップ。
300    username_user_cache: HashMap<String, User>,
301    /// ID -> screen name のマップ。
302    id_username_cache: HashMap<String, String>,
303}
304
305struct Reply {
306    to_tw_id: String,
307    to_user_id: String,
308    text: String,
309    post_image_if_long: bool,
310}
311
312impl Twitter {
313    pub fn new(wakeup_list: Vec<NaiveTime>) -> Result<Self> {
314        info!("[twitter] initialize");
315
316        let config = config::get(|cfg| cfg.twitter.clone());
317
318        let font = if !config.font_file.is_empty() {
319            let ttf_bin = fs::read(&config.font_file)?;
320            Some(FontRenderer::new(ttf_bin)?)
321        } else {
322            None
323        };
324
325        Ok(Twitter {
326            config,
327            wakeup_list,
328            font,
329            tl_check_since_id: None,
330            my_user_cache: None,
331            username_user_cache: HashMap::new(),
332            id_username_cache: HashMap::new(),
333        })
334    }
335
336    /// Twitter 巡回タスク。
337    async fn twitter_task(&mut self, ctrl: &Control) -> Result<()> {
338        // 自分の ID
339        let me = self.get_my_id().await?;
340        info!("[tw-check] user_me: {me:?}");
341
342        // チェック開始 ID
343        let since_id = self.get_since_id().await?;
344        info!("[tw-check] since_id: {since_id}");
345
346        // 設定ファイル中の全 user name (screen name) から ID を得る
347        info!("[tw-check] get all user info from screen name");
348        // borrow checker (E0502) が手強すぎて勝てないので諦めてコピーを取る
349        let rules = self.config.tlcheck.rules.clone();
350        for rule in rules.iter() {
351            self.resolve_ids(&rule.user_names).await?;
352        }
353        info!(
354            "[tw-check] user id cache size: {}",
355            self.username_user_cache.len()
356        );
357
358        // 以降メイン処理
359
360        // 自分の最終ツイート以降のタイムラインを得る (リツイートは除く)
361        let tl = self.users_timelines_home(&me.id, &since_id).await?;
362        info!("{} tweets fetched", tl.data.len());
363
364        // 全リプライを Vec として得る
365        let mut reply_buf = self.create_reply_list(&tl, &me);
366        // 全 AI リプライを得て追加
367        reply_buf.append(&mut self.create_ai_reply_list(ctrl, &tl, &me).await);
368
369        // バッファしたリプライを実行
370        for Reply {
371            to_tw_id,
372            to_user_id,
373            text,
374            post_image_if_long,
375        } in reply_buf
376        {
377            // since_id 更新用データ
378            // tweet id を数値比較のため文字列から変換する
379            // (リプライ先 ID + 1) の max をとる
380            let cur: u64 = self.tl_check_since_id.as_ref().unwrap().parse().unwrap();
381            let next: u64 = to_tw_id.parse().unwrap();
382            let max = cur.max(next);
383
384            let name = self.get_username_from_id(&to_user_id).unwrap();
385            info!("reply to: {name}");
386
387            // post_image_if_long が有効で文字数オーバーの場合、画像にして投稿する
388            if let Some(font) = &self.font
389                && post_image_if_long
390                && text.chars().count() > TWEET_LEN_MAX
391            {
392                let pngbin = font.draw_multiline_text(
393                    LONG_TWEET_FGCOLOR,
394                    LONG_TWEET_BGCOLOR,
395                    &text,
396                    LONG_TWEET_FONT_SIZE,
397                    LONG_TWEET_IMAGE_WIDTH,
398                );
399                let media_id = self.media_upload(pngbin).await?;
400                self.tweet_custom("", Some(&to_tw_id), &[media_id]).await?;
401            } else {
402                self.tweet_custom(&text, Some(&to_tw_id), &[]).await?;
403            }
404
405            // 成功したら since_id を更新する
406            self.tl_check_since_id = Some(max.to_string());
407        }
408
409        // TODO: vote test
410        /*
411        let param = TweetParam {
412            poll: Some(TweetParamPoll {
413                duration_minutes: 60 * 24,
414                options: vec!["ホワイト".into(), "ブラック".into()],
415            }),
416            text: Some("?".into()),
417            ..Default::default()
418        };
419        let resp = self.tweets_post(param).await?;
420        info!("tweet result: {:?}", resp);
421        */
422
423        Ok(())
424    }
425
426    /// 全リプライを生成する
427    fn create_reply_list(&self, tl: &Timeline, me: &User) -> Vec<Reply> {
428        let mut reply_buf = Vec::new();
429
430        for rule in self.config.tlcheck.rules.iter() {
431            // 自分のツイートには反応しない
432            let tliter = tl
433                .data
434                .iter()
435                // author_id が存在する場合のみ
436                .filter(|tw| tw.author_id.is_some())
437                // 自分のツイートには反応しない
438                .filter(|tw| *tw.author_id.as_ref().unwrap() != me.id)
439                // 特定ハッシュタグを含むものは除外 (別関数で返答する)
440                .filter(|tw| {
441                    !tw.entities
442                        .hashtags
443                        .iter()
444                        .any(|v| v.tag == self.config.ai_hashtag)
445                });
446
447            for tw in tliter {
448                // author_id が user_names リストに含まれているものでフィルタ
449                let user_match = rule.user_names.iter().any(|user_name| {
450                    let user = self.get_user_from_username(user_name);
451                    match user {
452                        Some(user) => *tw.author_id.as_ref().unwrap() == user.id,
453                        // id 取得に失敗しているので無視
454                        None => false,
455                    }
456                });
457                if !user_match {
458                    continue;
459                }
460                // pattern 判定
461                for (pats, msgs) in rule.patterns.iter() {
462                    // 配列内のすべてのパターンを満たす
463                    let match_hit = pats.iter().all(|pat| Self::pattern_match(pat, &tw.text));
464                    if match_hit {
465                        info!("FIND: {tw:?}");
466                        // 配列からリプライをランダムに1つ選ぶ
467                        let rnd_idx = rand::rng().random_range(0..msgs.len());
468                        reply_buf.push(Reply {
469                            to_tw_id: tw.id.clone(),
470                            to_user_id: tw.author_id.as_ref().unwrap().clone(),
471                            text: msgs[rnd_idx].clone(),
472                            post_image_if_long: false,
473                        });
474                        // 複数種類では反応しない
475                        // 反応は1回のみ
476                        break;
477                    }
478                }
479            }
480        }
481
482        reply_buf
483    }
484
485    /// 全 AI リプライを生成する
486    async fn create_ai_reply_list(&self, ctrl: &Control, tl: &Timeline, me: &User) -> Vec<Reply> {
487        let mut reply_buf = Vec::new();
488
489        let tliter = tl
490            .data
491            .iter()
492            // author_id が存在する場合のみ
493            .filter(|tw| tw.author_id.is_some())
494            // 自分のツイートには反応しない
495            .filter(|tw| *tw.author_id.as_ref().unwrap() != me.id)
496            // 自分がメンションされている場合のみ
497            .filter(|tw| {
498                tw.entities
499                    .mentions
500                    .iter()
501                    .any(|v| v.username == me.username)
502            })
503            // 設定で指定されたハッシュタグを含む場合のみ対象
504            .filter(|tw| {
505                tw.entities
506                    .hashtags
507                    .iter()
508                    .any(|v| v.tag == self.config.ai_hashtag)
509            });
510
511        for tw in tliter {
512            info!("FIND (AI): {tw:?}");
513
514            let user = Self::resolve_user(
515                tw.author_id.as_ref().unwrap(),
516                &tl.includes.as_ref().unwrap().users,
517            );
518            if user.is_none() {
519                warn!("User {} is not found", tw.author_id.as_ref().unwrap());
520                continue;
521            }
522
523            // 設定からプロローグ分の入力メッセージを生成する
524            let system_msgs: Vec<_> = self
525                .config
526                .prompt
527                .pre
528                .iter()
529                .map(|text| {
530                    let text = text.replace("${user}", &user.unwrap().name);
531                    InputItem::Message {
532                        role: Role::Developer,
533                        content: vec![InputContent::InputText { text }],
534                    }
535                })
536                .collect();
537
538            let mut main_msg = String::new();
539            // メンションおよびハッシュタグ部分を削除する
540            for (ind, ch) in tw.text.chars().enumerate() {
541                let ind = ind as u32;
542                let mut deleted = false;
543                for m in tw.entities.mentions.iter() {
544                    if (m.start..m.end).contains(&ind) {
545                        deleted = true;
546                        break;
547                    }
548                }
549                for h in tw.entities.hashtags.iter() {
550                    if (h.start..h.end).contains(&ind) {
551                        deleted = true;
552                        break;
553                    }
554                }
555                if !deleted {
556                    main_msg.push(ch);
557                }
558            }
559
560            // 最後にツイートの本文を追加
561            let mut msgs = system_msgs.clone();
562            msgs.push(InputItem::Message {
563                role: Role::User,
564                content: vec![InputContent::InputText { text: main_msg }],
565            });
566
567            // 結果に追加する
568            // エラーはログのみ出して追加をしない
569            {
570                let mut ai = ctrl.sysmods().openai.lock().await;
571                match ai.chat(None, msgs).await {
572                    Ok(resp) => reply_buf.push(Reply {
573                        to_tw_id: tw.id.clone(),
574                        to_user_id: tw.author_id.as_ref().unwrap().clone(),
575                        text: resp.output_text(),
576                        post_image_if_long: true,
577                    }),
578                    Err(e) => {
579                        warn!("AI chat error: {e}");
580                    }
581                }
582            }
583        }
584
585        reply_buf
586    }
587
588    fn resolve_user<'a>(id: &str, users: &'a [User]) -> Option<&'a User> {
589        users.iter().find(|&user| user.id == id)
590    }
591
592    /// text から pat を検索する。
593    /// 先頭が '^' だとそれで始まる場合のみ。
594    /// 末尾が '$' だとそれで終わる場合のみ。
595    #[allow(clippy::bool_to_int_with_if)]
596    fn pattern_match(pat: &str, text: &str) -> bool {
597        let count = pat.chars().count();
598        if count == 0 {
599            return false;
600        }
601        let match_start = pat.starts_with('^');
602        let match_end = pat.ends_with('$');
603        let begin = pat
604            .char_indices()
605            // clippy::bool_to_int_with_if
606            .nth(if match_start { 1 } else { 0 })
607            .unwrap_or((0, '\0'))
608            .0;
609        let end = pat
610            .char_indices()
611            .nth(if match_end { count - 1 } else { count })
612            .unwrap_or((pat.len(), '\0'))
613            .0;
614        let pat = &pat[begin..end];
615        if pat.is_empty() {
616            return false;
617        }
618
619        if match_start && match_end {
620            text == pat
621        } else if match_start {
622            text.starts_with(pat)
623        } else if match_end {
624            text.ends_with(pat)
625        } else {
626            text.contains(pat)
627        }
628    }
629
630    /// 自分のツイートリストを得て最終ツイート ID を得る(キャッシュ付き)。
631    async fn get_since_id(&mut self) -> Result<String> {
632        let me = self.get_my_id().await?;
633        if self.tl_check_since_id.is_none() {
634            let usertw = self.users_tweets(&me.id).await?;
635            // API は成功したが最新 ID が得られなかった場合は "1" を設定する
636            self.tl_check_since_id = Some(usertw.meta.newest_id.unwrap_or_else(|| "1".into()));
637        }
638
639        Ok(self.tl_check_since_id.clone().unwrap())
640    }
641
642    /// シンプルなツイート。
643    /// 中身は [Self::tweet_raw]。
644    pub async fn tweet(&mut self, text: &str) -> Result<()> {
645        self.tweet_custom(text, None, &[]).await
646    }
647
648    /// メディア付きツイート。
649    /// 中身は [Self::tweet_raw]。
650    pub async fn tweet_custom(
651        &mut self,
652        text: &str,
653        reply_to: Option<&str>,
654        media_ids: &[u64],
655    ) -> Result<()> {
656        let reply = reply_to.map(|id| TweetParamReply {
657            in_reply_to_tweet_id: id.to_string(),
658        });
659
660        let media_ids = if media_ids.is_empty() {
661            None
662        } else {
663            let media_ids: Vec<_> = media_ids.iter().map(|id| id.to_string()).collect();
664            Some(media_ids)
665        };
666        let media = media_ids.map(|media_ids| Media {
667            media_ids: Some(media_ids),
668            ..Default::default()
669        });
670
671        let param = TweetParam {
672            reply,
673            text: Some(text.to_string()),
674            media,
675            ..Default::default()
676        };
677
678        self.tweet_raw(param).await
679    }
680
681    /// [TwitterConfig::fake_tweet] 設定に対応したツイート。
682    async fn tweet_raw(&mut self, mut param: TweetParam) -> Result<()> {
683        // tl_check_since_id が None なら自分の最新ツイート ID を取得して設定する
684        self.get_since_id().await?;
685
686        // 140 字チェック
687        if let Some(ref text) = param.text {
688            let len = text.chars().count();
689            if len > TWEET_LEN_MAX {
690                warn!("tweet length > {TWEET_LEN_MAX}: {len}");
691                warn!("before: {text}");
692                let text = Self::truncate_tweet_text(text).to_string();
693                warn!("after : {text}");
694                param.text = Some(text);
695            }
696        }
697
698        if !self.config.fake_tweet {
699            // real tweet!
700            self.tweets_post(param).await?;
701
702            Ok(())
703        } else {
704            info!("fake tweet: {param:?}");
705
706            Ok(())
707        }
708    }
709
710    /// 140 字に切り詰める
711    fn truncate_tweet_text(text: &str) -> &str {
712        // 141 文字目の最初のバイトインデックスを得る
713        let lastc = text.char_indices().nth(TWEET_LEN_MAX);
714
715        match lastc {
716            // 0 からそれを含まないバイトまで
717            Some((ind, _)) => &text[0..ind],
718            // 存在しないなら文字列全体を返す
719            None => text,
720        }
721    }
722
723    /// <https://developer.twitter.com/en/docs/twitter-api/v1/media/upload-media/api-reference/post-media-upload>
724    /// <https://developer.twitter.com/en/docs/twitter-api/v1/media/upload-media/uploading-media/media-best-practices>
725    pub async fn media_upload<T: Into<reqwest::Body>>(&self, bin: T) -> Result<u64> {
726        if self.config.fake_tweet {
727            info!("fake upload");
728
729            return Ok(0);
730        }
731
732        info!("upload");
733        let part = multipart::Part::stream(bin);
734        let form = multipart::Form::new().part("media", part);
735
736        let resp = self
737            .http_oauth_post_multipart(URL_UPLOAD, &BTreeMap::new(), form)
738            .await?;
739        let json_str = netutil::check_http_resp(resp).await?;
740        let obj: UploadResponseData = netutil::convert_from_json(&json_str)?;
741        info!("upload OK: media_id={}", obj.media_id);
742
743        Ok(obj.media_id)
744    }
745
746    /// エントリ関数。[Self::twitter_task] を呼ぶ。
747    ///
748    /// [Control] 内の [Twitter] オブジェクトを lock するので
749    /// [Self::twitter_task] は排他実行となる。
750    async fn twitter_task_entry(ctrl: Control) -> Result<()> {
751        let mut twitter = ctrl.sysmods().twitter.lock().await;
752        twitter.twitter_task(&ctrl).await
753    }
754
755    /// 自身の Twitter ID を返す。
756    /// [Self::users_me] の キャッシュ付きバージョン。
757    async fn get_my_id(&mut self) -> Result<User> {
758        if let Some(user) = &self.my_user_cache {
759            Ok(user.clone())
760        } else {
761            Ok(self.users_me().await?.data)
762        }
763    }
764
765    fn get_user_from_username(&self, name: &String) -> Option<&User> {
766        self.username_user_cache.get(name)
767    }
768
769    fn get_username_from_id(&self, id: &String) -> Option<&String> {
770        self.id_username_cache.get(id)
771    }
772
773    /// user name (screen name) から id を取得する。
774    /// id -> user name のマップも同時に作成する。
775    ///
776    /// 結果は [Self::username_user_cache], [Self::id_username_cache] に入れる。
777    /// 凍結等で取得できない可能性があり、その場合はエラーを出しながら続行するよりは
778    /// panic でユーザに知らせる。
779    async fn resolve_ids(&mut self, user_names: &[String]) -> Result<()> {
780        // name_user_cache にないユーザ名を集める
781        let unknown_users: Vec<_> = user_names
782            .iter()
783            .filter_map(|user| {
784                if !self.username_user_cache.contains_key(user) {
785                    Some(user.clone())
786                } else {
787                    None
788                }
789            })
790            .collect();
791
792        // LIMIT_USERS_BY 個ずつ GET リクエストしてハッシュテーブルにキャッシュする
793        let mut start = 0_usize;
794        while start < unknown_users.len() {
795            let end = std::cmp::min(unknown_users.len(), start + LIMIT_USERS_BY);
796            let request_users = &unknown_users[start..end];
797            let mut rest: BTreeSet<_> = request_users.iter().collect();
798
799            // suspend user のみでリクエストすると
800            // {data: {...}} でなく {error: {...}}
801            // が返ってきて API は 200 で成功するがパースに失敗する
802            // やや汚いが panic してユーザリストの見直すよう促す
803            let result = self.users_by(request_users).await;
804            if let Err(e) = result {
805                if e.is::<serde_json::Error>() {
806                    panic!("parse error {e:?}");
807                } else {
808                    return Err(e);
809                }
810            }
811
812            for user in result?.data.iter() {
813                info!(
814                    "[twitter] resolve username: {} => {}",
815                    user.username, user.id
816                );
817                self.username_user_cache
818                    .insert(user.username.clone(), user.clone());
819                self.id_username_cache
820                    .insert(user.id.clone(), user.username.clone());
821                let removed = rest.remove(&user.username);
822                assert!(removed);
823            }
824            assert!(
825                rest.is_empty(),
826                "cannot resolved (account suspended?): {rest:?}"
827            );
828
829            start += LIMIT_USERS_BY;
830        }
831        assert_eq!(self.username_user_cache.len(), self.id_username_cache.len());
832
833        Ok(())
834    }
835
836    async fn users_me(&self) -> Result<UsersMe> {
837        let resp = self.http_oauth_get(URL_USERS_ME, &KeyValue::new()).await?;
838        let json_str = netutil::check_http_resp(resp).await?;
839        let obj: UsersMe = netutil::convert_from_json(&json_str)?;
840
841        Ok(obj)
842    }
843
844    async fn users_by(&self, users: &[String]) -> Result<UsersBy> {
845        if !(1..LIMIT_USERS_BY).contains(&users.len()) {
846            panic!("{} limit over: {}", URL_USERS_BY, users.len());
847        }
848        let users_str = users.join(",");
849        let resp = self
850            .http_oauth_get(
851                URL_USERS_BY,
852                &BTreeMap::from([("usernames".into(), users_str)]),
853            )
854            .await?;
855        let json_str = netutil::check_http_resp(resp).await?;
856        let obj: UsersBy = netutil::convert_from_json(&json_str)?;
857
858        Ok(obj)
859    }
860
861    async fn users_timelines_home(&self, id: &str, since_id: &str) -> Result<Timeline> {
862        let url = format!(URL_USERS_TIMELINES_HOME!(), id);
863        let param = KeyValue::from([
864            ("since_id".to_string(), since_id.to_string()),
865            ("exclude".to_string(), "retweets".to_string()),
866            ("expansions".to_string(), "author_id".to_string()),
867            ("tweet.fields".to_string(), "entities".to_string()),
868        ]);
869        let resp = self.http_oauth_get(&url, &param).await?;
870        let json_str = netutil::check_http_resp(resp).await?;
871        debug!("{json_str}");
872        let obj: Timeline = netutil::convert_from_json(&json_str)?;
873
874        Ok(obj)
875    }
876
877    async fn users_tweets(&self, id: &str) -> Result<Timeline> {
878        let url = format!(URL_USERS_TWEET!(), id);
879        let param = KeyValue::from([
880            // retweets and/or replies
881            ("exclude".into(), "retweets".into()),
882            // default=10, min=5, max=100
883            ("max_results".into(), "100".into()),
884        ]);
885        let resp = self.http_oauth_get(&url, &param).await?;
886        let json_str = netutil::check_http_resp(resp).await?;
887        let obj: Timeline = netutil::convert_from_json(&json_str)?;
888
889        Ok(obj)
890    }
891
892    async fn tweets_post(&self, param: TweetParam) -> Result<TweetResponse> {
893        let resp = self
894            .http_oauth_post_json(URL_TWEETS, &KeyValue::new(), &param)
895            .await?;
896        let json_str = netutil::check_http_resp(resp).await?;
897        let obj: TweetResponse = netutil::convert_from_json(&json_str)?;
898
899        Ok(obj)
900    }
901
902    async fn http_oauth_get(
903        &self,
904        base_url: &str,
905        query_param: &KeyValue,
906    ) -> Result<reqwest::Response> {
907        let cf = &self.config;
908        let mut oauth_param = create_oauth_field(&cf.consumer_key, &cf.access_token);
909        let signature = create_signature(
910            "GET",
911            base_url,
912            &oauth_param,
913            query_param,
914            &KeyValue::new(),
915            &cf.consumer_secret,
916            &cf.access_secret,
917        );
918        oauth_param.insert("oauth_signature".into(), signature);
919
920        let (oauth_k, oauth_v) = create_http_oauth_header(&oauth_param);
921
922        let client = reqwest::Client::new();
923        let req = client
924            .get(base_url)
925            .timeout(TIMEOUT)
926            .query(&query_param)
927            .header(oauth_k, oauth_v);
928        let res = req.send().await?;
929
930        Ok(res)
931    }
932
933    async fn http_oauth_post_json<T: Serialize>(
934        &self,
935        base_url: &str,
936        query_param: &KeyValue,
937        body_param: &T,
938    ) -> Result<reqwest::Response> {
939        let json_str = serde_json::to_string(body_param).unwrap();
940        debug!("POST: {json_str}");
941
942        let client = reqwest::Client::new();
943        let resp = netutil::send_with_retry(|| {
944            self.http_oauth_post(&client, base_url, query_param)
945                .header("Content-type", "application/json")
946                .body(json_str.clone())
947        })
948        .await?;
949
950        Ok(resp)
951    }
952
953    async fn http_oauth_post_multipart(
954        &self,
955        base_url: &str,
956        query_param: &KeyValue,
957        body: multipart::Form,
958    ) -> Result<reqwest::Response> {
959        let client = reqwest::Client::new();
960        let req = self
961            .http_oauth_post(&client, base_url, query_param)
962            .multipart(body);
963        let resp = req.send().await?;
964
965        Ok(resp)
966    }
967
968    fn http_oauth_post(
969        &self,
970        client: &reqwest::Client,
971        base_url: &str,
972        query_param: &KeyValue,
973    ) -> reqwest::RequestBuilder {
974        let cf = &self.config;
975        let mut oauth_param = create_oauth_field(&cf.consumer_key, &cf.access_token);
976        let signature = create_signature(
977            "POST",
978            base_url,
979            &oauth_param,
980            query_param,
981            &KeyValue::new(),
982            &cf.consumer_secret,
983            &cf.access_secret,
984        );
985        oauth_param.insert("oauth_signature".into(), signature);
986
987        let (oauth_k, oauth_v) = create_http_oauth_header(&oauth_param);
988
989        client
990            .post(base_url)
991            .timeout(TIMEOUT)
992            .query(query_param)
993            .header(oauth_k, oauth_v)
994    }
995}
996
997impl SystemModule for Twitter {
998    fn on_start(&mut self, ctrl: &Control) {
999        info!("[twitter] on_start");
1000        if self.config.tlcheck_enabled {
1001            if self.config.debug_exec_once {
1002                taskserver::spawn_oneshot_task(ctrl, "tw-check", Twitter::twitter_task_entry);
1003            } else {
1004                taskserver::spawn_periodic_task(
1005                    ctrl,
1006                    "tw-check",
1007                    &self.wakeup_list,
1008                    Twitter::twitter_task_entry,
1009                );
1010            }
1011        }
1012    }
1013}
1014
1015/// HTTP header や query を表すデータ構造。
1016///
1017/// 署名時にソートを求められるのと、ハッシュテーブルだと最終的なリクエスト内での順番が
1018/// 一意にならないため、Sorted Map として B-Tree を使うことにする
1019type KeyValue = BTreeMap<String, String>;
1020
1021/// OAuth 1.0a 認証のための KeyValue セットを生成する。
1022///
1023/// oauth_signature フィールドはこれらを含むデータを元に計算する必要があるので
1024/// まだ設定しない。
1025/// 乱数による nonce やタイムスタンプが含まれるため、呼び出すたびに結果は変わる。
1026///
1027/// 詳細:
1028/// <https://developer.twitter.com/en/docs/authentication/oauth-1-0a/authorizing-a-request>
1029fn create_oauth_field(consumer_key: &str, access_token: &str) -> KeyValue {
1030    let mut param = KeyValue::new();
1031
1032    // oauth_consumer_key: アプリの識別子
1033    param.insert("oauth_consumer_key".into(), consumer_key.into());
1034
1035    // oauth_nonce: ランダム値 (リプレイ攻撃対策)
1036    // 暗号学的安全性が必要か判断がつかないので安全な方にしておく
1037    // Twitter によるとランダムな英数字なら何でもいいらしいが、例に挙げられている
1038    // 32byte の乱数を BASE64 にして英数字のみを残したものとする
1039    let mut rng = rand::rng();
1040    let rnd32: [u8; 32] = rng.random();
1041    let rnd32_str = general_purpose::STANDARD.encode(rnd32);
1042    let mut nonce_str = "".to_string();
1043    for c in rnd32_str.chars() {
1044        if c.is_alphanumeric() {
1045            nonce_str.push(c);
1046        }
1047    }
1048    param.insert("oauth_nonce".into(), nonce_str);
1049
1050    // 署名は署名以外の oauth_* フィールドに対しても行う
1051    // 今はまだ不明なので後で追加する
1052    // param.emplace("oauth_signature", sha1(...));
1053
1054    // oauth_signature_method, oauth_timestamp, oauth_token, oauth_version
1055    param.insert("oauth_signature_method".to_string(), "HMAC-SHA1".into());
1056    let unix_epoch_sec = SystemTime::now()
1057        .duration_since(UNIX_EPOCH)
1058        .unwrap()
1059        .as_secs();
1060    param.insert("oauth_timestamp".into(), unix_epoch_sec.to_string());
1061    param.insert("oauth_token".into(), access_token.into());
1062    param.insert("oauth_version".into(), "1.0".into());
1063
1064    param
1065}
1066
1067/// HMAC-SHA1 署名を計算する。
1068/// この結果を oauth_signature フィールドに設定する必要がある。
1069///
1070/// * oauth_param: HTTP header 内の Authorization: OAuth 関連フィールド。
1071/// * query_param: URL 末尾の query。
1072/// * body_param: HTTP request body にあるパラメータ (POST data)。
1073///
1074/// 詳細:
1075/// <https://developer.twitter.com/en/docs/authentication/oauth-1-0a/creating-a-signature>
1076///
1077/// oauth_param, query_param, body_param 内でキーの重複があると panic する。
1078fn create_signature(
1079    http_method: &str,
1080    base_url: &str,
1081    oauth_param: &KeyValue,
1082    query_param: &KeyValue,
1083    body_param: &KeyValue,
1084    consumer_secret: &str,
1085    token_secret: &str,
1086) -> String {
1087    // "Collecting the request method and URL"
1088    // Example:
1089    // http_method = POST
1090    // base_url = https://api.twitter.com/1.1/statuses/update.json
1091
1092    // "Collecting parameters"
1093    // 以下の percent encode 前データを percent encode しながら 1つにまとめて
1094    // キーの辞書順にソートする
1095    // キーの重複は Twitter では認められていないのでシンプルに考えて OK
1096    // * URL 末尾の query
1097    // * request body
1098    // * HTTP header の oauth_* パラメタ
1099    //
1100    // 1. Percent encode every key and value that will be signed.
1101    // 2. Sort the list of parameters alphabetically [1] by encoded key [2].
1102    // 3. For each key/value pair:
1103    // 4. Append the encoded key to the output string.
1104    // 5. Append the ‘=’ character to the output string.
1105    // 6. Append the encoded value to the output string.
1106    // 7. If there are more key/value pairs remaining, append a ‘&’ character to the output string.
1107
1108    // 1-2
1109    let mut param = KeyValue::new();
1110    let encode_add = |param: &mut KeyValue, src: &KeyValue| {
1111        for (k, v) in src.iter() {
1112            let old = param.insert(netutil::percent_encode(k), netutil::percent_encode(v));
1113            if old.is_some() {
1114                panic!("duplicate key: {k}");
1115            }
1116        }
1117    };
1118    encode_add(&mut param, oauth_param);
1119    encode_add(&mut param, query_param);
1120    encode_add(&mut param, body_param);
1121
1122    // 3-7
1123    let mut parameter_string = "".to_string();
1124    let mut is_first = true;
1125    for (k, v) in param {
1126        if is_first {
1127            is_first = false;
1128        } else {
1129            parameter_string.push('&');
1130        }
1131        parameter_string.push_str(&k);
1132        parameter_string.push('=');
1133        parameter_string.push_str(&v);
1134    }
1135
1136    // "Creating the signature base string"
1137    // "signature base string" by OAuth spec
1138    // 署名対象となる文字列を生成する
1139    // method, url, param を & でつなげるだけ
1140    //
1141    // 1. Convert the HTTP Method to uppercase and set the output string equal to this value.
1142    // 2. Append the ‘&’ character to the output string.
1143    // 3. Percent encode the URL and append it to the output string.
1144    // 4. Append the ‘&’ character to the output string.
1145    // 5. Percent encode the parameter string and append it to the output string.
1146    let mut signature_base_string = "".to_string();
1147    signature_base_string.push_str(&http_method.to_ascii_uppercase());
1148    signature_base_string.push('&');
1149    signature_base_string.push_str(&netutil::percent_encode(base_url));
1150    signature_base_string.push('&');
1151    signature_base_string.push_str(&netutil::percent_encode(&parameter_string));
1152
1153    // "Getting a signing key"
1154    // 署名鍵は consumer_secret と token_secret をエスケープして & でつなぐだけ
1155    let mut signing_key = "".to_string();
1156    signing_key.push_str(consumer_secret);
1157    signing_key.push('&');
1158    signing_key.push_str(token_secret);
1159
1160    // "Calculating the signature"
1161    // HMAC SHA1
1162    let result = netutil::hmac_sha1(signing_key.as_bytes(), signature_base_string.as_bytes());
1163
1164    // base64 encode したものを署名として "oauth_signature" に設定する
1165    general_purpose::STANDARD.encode(result.into_bytes())
1166}
1167
1168/// HTTP header に設定する (key, value) を文字列として生成して返す。
1169///
1170/// Authorization: OAuth key1="value1", key2="value2", ..., keyN="valueN"
1171fn create_http_oauth_header(oauth_param: &KeyValue) -> (String, String) {
1172    let mut oauth_value = "OAuth ".to_string();
1173    {
1174        let v: Vec<_> = oauth_param
1175            .iter()
1176            .map(|(k, v)| {
1177                format!(
1178                    r#"{}="{}""#,
1179                    netutil::percent_encode(k),
1180                    netutil::percent_encode(v)
1181                )
1182            })
1183            .collect();
1184        oauth_value.push_str(&v.join(", "));
1185    }
1186
1187    ("Authorization".into(), oauth_value)
1188}
1189
1190#[cfg(test)]
1191mod tests {
1192    use super::*;
1193
1194    #[test]
1195    fn parse_default_toml() {
1196        // should not panic
1197        let obj: TimelineCheck = Default::default();
1198        assert_ne!(obj.rules.len(), 0);
1199
1200        let obj: TwitterPrompt = Default::default();
1201        assert_ne!(obj.pre.len(), 0);
1202    }
1203
1204    #[test]
1205    fn truncate_tweet_text() {
1206        // 20 chars * 7
1207        let from1 = "あいうえおかきくけこ0123456789".repeat(7);
1208        let to1 = Twitter::truncate_tweet_text(&from1).to_string();
1209        assert_eq!(from1.chars().count(), TWEET_LEN_MAX);
1210        assert_eq!(from1, to1);
1211
1212        let from2 = format!("{from1}あ");
1213        let to2 = Twitter::truncate_tweet_text(&from2).to_string();
1214        assert_eq!(from2.chars().count(), TWEET_LEN_MAX + 1);
1215        assert_eq!(from1, to2);
1216    }
1217
1218    #[test]
1219    fn tweet_pattern_match() {
1220        assert!(Twitter::pattern_match("あいうえお", "あいうえお"));
1221        assert!(Twitter::pattern_match("^あいうえお", "あいうえお"));
1222        assert!(Twitter::pattern_match("あいうえお$", "あいうえお"));
1223        assert!(Twitter::pattern_match("^あいうえお$", "あいうえお"));
1224
1225        assert!(Twitter::pattern_match("あいう", "あいうえお"));
1226        assert!(Twitter::pattern_match("^あいう", "あいうえお"));
1227        assert!(!Twitter::pattern_match("あいう$", "あいうえお"));
1228        assert!(!Twitter::pattern_match("^あいう$", "あいうえお"));
1229
1230        assert!(Twitter::pattern_match("うえお", "あいうえお"));
1231        assert!(!Twitter::pattern_match("^うえお", "あいうえお"));
1232        assert!(Twitter::pattern_match("うえお$", "あいうえお"));
1233        assert!(!Twitter::pattern_match("^うえお$", "あいうえお"));
1234
1235        assert!(Twitter::pattern_match("いうえ", "あいうえお"));
1236        assert!(!Twitter::pattern_match("^いうえ", "あいうえお"));
1237        assert!(!Twitter::pattern_match("いうえ$", "あいうえお"));
1238        assert!(!Twitter::pattern_match("^いうえ$", "あいうえお"));
1239
1240        assert!(!Twitter::pattern_match("", "あいうえお"));
1241        assert!(!Twitter::pattern_match("^", "あいうえお"));
1242        assert!(!Twitter::pattern_match("$", "あいうえお"));
1243        assert!(!Twitter::pattern_match("^$", "あいうえお"));
1244    }
1245
1246    // https://developer.twitter.com/en/docs/authentication/oauth-1-0a/creating-a-signature
1247    #[test]
1248    fn twitter_sample_signature() {
1249        let method = "POST";
1250        let url = "https://api.twitter.com/1.1/statuses/update.json";
1251
1252        // This is just an example in the Twitter API document
1253        // Not a real secret key
1254        let mut oauth_param = KeyValue::new();
1255        oauth_param.insert("oauth_consumer_key".into(), "xvz1evFS4wEEPTGEFPHBog".into());
1256        oauth_param.insert(
1257            "oauth_nonce".into(),
1258            "kYjzVBB8Y0ZFabxSWbWovY3uYSQ2pTgmZeNu2VS4cg".into(),
1259        );
1260        oauth_param.insert("oauth_signature_method".into(), "HMAC-SHA1".into());
1261        oauth_param.insert("oauth_timestamp".into(), "1318622958".into());
1262        oauth_param.insert(
1263            "oauth_token".into(),
1264            "370773112-GmHxMAgYyLbNEtIKZeRNFsMKPR9EyMZeS9weJAEb".into(),
1265        );
1266        oauth_param.insert("oauth_version".into(), "1.0".into());
1267
1268        let mut query_param = KeyValue::new();
1269        query_param.insert("include_entities".into(), "true".into());
1270
1271        let mut body_param = KeyValue::new();
1272        body_param.insert(
1273            "status".into(),
1274            "Hello Ladies + Gentlemen, a signed OAuth request!".into(),
1275        );
1276
1277        // This is just an example in the Twitter API document
1278        // Not a real secret key
1279        let consumer_secret = "kAcSOqF21Fu85e7zjz7ZN2U4ZRhfV3WpwPAoE3Z7kBw";
1280        let token_secret = "LswwdoUaIvS8ltyTt5jkRh4J50vUPVVHtR2YPi5kE";
1281
1282        let result = create_signature(
1283            method,
1284            url,
1285            &oauth_param,
1286            &query_param,
1287            &body_param,
1288            consumer_secret,
1289            token_secret,
1290        );
1291
1292        assert_eq!(result, "hCtSmYh+iHYCEqBWrE7C7hYmtUk=");
1293    }
1294}