1use 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
31pub 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 #[serde(default)]
108 entities: Entities,
109}
110
111#[derive(Clone, Debug, Serialize, Deserialize)]
112struct Meta {
113 result_count: u64,
115 newest_id: Option<String>,
117 oldest_id: Option<String>,
119}
120
121#[derive(Clone, Debug, Serialize, Deserialize)]
122struct Timeline {
123 data: Vec<Tweet>,
124 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 #[serde(skip_serializing_if = "Option::is_none")]
156 text: Option<String>,
157 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
182pub struct TwitterConfig {
183 tlcheck_enabled: bool,
185 debug_exec_once: bool,
187 fake_tweet: bool,
189 consumer_key: String,
191 consumer_secret: String,
193 access_token: String,
195 access_secret: String,
197 ai_hashtag: String,
199 font_file: String,
206 #[serde(default)]
208 tlcheck: TimelineCheck,
209 #[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#[derive(Debug, Default, Clone, Serialize, Deserialize)]
234pub struct TimelineCheckRule {
235 pub user_names: Vec<String>,
237 pub patterns: Vec<(Vec<String>, Vec<String>)>,
246}
247
248#[derive(Debug, Clone, Serialize, Deserialize)]
250pub struct TimelineCheck {
251 pub rules: Vec<TimelineCheckRule>,
253}
254
255const 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#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct TwitterPrompt {
267 pub pre: Vec<String>,
268}
269
270const 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 tl_check_since_id: Option<String>,
297 my_user_cache: Option<User>,
299 username_user_cache: HashMap<String, User>,
301 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 async fn twitter_task(&mut self, ctrl: &Control) -> Result<()> {
338 let me = self.get_my_id().await?;
340 info!("[tw-check] user_me: {me:?}");
341
342 let since_id = self.get_since_id().await?;
344 info!("[tw-check] since_id: {since_id}");
345
346 info!("[tw-check] get all user info from screen name");
348 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 let tl = self.users_timelines_home(&me.id, &since_id).await?;
362 info!("{} tweets fetched", tl.data.len());
363
364 let mut reply_buf = self.create_reply_list(&tl, &me);
366 reply_buf.append(&mut self.create_ai_reply_list(ctrl, &tl, &me).await);
368
369 for Reply {
371 to_tw_id,
372 to_user_id,
373 text,
374 post_image_if_long,
375 } in reply_buf
376 {
377 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 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 self.tl_check_since_id = Some(max.to_string());
407 }
408
409 Ok(())
424 }
425
426 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 let tliter = tl
433 .data
434 .iter()
435 .filter(|tw| tw.author_id.is_some())
437 .filter(|tw| *tw.author_id.as_ref().unwrap() != me.id)
439 .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 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 None => false,
455 }
456 });
457 if !user_match {
458 continue;
459 }
460 for (pats, msgs) in rule.patterns.iter() {
462 let match_hit = pats.iter().all(|pat| Self::pattern_match(pat, &tw.text));
464 if match_hit {
465 info!("FIND: {tw:?}");
466 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 break;
477 }
478 }
479 }
480 }
481
482 reply_buf
483 }
484
485 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 .filter(|tw| tw.author_id.is_some())
494 .filter(|tw| *tw.author_id.as_ref().unwrap() != me.id)
496 .filter(|tw| {
498 tw.entities
499 .mentions
500 .iter()
501 .any(|v| v.username == me.username)
502 })
503 .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 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 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 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 {
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 #[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 .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 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 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 pub async fn tweet(&mut self, text: &str) -> Result<()> {
645 self.tweet_custom(text, None, &[]).await
646 }
647
648 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 async fn tweet_raw(&mut self, mut param: TweetParam) -> Result<()> {
683 self.get_since_id().await?;
685
686 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 self.tweets_post(param).await?;
701
702 Ok(())
703 } else {
704 info!("fake tweet: {param:?}");
705
706 Ok(())
707 }
708 }
709
710 fn truncate_tweet_text(text: &str) -> &str {
712 let lastc = text.char_indices().nth(TWEET_LEN_MAX);
714
715 match lastc {
716 Some((ind, _)) => &text[0..ind],
718 None => text,
720 }
721 }
722
723 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 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 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 async fn resolve_ids(&mut self, user_names: &[String]) -> Result<()> {
780 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 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 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, ¶m).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 ("exclude".into(), "retweets".into()),
882 ("max_results".into(), "100".into()),
884 ]);
885 let resp = self.http_oauth_get(&url, ¶m).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(), ¶m)
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
1015type KeyValue = BTreeMap<String, String>;
1020
1021fn create_oauth_field(consumer_key: &str, access_token: &str) -> KeyValue {
1030 let mut param = KeyValue::new();
1031
1032 param.insert("oauth_consumer_key".into(), consumer_key.into());
1034
1035 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 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
1067fn 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 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 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 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(¶meter_string));
1152
1153 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 let result = netutil::hmac_sha1(signing_key.as_bytes(), signature_base_string.as_bytes());
1163
1164 general_purpose::STANDARD.encode(result.into_bytes())
1166}
1167
1168fn 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 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 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 #[test]
1248 fn twitter_sample_signature() {
1249 let method = "POST";
1250 let url = "https://api.twitter.com/1.1/statuses/update.json";
1251
1252 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 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}