helix-im 0.1.22

基于 Helix Core 的确定性 MessageV3 IM 业务模块
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
//! posts 读族 outbound 命令(P3a,parallelGroup=G1)。
//!
//! 纯**读** request/response:helix-im 只 build 正确 wire body。读族无 WS 回声、HTTP 响应体即数据
//! → `is_read=true`(spec06 缺陷A),dispatch 注册 `OutboundReadReply` 把响应透传回灌前端(read_relay)。
//! 与 P3b 真零交集:独立文件 + inventory 注册,不碰 channel*/registry.rs(仅 mod.rs append)。
//! ## endpoint / body 真源(mattermost csesapi 逐字,不自造 fixture,C5)
//! - posts/* 子路由 = `api/cses/posts`(api.go:169);post/* = `api/cses/post`(api.go:171);
//!   post/bookmark/* = `api/cses/post/bookmark`(api.go:177)。
//! - 命名陷阱(decode 静默失败防线):top20 → **snake_case** `channel_id`(posts.go:550);
//!   getPostsAfterIndex → `postIds` **单数 string**(posts.go:318,非数组);
//!   getUpdatedPosts → `timeStamp` **大写 S**(posts.go:体 CursorTs tag);时间 `createAt`。

use serde_json::{json, Value};
use std::collections::HashSet;

use crate::error::ImError;

use crate::outbound::registry::{require_str, OutboundCommand, OutboundRegistration};

// ── 本文件局部 helper(边界零信任:缺/类型错 → Err,不 panic)──────────────────────

/// 取必填字符串数组(非空、元素全 string)。供 `{postIds:[]}` 类命令复用(含 posts_read_ext)。
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}(非空字符串数组)")))
}

/// 解析 G11g exact-by-id 请求;空数组合法,并按首次出现顺序去重保证幂等。
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)
}

/// G11h initial-window 请求的已校验最小参数;显式 initial 或带关联键的最小查询均构造。
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct InitialWindowRequest {
    pub(crate) post_id: String,
    pub(crate) req_id: String,
    pub(crate) page_size: u32,
}

/// 解析 G11h initial-window 参数,拒绝缺 correlation、非法页长和伪造 cursor。
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,
    }))
}

/// 把入参 `in_key`(snake_case)原样透传成 wire 字段 `wire_key`(camelCase),仅当存在且非 null。
/// 不强转类型——边界零信任只校验「存在」,数值/字符串原值由前端 bridge 保证(对齐 vote_score::carry_optional)。
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());
        }
    }
}

/// 注册读命令样板:`name` + `build` 闭包 → struct/static/slice 三件套(`is_read=true` 全读族)。
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)
            }
            // 全读族(spec06 缺陷A):无 WS 回声、HTTP 响应体即数据 → dispatch 注册回灌。
            fn is_read(&self) -> bool {
                true
            }
        }
        // 单元 struct 值(`$cmd_struct` 同名构造子)经 const promotion 直接做 &'static dyn 注册
        // 目标——无需中间 static(避免与类型同名在 value 命名空间冲突 E0428)。
        inventory::submit! {
            OutboundRegistration {
                name: $name,
                command: &$cmd_struct,
            }
        }
    };
}

// ── posts/* 读族 ────────────────────────────────────────────────────────────────

// #1 getSchedule:查询当前 session owner 的定时消息。Go 从 session 派生 owner,客户端只发 channelId;
// 旧 user_id 可留在输入对象中兼容历史调用,但不得进入 wire。
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 })))
    }
);

