@geoly-ai/social-hub-cli 0.3.17 → 0.3.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/cmd-manifest.json +5 -2
  3. package/dist/cmd-manifest.test.js +3 -0
  4. package/dist/cmd-manifest.test.js.map +1 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +5 -2
  7. package/dist/index.js.map +1 -1
  8. package/dist/permissions-gates-admin.d.ts.map +1 -1
  9. package/dist/permissions-gates-admin.js +12 -0
  10. package/dist/permissions-gates-admin.js.map +1 -1
  11. package/dist/register-extensions.d.ts.map +1 -1
  12. package/dist/register-extensions.js +15 -1
  13. package/dist/register-extensions.js.map +1 -1
  14. package/dist/register-feishu-identities.d.ts +47 -0
  15. package/dist/register-feishu-identities.d.ts.map +1 -0
  16. package/dist/register-feishu-identities.js +346 -0
  17. package/dist/register-feishu-identities.js.map +1 -0
  18. package/dist/register-feishu-identities.test.d.ts +2 -0
  19. package/dist/register-feishu-identities.test.d.ts.map +1 -0
  20. package/dist/register-feishu-identities.test.js +124 -0
  21. package/dist/register-feishu-identities.test.js.map +1 -0
  22. package/package.json +3 -3
  23. package/skills/manifest.json +1 -1
  24. package/skills/reddit-matrix.lock.json +1 -1
  25. package/skills/reddit-voc-volume/SKILL.md +2 -7
  26. package/skills/reddit-voc-volume/matrix-contract.md +4 -3
  27. package/skills/reddit-voc-volume/references/arctic-shift.md +2 -1
  28. package/skills/reddit-voc-volume/scripts/reddit_voc_volume.py +157 -69
  29. package/skills/reddit-voc-volume/tests/test_archived_comments.py +104 -0
  30. package/skills/social-hub-admin/SKILL.md +47 -1
  31. package/skills/social-hub-cli/SKILL.md +1 -0
  32. package/skills/social-hub-posts/SKILL.md +14 -1
@@ -31,11 +31,16 @@ class ArchivedCommentTests(unittest.TestCase):
31
31
  )
32
32
  self.assertEqual(voc.normalize_reddit_post_id("t3_ABC123"), "abc123")
33
33
  self.assertEqual(voc.normalize_reddit_post_id("https://redd.it/AbC123"), "abc123")
34
+ self.assertEqual(voc.normalize_reddit_post_id("/r/example/comments/AbC123/slug"), "abc123")
34
35
  self.assertEqual(voc.normalize_reddit_comment_id("t1_DeF456"), "def456")
35
36
  with self.assertRaisesRegex(ValueError, "comment ID"):
36
37
  voc.normalize_reddit_post_id("t1_def456")
37
38
  with self.assertRaisesRegex(ValueError, "unsupported post URL host"):
38
39
  voc.normalize_reddit_post_id("https://example.com/comments/abc123")
40
+ with self.assertRaisesRegex(ValueError, "malformed Reddit post ID"):
41
+ voc.normalize_reddit_post_id("example.com/comments/abc123")
42
+ with self.assertRaisesRegex(ValueError, "malformed Reddit post ID"):
43
+ voc.normalize_reddit_post_id("https://redd.it/abc123/extra")
39
44
  with self.assertRaisesRegex(ValueError, "maximum 500"):
40
45
  voc.parse_reddit_comment_ids([f"c{index:x}" for index in range(501)])
41
46
 
@@ -101,10 +106,70 @@ class ArchivedCommentTests(unittest.TestCase):
101
106
  self.assertEqual(out["items"][1]["tree_path"], [0, 0])
102
107
  self.assertEqual(out["items"][1]["body"], "Child comment")
103
108
  self.assertFalse(out["items"][1]["body_truncated"])
109
+ self.assertTrue(
110
+ {
111
+ "body",
112
+ "id",
113
+ "link_id",
114
+ "parent_id",
115
+ "author",
116
+ "created_utc",
117
+ "score",
118
+ "subreddit",
119
+ }.issubset(out["items"][1])
120
+ )
104
121
  self.assertFalse(out["tree_complete"])
105
122
  self.assertEqual(out["collapsed"][0]["children"], ["hidden1", "hidden2"])
