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