/// MV3-G04d:把 `posts/getSchedule` 的服务端响应体归一为**稳定排序的绝对 `ScheduleProjection[]`**。
///
/// 权威参考包 `gates/schedule/mv3-g04d/outbound-sample.json` 的 `command_results[0].rust`:
/// `{"ok": true, "schedules": [{id, channelId, executeAt, status, post:{type, message, viewers}}]}`。
///
/// ## 排序(合同 `outbound-contract.json`:「查询结果按 executeAt 稳定排序」)
///
/// 主键 `executeAt` 升序;`executeAt` 相同再按 schedule id 升序 —— 服务端返回顺序不稳定时
/// 也保证同一份数据永远产出同一个数组(重复到达 → Angular 内存与 DOM 不变,合同 §5 幂等)。
///
/// ## 公开结果
///
/// Phase 1 合同决议固定 Angular `ScheduleProjection` 的唯一键集:`scheduleId`、
/// `post.text`、`post.channelId`。旧 `id` / `post.message` 不再双写,避免 WDIO 和业务调用方
/// 在两套等价值之间产生不稳定 fixture。
///
/// ## 边界零信任
///
/// 无待发对象(`null` / `[]` / 缺字段)→ 空数组,**绝不抛错**(合同:读取失败不得拖垮切会话)。
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();
    // Rust 的 sort_by 是稳定排序:同 (executeAt, id) 的元素保持服务端原序。
    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())
}

/// 从任意合法响应外壳里取出待发对象列表(`{data:[]}` / `{data:{}}` / `[]` / `{...}`
/// / `{data:{schedules:[]}}`)。
///
/// 最后一种是 Go 常见的双层信封(业务 `{status,data}` 外壳里再包一层带列表键的 payload)。
/// 不识别它的话,`{data:{schedules:[…]}}` 会被当成**一条**缺 `executeAt` 的脏对象整条丢掉 ——
/// 表现为「定时设上了但切回会话提示条永远不出现」,用户以为没设上而重复设定,到点多发
/// (contract.json `openDecisions.impact` 描述的正是这个故障)。
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))
}

/// 单条待发对象 → 绝对投影;缺 `executeAt` 锚点的脏数据直接丢弃(不进不稳定排序)。
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"])),
        "mentions": item.get("mentions").filter(|value| value.is_array()).cloned().unwrap_or_else(|| json!([])),
        "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))
}

// #2 postContext:取某 post 上下文(上拉锚点)。真源 {postId, before:int}(posts.go:564)。
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 }),
        ))
    }
);

// #3 top20:首屏 20 条。**命名陷阱**:body 是 snake_case `channel_id`(posts.go:550 唯一 snake 读端)。
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 })))
});

// #4 get:批量按 id 取 post。真源 {postIds:*[]string}(posts.go:535,复数数组)。
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 })))
    }
);

// #5 getPostsAfterIndex:锚 id 往后翻页。**命名陷阱**:body key `postIds` 值是**单 id string** 非数组(posts.go:318 `PostId string json:"postIds"`)。
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 })))
    }
);

// #6 getReplies:取某 reply 根的回复。真源 {replyId, pageNumber, pageSize, revoke?}
// (GetPostOpts 嵌 entity.PageOpts{pageNumber,pageSize},posts.go:280)。
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)?,
        ))
    }
);

// #7 getReplyBranch:取某一级回复的分支。真源 {replyFirstLevelId, pageNumber, pageSize}(posts.go:261)。
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)?,
        ))
    }
);

// #8 queryTodoList:批量取待办状态。真源 {postIds:[]string} 非空(posts.go:694)。
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 })))
    }
);

// #9 getUpdatedPosts:游标拉被更新的 post。**命名陷阱**:`timeStamp` 大写 S(posts.go CursorTs tag)。
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 }),
        ))
    }
);

// #10 getLatestPost:拉某 channel 最新首屏(too_long 重拉链)。真源 {channelId, timestamp}(posts.go:201)。
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))
    }
);

/// 组装回复分页 body,并在产生 HTTP effect 前拒绝非法显式页值。
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)
}

/// 解析回复独立页长:缺省 20,显式值继续受时间线共享上限 60 约束。
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}")))
}

/// 解析缺省为 1、显式必须为正整数的页码,避免把坏值静默改成另一页。
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);
    }

    /// G11g 空请求是合法零结果;重复 id 只保留首次出现以保证重放幂等。
    #[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"]
        );
    }

    /// G11h 使用独立的 singular postId/reqId/pageSize wire,不能退回 legacy postIds。
    #[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);
    }
}

// ── post/bookmark/* / announcement/* / post/read/list 读族 → posts_read_ext.rs ──────