106
123
  self.assertFalse(out["archive_caveats"]["score_realtime"])
107
124
 
125
+ def test_exact_tree_limit_is_not_reported_complete_without_more_node(self) -> None:
126
+ payload = {
127
+ "data": [
128
+ {
129
+ "kind": "t1",
130
+ "data": {
131
+ "id": "only1",
132
+ "link_id": "t3_post1",
133
+ "parent_id": "t3_post1",
134
+ "body": "At the requested cap",
135
+ "replies": "",
136
+ },
137
+ }
138
+ ]
139
+ }
140
+ with mock.patch.object(voc, "arctic_get", return_value=payload):
141
+ code, out = self.run_main("comments", "post1", "--limit", "1")
142
+
143
+ self.assertEqual(code, 0)
144
+ self.assertFalse(out["tree_complete"])
145
+ self.assertTrue(out["tree_limit_reached"])
146
+ self.assertTrue(out["rate_limit"]["truncated"])
147
+ self.assertEqual(out["collapsed"], [])
148
+
149
+ def test_tree_flattener_handles_depth_beyond_python_recursion_limit(self) -> None:
150
+ node = {
151
+ "kind": "t1",
152
+ "data": {"id": "c4ff", "body": "leaf", "replies": ""},
153
+ }
154
+ for index in reversed(range(1_100)):
155
+ node = {
156
+ "kind": "t1",
157
+ "data": {
158
+ "id": f"c{index:x}",
159
+ "body": "nested",
160
+ "replies": {"data": {"children": [node]}},
161
+ },
162
+ }
163
+ node["data"]["replies"]["data"]["children"][0]["data"]["parent_id"] = (
164
+ f"t1_c{index:x}"
165
+ )
166
+
167
+ items, collapsed = voc.flatten_arctic_comment_tree({"data": [node]})
168
+ self.assertEqual(len(items), 1_101)
169
+ self.assertEqual(items[-1]["depth"], 1_100)
170
+ self.assertEqual(items[-1]["tree_order"], 1_100)
171
+ self.assertEqual(collapsed, [])
172
+
108
173
  def test_search_mode_forwards_supported_filters(self) -> None:
109
174
  with mock.patch.object(voc, "arctic_get", return_value={"data": []}) as get:
