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 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 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 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 RefreshToken {
47 #[arg(long, env = "DOUYIN_CLIENT_KEY")]
48 client_key: String,
49 #[arg(long)]
50 refresh_token: String,
51 },
52 RenewRefreshToken {
54 #[arg(long, env = "DOUYIN_CLIENT_KEY")]
55 client_key: String,
56 #[arg(long)]
57 refresh_token: String,
58 },
59 Userinfo(AuthOptions),
61 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 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 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 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 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 #[arg(long, env = "DOUYIN_ACCESS_TOKEN")]
142 token: Option<String>,
143 #[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}