use serde_json::{json, Value};
use std::collections::HashSet;
use crate::error::ImError;
use crate::outbound::registry::{require_str, OutboundCommand, OutboundRegistration};
pub(super) fn require_str_array(args: &Value, key: &str, cmd: &str) -> Result<Value, ImError> {
args.get(key)
.and_then(Value::as_array)
.filter(|a| !a.is_empty() && a.iter().all(Value::is_string))
.cloned()
.map(Value::Array)
.ok_or_else(|| ImError::Parse(format!("{cmd}: 缺/空 {key}(非空字符串数组)")))
}
pub(crate) fn exact_post_ids(args: &Value, cmd: &str) -> Result<Vec<String>, ImError> {
let values = args
.get("post_ids")
.and_then(Value::as_array)
.ok_or_else(|| ImError::Parse(format!("{cmd}: 缺 post_ids(字符串数组)")))?;
let mut seen = HashSet::new();
let mut ids = Vec::with_capacity(values.len());
for value in values {
let id = value
.as_str()
.filter(|id| !id.is_empty())
.ok_or_else(|| ImError::Parse(format!("{cmd}: post_ids 必须为非空字符串数组")))?;
if seen.insert(id.to_string()) {
ids.push(id.to_string());
}
}
Ok(ids)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct InitialWindowRequest {
pub(crate) post_id: String,
pub(crate) req_id: String,
pub(crate) page_size: u32,
}
pub(crate) fn initial_window_request(
args: &Value,
cmd: &str,
) -> Result<Option<InitialWindowRequest>, ImError> {
let direction = args.get("direction").and_then(Value::as_str);
let explicit_initial =
direction.is_some_and(|direction| direction.eq_ignore_ascii_case("initial"));
let implicit_initial = direction.is_none_or(str::is_empty)
&& args
.get("req_id")
.and_then(Value::as_str)
.is_some_and(|req_id| !req_id.is_empty());
if !explicit_initial && !implicit_initial {
return Ok(None);
}
if args
.get("cursor")
.or_else(|| args.get("cursor_version"))
.or_else(|| args.get("cursorVersion"))
.is_some_and(|value| !value.is_null())
{
return Err(ImError::Parse(format!(
"{cmd}: initial window must not carry cursor"
)));
}
let post_id = require_str(args, "post_id", cmd)?.to_string();
let req_id = require_str(args, "req_id", cmd)?.to_string();
let page_size = crate::timeline_state::TimelinePageSize::parse(
args.get("page_size").or_else(|| args.get("limit")),
)
.map_err(|error| ImError::Parse(format!("{cmd}: initial pageSize: {error}")))?
.get();
Ok(Some(InitialWindowRequest {
post_id,
req_id,
page_size,
}))
}
pub(super) fn carry_into(
body: &mut serde_json::Map<String, Value>,
args: &Value,
in_key: &str,
wire_key: &str,
) {
if let Some(v) = args.get(in_key) {
if !v.is_null() {
body.insert(wire_key.to_string(), v.clone());
}
}
}
macro_rules! read_cmd {
($cmd_struct:ident, $reg:ident, $name:literal, $build:expr) => {
struct $cmd_struct;
impl OutboundCommand for $cmd_struct {
fn name(&self) -> &'static str {
$name
}
fn build(&self, args: &Value) -> Result<(&'static str, Value), ImError> {
let f: fn(&Value, &'static str) -> Result<(&'static str, Value), ImError> = $build;
f(args, $name)
}
fn is_read(&self) -> bool {
true
}
}
inventory::submit! {
OutboundRegistration {
name: $name,
command: &$cmd_struct,
}
}
};
}
read_cmd!(
GetScheduleCommand,
GET_SCHEDULE_REG,
"im_get_schedule",
|args, cmd| {
let channel_id = require_str(args, "channel_id", cmd)?;
Ok(("posts/getSchedule", json!({ "channelId": channel_id })))
}
);
pub fn schedule_projections(channel_id: &str, body: &Value) -> Value {
let mut items: Vec<(i64, String, Value)> = schedule_items(body)
.iter()
.filter_map(|item| schedule_projection(channel_id, item))
.collect();
items.sort_by(|left, right| left.0.cmp(&right.0).then_with(|| left.1.cmp(&right.1)));
Value::Array(items.into_iter().map(|(_, _, item)| item).collect())
}
fn schedule_items(body: &Value) -> Vec<Value> {
let payload = body
.get("data")
.or_else(|| body.get("schedules"))
.unwrap_or(body);
match payload {
Value::Array(items) => items.clone(),
Value::Object(item) => match nested_list(item) {
Some(items) => items.clone(),
None if !item.is_empty() => vec![Value::Object(item.clone())],
None => Vec::new(),
},
_ => Vec::new(),
}
}
fn nested_list(item: &serde_json::Map<String, Value>) -> Option<&Vec<Value>> {
["schedules", "list", "items", "records"]
.iter()
.find_map(|key| item.get(*key).and_then(Value::as_array))
}
fn schedule_projection(channel_id: &str, item: &Value) -> Option<(i64, String, Value)> {
let execute_at = first_i64(
item,
&[
"executeAt",
"execute_at",
"schedulePostAt",
"schedule_post_at",
"sendAt",
"send_at",
],
)?;
let schedule_id = first_str(item, &["scheduleId", "schedule_id", "id"])
.unwrap_or_default()
.to_string();
let channel = first_str(item, &["channelId", "channel_id"])
.unwrap_or(channel_id)
.to_string();
let status = first_str(item, &["status"])
.filter(|status| !status.is_empty())
.unwrap_or("scheduled")
.to_string();
let message = first_str(item, &["message", "text"]).unwrap_or_default();
let post = json!({
"channelId": channel,
"type": first_str(item, &["type", "msgType", "msg_type"]).unwrap_or("TEXT"),
"text": message,
"viewers": item
.get("viewers")
.filter(|viewers| viewers.is_array())
.cloned()
.unwrap_or_else(|| json!(["all"])),
"props": item
.get("props")
.filter(|props| props.is_object())
.cloned()
.unwrap_or_else(|| json!({})),
});
let projection = json!({
"scheduleId": schedule_id,
"channelId": channel,
"executeAt": execute_at,
"status": status,
"post": post,
});
Some((execute_at, schedule_id, projection))
}
fn first_str<'a>(item: &'a Value, keys: &[&str]) -> Option<&'a str> {
keys.iter()
.find_map(|key| item.get(*key).and_then(Value::as_str))
}
fn first_i64(item: &Value, keys: &[&str]) -> Option<i64> {
keys.iter()
.find_map(|key| item.get(*key).and_then(Value::as_i64))
}
read_cmd!(
PostContextCommand,
POST_CONTEXT_REG,
"im_post_context",
|args, cmd| {
let post_id = require_str(args, "post_id", cmd)?;
let before = args.get("before").and_then(Value::as_i64).unwrap_or(0);
Ok((
"posts/postContext",
json!({ "postId": post_id, "before": before }),
))
}
);
read_cmd!(Top20Command, TOP20_REG, "im_top20", |args, cmd| {
let channel_id = require_str(args, "channel_id", cmd)?;
Ok(("posts/top20", json!({ "channel_id": channel_id })))
});
read_cmd!(
GetPostsCommand,
GET_POSTS_REG,
"im_get_posts",
|args, cmd| {
let post_ids = exact_post_ids(args, cmd)?;
Ok(("posts/get", json!({ "postIds": post_ids })))
}
);
read_cmd!(
GetPostsAfterIndexCommand,
GET_POSTS_AFTER_INDEX_REG,
"im_get_posts_after_index",
|args, cmd| {
let post_id = require_str(args, "post_id", cmd)?;
if let Some(initial) = initial_window_request(args, cmd)? {
return Ok((
"posts/getPostsAfterIndex",
json!({
"postId": initial.post_id,
"direction": "initial",
"pageSize": initial.page_size,
"reqId": initial.req_id,
}),
));
}
Ok(("posts/getPostsAfterIndex", json!({ "postIds": post_id })))
}
);
read_cmd!(
GetRepliesCommand,
GET_REPLIES_REG,
"im_get_replies",
|args, cmd| {
let reply_id = require_str(args, "reply_id", cmd)?;
Ok((
"posts/getReplies",
page_body(args, "replyId", reply_id, cmd)?,
))
}
);
read_cmd!(
GetReplyBranchCommand,
GET_REPLY_BRANCH_REG,
"im_get_reply_branch",
|args, cmd| {
let id = require_str(args, "reply_first_level_id", cmd)?;
Ok((
"posts/getReplyBranch",
page_body(args, "replyFirstLevelId", id, cmd)?,
))
}
);
read_cmd!(
QueryTodoListCommand,
QUERY_TODO_REG,
"im_query_todo_list",
|args, cmd| {
let post_ids = require_str_array(args, "post_ids", cmd)?;
Ok(("posts/queryTodoList", json!({ "postIds": post_ids })))
}
);
read_cmd!(
GetUpdatedPostsCommand,
GET_UPDATED_POSTS_REG,
"im_get_updated_posts",
|args, cmd| {
let ts = args
.get("time_stamp")
.and_then(Value::as_i64)
.ok_or_else(|| ImError::Parse(format!("{cmd}: 缺/坏 time_stamp(int64 毫秒)")))?;
let limit = args.get("limit").and_then(Value::as_i64).unwrap_or(0);
Ok((
"posts/getUpdatedPosts",
json!({ "timeStamp": ts, "limit": limit }),
))
}
);
read_cmd!(
GetLatestPostCommand,
GET_LATEST_POST_REG,
"im_get_latest_post",
|args, cmd| {
let channel_id = require_str(args, "channel_id", cmd)?;
let timestamp = args.get("timestamp").and_then(Value::as_i64).unwrap_or(0);
let mut body = json!({ "channelId": channel_id, "timestamp": timestamp });
if let Some(cursor_version) = args.get("cursor_version").and_then(Value::as_u64) {
body["cursorVersion"] = Value::from(cursor_version);
}
if let Some(page_size) = args.get("page_size").and_then(Value::as_u64) {
body["pageSize"] = Value::from(page_size);
}
Ok(("posts/getLatestPost", body))
}
);
fn page_body(
args: &Value,
id_key: &'static str,
id_val: &str,
cmd: &str,
) -> Result<Value, ImError> {
let page_number = positive_page_number(args.get("page_number"), cmd)?;
let page_size = reply_page_size(args.get("page_size"), cmd)?;
let mut b = json!({ id_key: id_val, "pageNumber": page_number, "pageSize": page_size });
if let Some(revoke) = args.get("revoke").and_then(Value::as_bool) {
b["revoke"] = json!(revoke);
}
Ok(b)
}
fn reply_page_size(value: Option<&Value>, cmd: &str) -> Result<u32, ImError> {
let Some(value) = value else {
return Ok(20);
};
let raw = value
.as_u64()
.ok_or_else(|| ImError::Parse(format!("{cmd}: pageSize 必须是正整数")))?;
let raw =
u32::try_from(raw).map_err(|_| ImError::Parse(format!("{cmd}: pageSize 超出范围")))?;
crate::timeline_state::TimelinePageSize::new(raw)
.map(crate::timeline_state::TimelinePageSize::get)
.map_err(|error| ImError::Parse(format!("{cmd}: pageSize: {error}")))
}
fn positive_page_number(value: Option<&Value>, cmd: &str) -> Result<u32, ImError> {
let Some(value) = value else {
return Ok(1);
};
let raw = value
.as_u64()
.ok_or_else(|| ImError::Parse(format!("{cmd}: pageNumber 必须是正整数")))?;
let page =
u32::try_from(raw).map_err(|_| ImError::Parse(format!("{cmd}: pageNumber 超出范围")))?;
if page == 0 {
return Err(ImError::Parse(format!("{cmd}: pageNumber 必须是正整数")));
}
Ok(page)
}
#[cfg(test)]
mod tests {
use super::{exact_post_ids, initial_window_request, page_body};
use serde_json::json;
#[test]
fn reply_page_defaults_match_go_page_opts() {
let body = page_body(&json!({}), "replyFirstLevelId", "first-1", "test").unwrap();
assert_eq!(body["replyFirstLevelId"], "first-1");
assert_eq!(body["pageNumber"], 1);
assert_eq!(body["pageSize"], 20);
}
#[test]
fn reply_page_rejects_non_positive_values_and_keeps_positive_values() {
let defaults = page_body(
&json!({"page_number": 0, "page_size": -1}),
"replyId",
"root-1",
"test",
);
assert!(defaults.is_err());
let explicit = page_body(
&json!({"page_number": 2, "page_size": 50}),
"replyId",
"root-1",
"test",
)
.unwrap();
assert_eq!(explicit["pageNumber"], 2);
assert_eq!(explicit["pageSize"], 50);
}
#[test]
fn exact_post_ids_accepts_empty_and_deduplicates() {
assert_eq!(
exact_post_ids(&json!({"post_ids": []}), "im_get_posts").unwrap(),
Vec::<String>::new()
);
assert_eq!(
exact_post_ids(&json!({"post_ids": ["p1", "p1", "p2"]}), "im_get_posts").unwrap(),
vec!["p1", "p2"]
);
}
#[test]
fn initial_window_request_requires_correlation_and_valid_page_size() {
let request = initial_window_request(
&json!({
"direction": "initial",
"post_id": "post-1",
"req_id": "req-1",
"page_size": 60
}),
"im_get_posts_after_index",
)
.unwrap()
.unwrap();
assert_eq!(request.post_id, "post-1");
assert_eq!(request.req_id, "req-1");
assert_eq!(request.page_size, 60);
assert!(initial_window_request(
&json!({
"direction": "initial",
"post_id": "post-1",
"req_id": "req-1",
"page_size": 61
}),
"im_get_posts_after_index"
)
.is_err());
let implicit = initial_window_request(
&json!({
"post_id": "post-1",
"req_id": "req-1",
"limit": 20
}),
"im_get_posts_after_index",
)
.unwrap()
.unwrap();
assert_eq!(implicit.page_size, 20);
}
}