110
175
  code, out = self.run_main(
@@ -168,6 +233,42 @@ class ArchivedCommentTests(unittest.TestCase):
168
233
  self.assertFalse(out["tree_complete"])
169
234
  self.assertIn("arctic_comments_tree_malformed_payload", out["errors"][0])
170
235
 
236
+ malformed_more = {
237
+ "data": [
238
+ {
239
+ "kind": "more",
240
+ "data": {"children": "not-an-array", "parent_id": "t3_post1"},
241
+ }
242
+ ]
243
+ }
244
+ with mock.patch.object(voc, "arctic_get", return_value=malformed_more):
245
+ code, out = self.run_main("comments", "post1")
246
+ self.assertEqual(code, 1)
247
+ self.assertFalse(out["tree_complete"])
248
+ self.assertIn("more.children must be an array", out["errors"][0])
249
+
250
+ def test_search_and_ids_reject_malformed_upstream_rows(self) -> None:
251
+ for argv, payload, expected in (
252
+ (("comments", "post1", "--source", "search"), {"data": {}}, "expected data[]"),
253
+ (("comments", "--ids", "one1"), {"data": ["bad-row"]}, "object rows"),
254
+ (("comments", "--ids", "one1"), {"data": [{"id": ""}]}, "malformed Reddit comment ID"),
255
+ (
256
+ ("comments", "post1", "--source", "search"),
257
+ {"data": [{"id": "one1", "link_id": "t3_other", "body": "wrong post"}]},
258
+ "link_id does not match",
259
+ ),
260
+ (
261
+ ("comments", "--ids", "one1"),
262
+ {"data": [{"id": "one1", "body": {"unexpected": "object"}}]},
263
+ "body must be a string or null",
264
+ ),
265
+ ):
266
+ with self.subTest(argv=argv, payload=payload):
267
+ with mock.patch.object(voc, "arctic_get", return_value=payload):
268
+ code, out = self.run_main(*argv)
269
+ self.assertEqual(code, 1)
270
+ self.assertIn(expected, out["errors"][0])
271
+
171
272
  def test_malformed_id_and_out_of_range_limits_fail_before_network(self) -> None:
172
273
  with mock.patch.object(voc, "arctic_get") as get, contextlib.redirect_stderr(io.StringIO()):
173
274
  with mock.patch.object(sys, "argv", [str(SCRIPT), "comments", "--ids", "t3_wrongkind"]):
@@ -179,6 +280,9 @@ class ArchivedCommentTests(unittest.TestCase):
179
280
  with mock.patch.object(sys, "argv", [str(SCRIPT), "comments", "post1", "--parent-id", "top"]):
180
281
  with self.assertRaisesRegex(SystemExit, "2"):
181
282
  voc.main()
283
+ with mock.patch.object(sys, "argv", [str(SCRIPT), "comments", "x" * 33]):
284
+ with self.assertRaisesRegex(SystemExit, "2"):
285
+ voc.main()
182
286
  with mock.patch.object(sys, "argv", [str(SCRIPT), "comments", "--ids", "comment1", "--source", "search"]):
183
287
  with self.assertRaisesRegex(SystemExit, "2"):
184
288
  voc.main()
@@ -8,8 +8,10 @@ description: >-
8
8
  或管理品牌注册表、persona×brand 适配关系、trigger/avoid/rule_signal 注册表、品牌提及授权时用本 skill;
9
9
  部分既有高风险 admin 命令(settings/api-keys/members 等)需 --apply(品牌治理新命令无 --apply 选项)。
10
10
  API Key 创建/轮换是管理员资源,不是 CLI 日常登录方式。
11
+ 另含 feishu-identities(Hub 用户 ↔ 飞书 open_id 映射的运维同步面,决定告警 @ 谁)——
12
+ 用户说「同步飞书身份」「谁还没映射上飞书」「告警 @ 不到人」时用本 skill。
11
13
  metadata:
12
- cliVersion: ">=0.3.9"
14
+ cliVersion: ">=0.3.18"
13
15
  ---
14
16
 
15
17
  # social-hub-admin
@@ -174,6 +176,50 @@ social-hub notification-channels test -t <team-id> --channel <uuid>
174
176
 
175
177
  `provider: "feishu"` 是 CLI 支持的 provider 枚举,不是飞书数据同步流程。
176
178
 
179
+ ## 飞书身份映射(feishu-identities,运维同步面)
180
+
181
+ 决定**告警卡片会 @ 到谁**:Hub 用户 ↔ 飞书 `open_id`。之前唯一的写入方式是
182
+ `scripts/sync-feishu-identities.mjs`,那个脚本要求运维手里有生产 `DATABASE_URL`;
183
+ 这组命令把同一套语义搬到鉴权后面,只需要一次 `social-hub auth login`。
184
+
185
+ ```bash
186
+ social-hub feishu-identities list
187
+ social-hub feishu-identities list --status unmapped # 谁还没映射上
188
+ social-hub feishu-identities list --status lookup_failed # 谁查失败了(不是"不在通讯录")
189
+
190
+ # 本机 lark-cli 查 open_id → PUT 给 Hub 落库
191
+ social-hub feishu-identities sync --app-id cli_xxx --dry-run
192
+ social-hub feishu-identities sync --app-id cli_xxx --apply
193
+ ```
194
+
195
+ **为什么查询在本机而不是服务端**:飞书 `contact` 查询目前只能用个人授权(bot 缺
196
+ `contact:user.id:readonly`),服务端既没有应用凭据也拿不到这个身份。让 Hub 代查就意味着
197
+ 托管某个人的 refresh token,那个人离职/改密的当天全链路静默失效。所以形态是
198
+ 「本机 `lark-cli` 查 → PUT 落库」,`sync` 依赖本机装了 `lark-cli` 并已授权。
199
+
200
+ ### 三个状态不是同义词(读这段再动手)
201
+
202
+ | 状态 | 含义 | 对既有 open_id 的影响 |
203
+ | --------------- | -------------------------------- | ------------------------ |
204
+ | `resolved` | 查到了 | 覆盖 |
205
+ | `not_found` | 飞书通讯录里**确实没这人** | **清空**(离职语义) |
206
+ | `lookup_failed` | 查询本身失败,我们不知道 | **保留**上次成功解析的值 |
207
+ | (无行) | 从未同步过(`list` 里为 `null`) | — |
208
+
209
+ 把「没问出来」写成 `not_found` 的代价是**静默注销一个本来能收到告警的人**。
210
+
211
+ ### 两道护栏
212
+
213
+ - **写入面只认交互式登录**:API key、服务 token、job/account 绑定的自动化 CLI token
214
+ 一律 403。这批数据是 mention 注入的源头,不该有非人值守的写入方。
215
+ - **防一次误操作清空全部映射**:当此前 `resolved` 的人里超过一半这次变成 `not_found`
216
+ 时 `sync` 直接拒绝退出。登错飞书租户/授权失效正是这个形态,与「公司集体离职」
217
+ 不可区分,必须人来拍板 —— 确认无误才加 `--force-mass-not-found`。
218
+
219
+ `list` 读到的 `openId` 若形如 `ou_***` 说明**已脱敏**(判据是响应里的 `redacted`,
220
+ 不是字符串形态),原始 id 只给不受限的交互式 admin 登录;脱敏值回写会写出一个
221
+ 永远 @ 不到人的映射。
222
+
177
223
  ## 系统级人设库(personas)
178
224
 
179
225
  权限资源:`systemPersona`(权限矩阵中单独一行,与 `systemBrand` 类似)。
@@ -47,6 +47,7 @@ social-hub version --json
47
47
  | 图谱 / 合规 / 风控 | `social-hub-graph-compliance` |
48
48
  | settings / invites / members / api-keys | `social-hub-admin` |
49
49
  | brand-registry / brand-fit / trigger 注册表四眼 / brand-auth | `social-hub-admin` |
50
+ | 飞书身份映射同步(告警 @ 谁) | `social-hub-admin` |
50
51
  | 板块情报 / KOL / insights | `social-hub-intelligence` |
51
52
  | 运营板块池 catalog / aliases / tier rules | `social-hub-subreddit-pools` |
52
53
  | 通用网页抓取 / Firecrawl / scrape | `social-hub-scrape` |
@@ -50,7 +50,7 @@ social-hub reddit refresh-logs -t <team-id> -n 20
50
50
 
51
51
  筛选帖表现时优先 **`--brand`**,勿把 `--campaign` 当作品牌 ID。
52
52
 
53
- **`opsStatus` 约定(canonical,勿传中文或 `banned`):** `normal` | `risk` | `deleted` | `abnormal` | `unknown`。列表与 stats 均支持 `--ops-status`;stats 同样支持 `--contains-brand-keyword` 与 `--primary-product`,筛选口径与 Web 帖子表现页一致。
53
+ **`opsStatus` 约定(canonical,勿传中文):** `normal` | `risk` | `deleted` | `abnormal` | `banned` | `unknown`。⚠️ 把它改成 `deleted` / `banned` 时**必须**同时带必选项 `repostRequired`(见下方「需补发」),否则 400。列表与 stats 均支持 `--ops-status`;stats 同样支持 `--contains-brand-keyword` 与 `--primary-product`,筛选口径与 Web 帖子表现页一致。
54
54
 
55
55
  ## 单条写入与更新
56
56
 
@@ -73,6 +73,9 @@ social-hub reddit update -t <team-id> --snapshot <uuid> -j '{
73
73
  "secondRepostLink": "https://www.reddit.com/r/example/comments/repost2/"
74
74
  }'
