Skip to main content

douyin_cli/
api.rs

1use std::collections::HashMap;
2use std::io::{self, Write};
3
4use clap::{Args, Subcommand, ValueEnum};
5use serde_json::{json, Map, Value};
6
7use crate::err;
8use crate::openapi::{im_message_body, OpenApiClient, RequestSpec};
9use crate::settings;
10
11#[derive(Debug, Args)]
12pub struct ApiArgs {
13    #[command(subcommand)]
14    command: ApiCommand,
15}
16
17#[derive(Debug, Subcommand)]
18enum ApiCommand {
19    /// 获取 client_token
20    ClientToken {
21        #[arg(long, env = "DOUYIN_CLIENT_KEY")]
22        client_key: String,
23        #[arg(long, env = "DOUYIN_CLIENT_SECRET")]
24        client_secret: String,
25    },
26    /// 生成官方 OAuth 授权链接
27    AuthorizeUrl {
28        #[arg(long, env = "DOUYIN_CLIENT_KEY")]
29        client_key: String,
30        #[arg(long)]
31        redirect_uri: String,
32        #[arg(long, required = true)]
33        scope: Vec<String>,
34        #[arg(long)]
35        state: Option<String>,
36    },
37    /// 用 OAuth code 换取 access_token
38    AccessToken {
39        #[arg(long, env = "DOUYIN_CLIENT_KEY")]
40        client_key: String,
41        #[arg(long, env = "DOUYIN_CLIENT_SECRET")]
42        client_secret: String,
43        #[arg(long)]
44        code: String,
45    },
46    /// 刷新官方 access_token
47    RefreshToken {
48        #[arg(long, env = "DOUYIN_CLIENT_KEY")]
49        client_key: String,
50        #[arg(long)]
51        refresh_token: String,
52    },
53    /// 续期官方 refresh_token
54    RenewRefreshToken {
55        #[arg(long, env = "DOUYIN_CLIENT_KEY")]
56        client_key: String,
57        #[arg(long)]
58        refresh_token: String,
59    },
60    /// 获取官方授权用户信息
61    Userinfo(AuthOptions),
62    /// 调用官方接口获取视频评论列表
63    CommentList {
64        #[command(flatten)]
65        auth: AuthOptions,
66        #[arg(long)]
67        item_id: String,
68        #[arg(long, default_value_t = 0)]
69        cursor: u64,
70        #[arg(long, default_value_t = 20, value_parser = clap::value_parser!(u32).range(1..=20))]
71        count: u32,
72        /// 0=综合排序,1=最多点赞,2=最新发布
73        #[arg(long, value_parser = clap::value_parser!(u8).range(0..=2))]
74        sort_type: Option<u8>,
75    },
76    /// 调用官方接口获取评论回复列表
77    CommentReplies {
78        #[command(flatten)]
79        auth: AuthOptions,
80        #[arg(long)]
81        item_id: String,
82        #[arg(long)]
83        comment_id: String,
84        #[arg(long, default_value_t = 0)]
85        cursor: u64,
86        #[arg(long, default_value_t = 20, value_parser = clap::value_parser!(u32).range(1..=20))]
87        count: u32,
88        /// 0=综合排序,1=最多点赞,2=最新发布
89        #[arg(long, value_parser = clap::value_parser!(u8).range(0..=2))]
90        sort_type: Option<u8>,
91    },
92    /// 调用官方接口回复视频评论
93    CommentReply {
94        #[command(flatten)]
95        auth: AuthOptions,
96        #[arg(long)]
97        item_id: String,
98        #[arg(long)]
99        comment_id: Option<String>,
100        #[arg(long)]
101        content: String,
102        #[arg(long)]
103        yes: bool,
104    },
105    /// 通过官方私信接口回复或首次进入会话
106    ImMessageSend {
107        #[command(flatten)]
108        auth: AuthOptions,
109        #[arg(long)]
110        to_user_id: String,
111        /// 私信场景;reply/enter 也可作为别名
112        #[arg(long, value_enum, default_value_t = ImScene::Reply)]
113        scene: ImScene,
114        /// 回调事件中的消息 ID
115        #[arg(long)]
116        msg_id: String,
117        /// 回调事件中的会话 ID
118        #[arg(long)]
119        conversation_id: String,
120        #[arg(long, value_enum, default_value_t = MessageType::Text)]
121        message_type: MessageType,
122        #[arg(long)]
123        text: Option<String>,
124        #[arg(long)]
125        media_id: Option<String>,
126        #[arg(long)]
127        item_id: Option<String>,
128        #[arg(long)]
129        yes: bool,
130    },
131    /// 调用任意官方 OpenAPI 路径
132    Request {
133        method: String,
134        path: String,
135        #[arg(long, env = "DOUYIN_ACCESS_TOKEN")]
136        token: Option<String>,
137        #[arg(long = "param")]
138        params: Vec<String>,
139        #[arg(long = "json")]
140        json_text: Option<String>,
141        #[arg(long = "form")]
142        forms: Vec<String>,
143        #[arg(long = "header")]
144        headers: Vec<String>,
145    },
146}
147
148#[derive(Debug, Args)]
149struct AuthOptions {
150    /// 默认读取已保存 token
151    #[arg(long, env = "DOUYIN_ACCESS_TOKEN")]
152    token: Option<String>,
153    /// 默认读取已保存 open_id
154    #[arg(long)]
155    open_id: Option<String>,
156}
157
158#[derive(Clone, Debug, ValueEnum)]
159enum MessageType {
160    Text,
161    Image,
162    Video,
163}
164
165#[derive(Clone, Debug, ValueEnum)]
166enum ImScene {
167    #[value(name = "im-reply-msg", alias = "reply")]
168    Reply,
169    #[value(name = "im-enter-direct-msg", alias = "enter")]
170    Enter,
171}
172
173impl ImScene {
174    fn as_str(&self) -> &'static str {
175        match self {
176            Self::Reply => "im_reply_msg",
177            Self::Enter => "im_enter_direct_msg",
178        }
179    }
180}
181
182pub fn run(args: ApiArgs) -> Result<(), String> {
183    let client = OpenApiClient::new()?;
184    let response = match args.command {
185        ApiCommand::ClientToken {
186            client_key,
187            client_secret,
188        } => client.client_token(&client_key, &client_secret)?,
189        ApiCommand::AuthorizeUrl {
190            client_key,
191            redirect_uri,
192            scope,
193            state,
194        } => {
195            println!(
196                "{}",
197                client.authorize_url(&client_key, &redirect_uri, &scope, state.as_deref())?
198            );
199            return Ok(());
200        }
201        ApiCommand::AccessToken {
202            client_key,
203            client_secret,
204            code,
205        } => client.access_token(&client_key, &client_secret, &code)?,
206        ApiCommand::RefreshToken {
207            client_key,
208            refresh_token,
209        } => client.refresh_token(&client_key, &refresh_token)?,
210        ApiCommand::RenewRefreshToken {
211            client_key,
212            refresh_token,
213        } => client.renew_refresh_token(&client_key, &refresh_token)?,
214        ApiCommand::Userinfo(auth) => {
215            let (token, open_id) = resolve_auth(auth)?;
216            client.request(RequestSpec {
217                method: "GET",
218                path: "/oauth/userinfo/",
219                token: Some(&token),
220                params: Some(HashMap::from([("open_id".to_owned(), open_id)])),
221                auth_required: true,
222                ..RequestSpec::default()
223            })?
224        }
225        ApiCommand::CommentList {
226            auth,
227            item_id,
228            cursor,
229            count,
230            sort_type,
231        } => {
232            let (token, open_id) = resolve_auth(auth)?;
233            let mut params = HashMap::from([
234                ("open_id".to_owned(), open_id),
235                ("item_id".to_owned(), item_id),
236                ("cursor".to_owned(), cursor.to_string()),
237                ("count".to_owned(), count.to_string()),
238            ]);
239            if let Some(sort_type) = sort_type {
240                params.insert("sort_type".to_owned(), sort_type.to_string());
241            }
242            client.request(RequestSpec {
243                method: "GET",
244                path: "/item/comment/list/",
245                token: Some(&token),
246                params: Some(params),
247                auth_required: true,
248                ..RequestSpec::default()
249            })?
250        }
251        ApiCommand::CommentReplies {
252            auth,
253            item_id,
254            comment_id,
255            cursor,
256            count,
257            sort_type,
258        } => {
259            let (token, open_id) = resolve_auth(auth)?;
260            let mut params = HashMap::from([
261                ("open_id".to_owned(), open_id),
262                ("item_id".to_owned(), item_id),
263                ("comment_id".to_owned(), comment_id),
264                ("cursor".to_owned(), cursor.to_string()),
265                ("count".to_owned(), count.to_string()),
266            ]);
267            if let Some(sort_type) = sort_type {
268                params.insert("sort_type".to_owned(), sort_type.to_string());
269            }
270            client.request(RequestSpec {
271                method: "GET",
272                path: "/item/comment/reply/list/",
273                token: Some(&token),
274                params: Some(params),
275                auth_required: true,
276                ..RequestSpec::default()
277            })?
278        }
279        ApiCommand::CommentReply {
280            auth,
281            item_id,
282            comment_id,
283            content,
284            yes,
285        } => {
286            let (token, open_id) = resolve_auth(auth)?;
287            validate_text(&content, "评论内容", 100, false)?;
288            confirm_write("将通过官方 OpenAPI 发送评论回复,是否继续?", yes)?;
289            let mut body = Map::from_iter([
290                ("item_id".to_owned(), json!(item_id)),
291                ("content".to_owned(), json!(content)),
292            ]);
293            if let Some(comment_id) = comment_id {
294                body.insert("comment_id".to_owned(), json!(comment_id));
295            }
296            client.request(RequestSpec {
297                method: "POST",
298                path: "/item/comment/reply/",
299                token: Some(&token),
300                params: Some(HashMap::from([("open_id".to_owned(), open_id)])),
301                json_body: Some(Value::Object(body)),
302                auth_required: true,
303                ..RequestSpec::default()
304            })?
305        }
306        ApiCommand::ImMessageSend {
307            auth,
308            to_user_id,
309            scene,
310            msg_id,
311            conversation_id,
312            message_type,
313            text,
314            media_id,
315            item_id,
316            yes,
317        } => {
318            let (token, open_id) = resolve_auth(auth)?;
319            let content = message_content(&message_type, text, media_id, item_id)?;
320            confirm_write("将通过官方 OpenAPI 发送私信消息,是否继续?", yes)?;
321            client.request(RequestSpec {
322                method: "POST",
323                path: "/im/send/msg/",
324                token: Some(&token),
325                params: Some(HashMap::from([("open_id".to_owned(), open_id)])),
326                json_body: Some(im_message_body(
327                    &to_user_id,
328                    scene.as_str(),
329                    &msg_id,
330                    &conversation_id,
331                    content,
332                )),
333                auth_required: true,
334                ..RequestSpec::default()
335            })?
336        }
337        ApiCommand::Request {
338            method,
339            path,
340            token,
341            params,
342            json_text,
343            forms,
344            headers,
345        } => {
346            let data = settings::load().map_err(err)?;
347            let saved = settings::openapi(&data);
348            let token = token.or_else(|| saved_string(&saved, "accessToken"));
349            client.request(RequestSpec {
350                method: &method,
351                path: &path,
352                token: token.as_deref(),
353                params: parse_key_values(params)?,
354                json_body: parse_json(json_text)?,
355                form: parse_key_values(forms)?,
356                headers: parse_key_values(headers)?,
357                auth_required: true,
358            })?
359        }
360    };
361    print_json(&response)
362}
363
364fn resolve_auth(options: AuthOptions) -> Result<(String, String), String> {
365    let data = settings::load().map_err(err)?;
366    let saved = settings::openapi(&data);
367    let token = options
368        .token
369        .or_else(|| saved_string(&saved, "accessToken"))
370        .ok_or_else(|| "缺少 access_token,请先运行 douyin auth login".to_owned())?;
371    let open_id = options
372        .open_id
373        .or_else(|| saved_string(&saved, "openId"))
374        .ok_or_else(|| "缺少 open_id,请先运行 douyin auth login".to_owned())?;
375    Ok((token, open_id))
376}
377
378fn saved_string(values: &Map<String, Value>, key: &str) -> Option<String> {
379    values
380        .get(key)
381        .and_then(Value::as_str)
382        .filter(|value| !value.is_empty())
383        .map(str::to_owned)
384}
385
386fn message_content(
387    message_type: &MessageType,
388    text: Option<String>,
389    media_id: Option<String>,
390    item_id: Option<String>,
391) -> Result<Value, String> {
392    let (code, kind, key, value, error) = match message_type {
393        MessageType::Text => (1, "text", "text", text, "message-type=text 需要 --text"),
394        MessageType::Image => (
395            2,
396            "image",
397            "media_id",
398            media_id,
399            "message-type=image 需要 --media-id",
400        ),
401        MessageType::Video => (
402            3,
403            "video",
404            "item_id",
405            item_id,
406            "message-type=video 需要 --item-id",
407        ),
408    };
409    let value = value
410        .filter(|value| !value.trim().is_empty())
411        .ok_or(error)?;
412    if matches!(message_type, MessageType::Text) {
413        validate_text(&value, "私信文本", 1_000, true)?;
414    }
415    let payload = Value::Object(Map::from_iter([(key.to_owned(), json!(value))]));
416    Ok(Value::Object(Map::from_iter([
417        ("msg_type".to_owned(), json!(code)),
418        (kind.to_owned(), payload),
419    ])))
420}
421
422fn validate_text(
423    value: &str,
424    name: &str,
425    max_chars: usize,
426    forbid_links: bool,
427) -> Result<(), String> {
428    let length = value.chars().count();
429    if length == 0 {
430        return Err(format!("{name}不能为空"));
431    }
432    if length > max_chars {
433        return Err(format!(
434            "{name}不能超过 {max_chars} 个字符(当前 {length})"
435        ));
436    }
437    if forbid_links && (value.contains("http://") || value.contains("https://")) {
438        return Err(format!("{name}不能包含链接"));
439    }
440    Ok(())
441}
442
443fn parse_key_values(values: Vec<String>) -> Result<Option<HashMap<String, String>>, String> {
444    if values.is_empty() {
445        return Ok(None);
446    }
447    values
448        .into_iter()
449        .map(|value| {
450            let (key, value) = value
451                .split_once('=')
452                .ok_or_else(|| format!("参数必须是 key=value 格式: {value}"))?;
453            if key.is_empty() {
454                return Err(format!("参数 key 不能为空: ={value}"));
455            }
456            Ok((key.to_owned(), value.to_owned()))
457        })
458        .collect::<Result<HashMap<_, _>, _>>()
459        .map(Some)
460}
461
462fn parse_json(text: Option<String>) -> Result<Option<Value>, String> {
463    let Some(text) = text else {
464        return Ok(None);
465    };
466    let value: Value =
467        serde_json::from_str(&text).map_err(|error| format!("--json 不是合法 JSON: {error}"))?;
468    if !value.is_object() && !value.is_array() {
469        return Err("--json 必须是 JSON object 或 array".to_owned());
470    }
471    Ok(Some(value))
472}
473
474fn confirm_write(prompt: &str, yes: bool) -> Result<(), String> {
475    if yes {
476        return Ok(());
477    }
478    print!("{prompt} [y/N]: ");
479    io::stdout().flush().map_err(err)?;
480    let mut answer = String::new();
481    io::stdin().read_line(&mut answer).map_err(err)?;
482    if matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes") {
483        Ok(())
484    } else {
485        Err("操作已取消".to_owned())
486    }
487}
488
489fn print_json(value: &Value) -> Result<(), String> {
490    println!("{}", serde_json::to_string_pretty(value).map_err(err)?);
491    Ok(())
492}
493
494#[cfg(test)]
495mod tests {
496    use super::{message_content, parse_json, parse_key_values, validate_text, MessageType};
497    use crate::test_support::{must, present};
498    use serde_json::json;
499
500    #[test]
501    fn text_message_requires_text_and_uses_current_content_shape() {
502        assert_eq!(
503            message_content(&MessageType::Text, None, None, None).unwrap_err(),
504            "message-type=text 需要 --text"
505        );
506        assert_eq!(
507            must(message_content(
508                &MessageType::Text,
509                Some("你好".to_owned()),
510                None,
511                None
512            )),
513            json!({"msg_type": 1, "text": {"text": "你好"}})
514        );
515        assert!(message_content(
516            &MessageType::Text,
517            Some("https://example.com".to_owned()),
518            None,
519            None
520        )
521        .is_err());
522        assert!(validate_text(&"字".repeat(101), "评论内容", 100, false).is_err());
523    }
524
525    #[test]
526    fn generic_request_parsers_reject_invalid_values() {
527        assert!(parse_key_values(vec!["invalid".to_owned()]).is_err());
528        assert!(parse_json(Some("1".to_owned())).is_err());
529        let values = present(must(parse_key_values(vec!["open_id=value".to_owned()])));
530        assert_eq!(values["open_id"], "value");
531    }
532}