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 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 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 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 RefreshToken {
48 #[arg(long, env = "DOUYIN_CLIENT_KEY")]
49 client_key: String,
50 #[arg(long)]
51 refresh_token: String,
52 },
53 RenewRefreshToken {
55 #[arg(long, env = "DOUYIN_CLIENT_KEY")]
56 client_key: String,
57 #[arg(long)]
58 refresh_token: String,
59 },
60 Userinfo(AuthOptions),
62 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 #[arg(long, value_parser = clap::value_parser!(u8).range(0..=2))]
74 sort_type: Option<u8>,
75 },
76 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 #[arg(long, value_parser = clap::value_parser!(u8).range(0..=2))]
90 sort_type: Option<u8>,
91 },
92 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 ImMessageSend {
107 #[command(flatten)]
108 auth: AuthOptions,
109 #[arg(long)]
110 to_user_id: String,
111 #[arg(long, value_enum, default_value_t = ImScene::Reply)]
113 scene: ImScene,
114 #[arg(long)]
116 msg_id: String,
117 #[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 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 #[arg(long, env = "DOUYIN_ACCESS_TOKEN")]
152 token: Option<String>,
153 #[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}