75
75
  # 改/取消关联账号(socialAccountId):account-uuid 须属该快照所在团队;null=取消关联;换团队(teamId)时后端会自动清空它
76
+ # 改成「已删除」/「账号被封」:必须带 repostRequired(是否需补发)——不带一律 400
77
+ social-hub reddit update -t <team-id> --snapshot <uuid> -j '{"opsStatus":"deleted","repostRequired":true}'
78
+ social-hub reddit update -t <team-id> --snapshot <uuid> --repost-required no -j '{"opsStatus":"banned"}'
76
79
  social-hub reddit update -t <team-id> --snapshot <uuid> -j '{"socialAccountId":"<account-uuid>"}'
77
80
  social-hub reddit update -t <team-id> --snapshot <uuid> -j '{"socialAccountId":null}'
78
81
  # 改/取消品牌关联(brandId,系统级):品牌跨团队有效,换团队不清;null=取消;client 角色只能设其可见品牌
@@ -120,6 +123,16 @@ social-hub reddit batch-upsert -t <team-id> -j '{
120
123
  - **`brandNote`**:运营侧品牌备注(nullable string,仅内部可见)。
121
124
  - **`negativeFeedback`**:帖子负面互动反馈(nullable string,仅内部可见),如被举报/删评/封号等运营记录。与 `brandNote` 同类型;`create-snapshot`、`update`、batch item 均支持;Web 帖子表现编辑弹窗可改。
122
125
  - **`repostType`**:补发类型,canonical `none`(非补发)/`first`(补发一次)/`second`(补发二次),null=none。列表 `--repost-type` 可筛选。**自动拆分**:对一个 `repostType=none` 的帖 `update` 时,若本次**新填/改了** `firstRepostLink`(或 `secondRepostLink`),后端会自动以该链接为 permalink **新建一个补发帖**(`repostType=first`/`second`),拷贝原帖归因+状态、互动清零、不带补发链接、时间=当下;`(teamId,permalink)` 已存在或链接没变则不重复创建。只 `update`(PUT)触发,`create-snapshot`/`batch-upsert` 不触发。
126
+ - **`repostRequired`(必选项「是否需补发」)**:布尔。把 `opsStatus` 改成 `deleted` / `banned` 时**必须带**,否则服务端 400(fail-closed,不会默认成「不需补发」)。原帖 `repostType` 已是 `second`(补发二次)时**不问也不写**,带了也无效。
127
+ - **`create-snapshot` 同样受这道闸门**:新建时 `opsStatus` 直接写 `deleted`/`banned`(且 `repostType` 不是 `second`)也必须在 body 里带 `repostRequired`,否则 400。新建行没有「改前状态」,轮次按本次提交的 `repostType` 派生。
128
+ - **在两个 terminal 之间切换**(`deleted` ↔ `banned`)同样要带;`pending` 的行再保存也要带。
129
+ - **不能在同一次请求里既改 `repostType` 又把状态迁入/切换 terminal**(服务端 400):「需补发第几次」按**改前**的补发类型判,这种请求下「原数据」二义。先单独保存补发类型,再改状态。
130
+ - **不要传数字**:「需补发1 / 需补发2」的轮次由服务端按**原帖**的 `repostType` 派生(首发/none → 需补发1,`first` → 需补发2),并在决策那一刻冻结进 `repostRequirement` 列;事后再改 `repostType` 不会改写历史决策(**唯一例外**:改成 `second` 会清空该列——补发二次的帖按需求就不该带「需补发」)。
131
+ - 读侧字段 `repostRequirement`:`null`(不适用)/ `pending`(自动检测删的帖,等人决定)/ `not_required` / `required_1` / `required_2`。帖子表现列表在「运营状态」一格里把 `required_1/2` 渲染成「需补发1 / 需补发2」,`pending` 渲染成「待定补发」。
132
+ - **自动路径只落 `pending`**:worker 删除检测、存活检测、刷新、飞书/批量导入(`batch-upsert`)把帖子判成已删除时没有人可以回答这个问题,一律落 `pending` 进待决队列,绝不替运营决定。
133
+ - **批量(bulk-patch)**:整批设成 `deleted`/`banned` 时必须带 `repostRequired`;但如果选中的行**全部**是 `second`,服务端不会要求作答(需求:二次不弹)。轮次由服务端**逐行**按各自 `repostType` 派生。
134
+ - 状态从 `deleted`/`banned` 改回其他值时,这一列会被**清空**(决策作废)。
135
+ - 同一次请求里既改 `repostType` 又把状态迁入 `deleted`/`banned` 会被拒绝(「原数据」二义):先改类型,再改状态。
123
136
  - **`feishuRecordId`**:飞书记录 ID。当输入来自飞书时,Hub 优先按 `(teamId, feishuRecordId)` 幂等;同一 permalink 对应不同飞书记录时,后续记录可能获得 `feishu.local` 占位 permalink
124
137
  - **无 permalink**:可只带 `feishuRecordId`(及 `baseToken`/`tableId`),Hub 会生成稳定占位 URL
125
138