@geoly-ai/social-hub-cli 0.3.4 → 0.3.5

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 (34) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/cmd-manifest.json +22 -2
  3. package/dist/cmd-manifest.test.js +28 -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 +23 -0
  7. package/dist/index.js.map +1 -1
  8. package/dist/permissions-gates-notify.d.ts +26 -0
  9. package/dist/permissions-gates-notify.d.ts.map +1 -0
  10. package/dist/permissions-gates-notify.js +118 -0
  11. package/dist/permissions-gates-notify.js.map +1 -0
  12. package/dist/permissions-gates-notify.test.d.ts +2 -0
  13. package/dist/permissions-gates-notify.test.d.ts.map +1 -0
  14. package/dist/permissions-gates-notify.test.js +107 -0
  15. package/dist/permissions-gates-notify.test.js.map +1 -0
  16. package/dist/permissions.d.ts.map +1 -1
  17. package/dist/permissions.js +2 -0
  18. package/dist/permissions.js.map +1 -1
  19. package/dist/register-extensions.d.ts.map +1 -1
  20. package/dist/register-extensions.js +5 -9
  21. package/dist/register-extensions.js.map +1 -1
  22. package/dist/register-notify.d.ts +172 -0
  23. package/dist/register-notify.d.ts.map +1 -0
  24. package/dist/register-notify.js +1148 -0
  25. package/dist/register-notify.js.map +1 -0
  26. package/dist/register-notify.test.d.ts +2 -0
  27. package/dist/register-notify.test.d.ts.map +1 -0
  28. package/dist/register-notify.test.js +309 -0
  29. package/dist/register-notify.test.js.map +1 -0
  30. package/package.json +3 -3
  31. package/skills/README.md +1 -0
  32. package/skills/manifest.json +6 -1
  33. package/skills/social-hub-notifications/SKILL.md +351 -0
  34. package/skills/social-hub-posts/SKILL.md +36 -0
@@ -0,0 +1,351 @@
1
+ ---
2
+ name: social-hub-notifications
3
+ description: >-
4
+ Social Hub 事件订阅通知:`social-hub notify` 订阅 Hub 的节点事件(首个场景是帖子发布后
5
+ 2h/6h/24h/2d/7d 各一条),在**自己的机器上**拉走后自行执行副作用(开浏览器、截图、发飞书群)。
6
+ 用户说「帖子发出后隔几小时提醒我去看数据」「让我本地的 agent 收到 Hub 的事件」「订阅通知」
7
+ 「拉事件 / ack 游标 / 游标卡住了 / 订阅 stale」「配节点档位 / 停掉某个 source 的通知」时用本 skill。
8
+ **Hub 只负责通知,完全不管执行**——不下发任务、不收执行结果、没有租约。
9
+ 不用于 OpenClaw 任务领取(那是 social-hub-ops-runtime 的 claim/complete,有租约与终态)、
10
+ 也不用于飞书审核通知渠道(social-hub-admin)。
11
+ metadata:
12
+ cliVersion: ">=0.3.4"
13
+ ---
14
+
15
+ # social-hub-notifications
16
+
17
+ > **前置条件:** 先读 [`../social-hub-shared/SKILL.md`](../social-hub-shared/SKILL.md),
18
+ > 完成 `social-hub auth login` 与 `social-hub context use <team-uuid>`。
19
+
20
+ ## 这是什么(先分清边界)
21
+
22
+ Hub 在某个节点**发出一条事件**,你的 agent 通过 HTTP long-poll 拉走,**自己**去干活。
23
+
24
+ | | 事件订阅通知(本 skill) | ops runtime(`social-hub-ops-runtime`) |
25
+ | ------------ | -------------------------------------- | --------------------------------------- |
26
+ | 语义 | Hub 广播「到点了」 | Hub 派活并对结果负责 |
27
+ | 租约 / claim | **无** | 有(claim / heartbeat / lease) |
28
+ | 回传结果 | **不回传**,Hub 不感知你是否成功 | complete / fail / skip |
29
+ | ack 的含义 | 「我已处理到这里」,**只用于推进游标** | 执行回执 |
30
+
31
+ **别把 ack 当执行回执。** ack 唯一的作用是推进游标。
32
+
33
+ ## 五个必须先内化的概念
34
+
35
+ 1. **游标是连续 watermark**:`cursorSeq = N` 表示该订阅**所有匹配事件**中 `seq ≤ N` 的
36
+ **均已完成**。它**不是**「第 N 条已确认」。→ 所以 `pull` 默认 `--limit 1`,别改大。
37
+ 2. **at-least-once**:同一条事件可能被重复投递(网络重试、ack 响应丢失)。
38
+ **幂等键是 `dedupeKey`,不是 `seq`**——seq 只表示顺序,不是副作用身份。
39
+ 3. **四层版本 + `includedFields`**:`sourceDefinitionVersion` / `payloadSchemaVersion` /
40
+ `sourceConfigVersion` / `fieldSetVersion`。事件形状会随后台配置变化,**这是设计不是 bug**。
41
+ CLI 只原样输出,**绝不补齐**缺失字段;你也不要「猜」一个缺失字段的默认值。
42
+ 4. **tombstone**:过保留期后 payload 被清空、行仍在,`payloadStatus: "expired"`、
43
+ `payload: null`、`includedFields: []`。收到它**不得**当业务事件处理,但**必须**照常 ack。
44
+ 5. **没有 `--auto-ack`,本期也不会有**。CLI 把事件写进 stdout ≠ 你已经截完图、发完飞书。
45
+ ack 必须由**真正完成副作用的那一方**在完成之后发起。
46
+
47
+ ## 退出码(脚本/supervisor 据此分流)
48
+
49
+ | 码 | 含义 | 应对 |
50
+ | --- | ----------------------------------------------------------------------------- | --------------------------------- |
51
+ | 0 | 成功(含「无消息」、`--follow` 被 SIGINT/SIGTERM 正常中断) | 继续 |
52
+ | 1 | 参数 / 鉴权 / 权限 / 协议错误,或服务端**非重试**错误(含 pull 的 410 stale) | **停下来看 stderr**,不要自动重启 |
53
+ | 2 | 可重试的网络 / 服务不可用;或 `--fail-if-empty` 且无消息 | 退避后重试 |
54
+ | 3 | ack 的 cursor / receipt 冲突(409 CAS 冲突 / 410 游标过期) | 走「游标恢复分支」(见下) |
55
+ | 4 | 保留位:未来批量操作部分成功。**Phase 1 不会出现** | — |
56
+
57
+ stdout **只有事件**(`--format jsonl` 时每行一个完整 envelope)。
58
+ 重连、退避、心跳、错误一律走 **stderr**(结构化 JSON 单行)。
59
+
60
+ ## 建订阅
61
+
62
+ > **首次启用必读:该 team 必须先有一条 active 的 source config,否则建订阅会被拒
63
+ > (409 `SUBSCRIPTION_SOURCE_NOT_CONFIGURED`)——这是故意的,避免产生「看起来 active、
64
+ > 永远收不到事件」的死订阅。** 新部署默认**没有**任何 config,节点也不会物化
65
+ > (物化路径会以 `no_active_config` 静默跳过)。先按 [后台侧配置](#后台侧配置运维不是-agent-日常)
66
+ > 建一条并激活,再回到这里。这一步是运维做的,不是 agent 日常。
67
+
68
+ ```bash
69
+ # 一台机器一个订阅、各自独立游标(订阅之间是广播,不是竞争消费)
70
+ social-hub notify subscriptions create -t <team> \
71
+ --name my-mac-agent \
72
+ --source post-performance-node.v1 \
73
+ --start now \
74
+ --require-fields subject.permalink,account.externalRef,browserEnvironment.environmentId
75
+ ```
76
+
77
+ - `--start now`(默认):从当前流末尾开始,**避免新机器一上线被历史事件淹没**。
78
+ `--start earliest` 会重放保留期内全部事件,只在你确实要补历史时用。
79
+ - `--require-fields`:声明「没有这些字段就别发给我」。后台若激活一个不含这些字段的新配置,
80
+ 会被 **409 `SOURCE_CONFIG_REQUIRED_FIELDS_CONFLICT`** 拒绝激活——这是保护你,不是报错。
81
+ 反过来,创建订阅时这些字段必须已在当前生效配置里,否则创建就会被拒。
82
+ - 过滤:`--filter-brand` / `--filter-account` / `--filter-subreddit`(逗号分隔)。
83
+ 同字段多值 = OR,跨字段 = AND。
84
+ - 复杂请求体走 `-j '{...}'`;**Windows 请用 `--json-file <path>` 或 `-j @<path>`**
85
+ (cmd/PowerShell 不剥引号,内联 JSON 会被破坏)。
86
+
87
+ ```bash
88
+ social-hub notify subscriptions list -t <team> [--status active] [--source-id <sourceId>]
89
+ social-hub notify subscriptions get -t <team> --subscription my-mac-agent
90
+ social-hub notify subscriptions pause -t <team> --subscription my-mac-agent
91
+ social-hub notify subscriptions resume -t <team> --subscription my-mac-agent
92
+ social-hub notify subscriptions delete -t <team> --subscription my-mac-agent --apply
93
+ ```
94
+
95
+ `--subscription` 收订阅名或 uuid。**pause 期间 ack 不会成功**,别在 pause 状态下跑消费循环。
96
+
97
+ ## 拉事件
98
+
99
+ ```bash
100
+ # 一次性(cron / agent 的单次工具调用)——完整响应含 receipt
101
+ social-hub notify pull -t <team> --subscription my-mac-agent --limit 1 --wait 0
102
+
103
+ # 一次性 + JSON Lines(stdout 只出 envelope)
104
+ social-hub notify pull -t <team> --subscription my-mac-agent \
105
+ --limit 1 --wait 0 --format jsonl --receipt-file ./pending-receipt.json
106
+
107
+ # 常驻监听
108
+ social-hub notify pull -t <team> --subscription my-mac-agent \
109
+ --follow --wait 25 --format jsonl --receipt-file ./pending-receipt.json
110
+ ```
111
+
112
+ ### 🔴 `--receipt-file` 不是可选的便利
113
+
114
+ `ack` 要求回传**整个 receipt 对象**(不只是签名串),而 **envelope 里没有 receipt**。
115
+ 所以 `--format jsonl` **必须**配 `--receipt-file`,否则 CLI 直接退出 1 —— 不然你会拿到
116
+ 一批永远 ack 不掉的死事件。CLI 保证**先原子写 receipt 文件,再输出 envelope**(0600 权限)。
117
+
118
+ sidecar 形状:
119
+
120
+ ```json
121
+ {
122
+ "subscriptionId": "…",
123
+ "cursorSeq": "1041",
124
+ "throughSeq": "1042",
125
+ "eventIds": ["…"],
126
+ "receipt": {
127
+ "subscriptionId": "…",
128
+ "fromCursorSeq": "1041",
129
+ "maxDeliveredSeq": "1042",
130
+ "filterVersion": 3,
131
+ "receiptEpoch": 2,
132
+ "expiresAt": "…",
133
+ "signature": "…"
134
+ }
135
+ }
136
+ ```
137
+
138
+ ### `--follow` 的背压:maxUnacked = 1
139
+
140
+ `--follow` 输出一条后会**轮询自己的 subscription**,等 `cursorSeq >= 该条 seq` 才拉下一条。
141
+ 它是便利模式,**不是新的消费协议**。因此:
142
+
143
+ - `--follow` 只支持 `--limit 1`、只支持 `--format jsonl`、不能与 `--fail-if-empty` 同用。
144
+ - 你不 ack,它就**一直等**(stderr 每 30s 一条 `awaiting_ack` 心跳)。这是**有意的**:
145
+ 跳过 = 静默丢事件。
146
+ - 网络故障 / 5xx:内部退避重试(≤30s),**不退出**。
147
+ - 403 / 400 / 410 等非重试错误:立即退出 1。
148
+ - 订阅变 `paused` / `stale` / `deleted`,或 `receiptEpoch` / `filterVersion` 变了而游标还没越过
149
+ 待 ack 的那条:立即退出 1(继续等只会永久挂死,因为在途 receipt 已作废)。
150
+ - SIGINT / SIGTERM:正常退出 **0**。
151
+
152
+ ## ack
153
+
154
+ ```bash
155
+ # 推荐:直接喂 sidecar,seq 自动从里面取
156
+ social-hub notify ack -t <team> --subscription my-mac-agent \
157
+ --receipt-file ./pending-receipt.json
158
+
159
+ # 或显式给
160
+ social-hub notify ack -t <team> --subscription my-mac-agent \
161
+ --expected-cursor-seq 1041 --through-seq 1042 --receipt @pending-receipt.json
162
+ ```
163
+
164
+ `--receipt` 也接受 `-`(stdin)与内联 JSON。**`seq` 全程是字符串**(bigint):任何
165
+ `Number(seq)`、任何用 JS number 排序/比较都会在跨过 2^53 后静默丢精度。
166
+
167
+ ## 🔁 agent 消费循环(可直接抄)
168
+
169
+ 你需要自己提供的三个原语(**必须都是 fail-stop 的**,任何一个出错都不许继续):
170
+
171
+ | 原语 | 契约 |
172
+ | -------------------------- | ------------------------------------------------------------------------------------------------------------- |
173
+ | `store_claim <key>` | 退出码 **0=抢到 / 10=键已存在 / 其它=存储故障**。必须是原子写(唯一约束/事务),**不能** SELECT-then-INSERT |
174
+ | `store_state <key>` | 打印 `started\|completed\|failed_retryable\|outcome_unknown\|quarantined\|skipped_expired`;读不到就非 0 退出 |
175
+ | `store_mark <key> <state>` | 持久写;写不成功必须非 0 退出(**绝不能**静默失败后继续 ack) |
176
+
177
+ `do_side_effects` 也要区分两种失败:**确定没发生**(返回 1 → `failed_retryable`,可安全重试)与
178
+ **结果不明**(返回 2 → `outcome_unknown`,必须先 reconcile 才能重试)。
179
+
180
+ ```bash
181
+ #!/usr/bin/env bash
182
+ # 单线程、maxUnacked=1、崩溃可恢复。函数必须定义在循环之前。
183
+ set -uo pipefail
184
+ TEAM=<team-uuid>; SUB=my-mac-agent; RECEIPT=./pending-receipt.json
185
+ HUB=$(social-hub config show --json | jq -r .apiUrl) # 幂等键要带 Hub 身份
186
+
187
+ die() { echo "$*" >&2; exit 1; }
188
+
189
+ mark() { store_mark "$KEY" "$1" || die "durable store 写入失败($1) $KEY —— fail-stop,不执行也不 ack"; }
190
+
191
+ ack_now() {
192
+ social-hub notify ack -t "$TEAM" --subscription "$SUB" --receipt-file "$RECEIPT" || handle_ack $?
193
+ }
194
+
195
+ do_work() {
196
+ if [ "$STATUS" = "expired" ]; then # tombstone:不办事,但一定要 ack
197
+ mark skipped_expired # 即使 payloadSchemaVersion 未知也照常 ack —— 反正不读 payload
198
+ elif ! schema_supported "$SCHEMA"; then # 未知 payload 版本 → 拒绝处理且【不 ack】
199
+ mark quarantined
200
+ die "未知 payloadSchemaVersion=$SCHEMA eventId=$(jq -r .eventId <<<"$EVENT");升级 handler 后用 store_unquarantine 解封再重跑"
201
+ else
202
+ do_side_effects "$EVENT"; rc=$? # 截图 / 发飞书
203
+ case $rc in
204
+ 0) mark completed ;; # 先本地落地,再 ack
205
+ 1) mark failed_retryable; die "副作用确定失败,下轮重试(不 ack)" ;;
206
+ *) mark outcome_unknown; die "副作用结果不明,必须人工/自动 reconcile 后才能重试(不 ack)" ;;
207
+ esac
208
+ fi
209
+ ack_now
210
+ }
211
+
212
+ while true; do
213
+ EVENT=$(social-hub notify pull -t "$TEAM" --subscription "$SUB" \
214
+ --limit 1 --wait 25 --format jsonl --receipt-file "$RECEIPT")
215
+ rc=$?
216
+ case $rc in
217
+ 0) : ;; # 继续
218
+ 2) sleep 5; continue ;; # 可重试:退避
219
+ *) die "fatal rc=$rc,需要人工介入" ;; # 1/3:停下来
220
+ esac
221
+ [ -z "$EVENT" ] && continue # 本轮无消息(stdout 有行才算有事件,别看文件在不在)
222
+
223
+ DEDUPE=$(jq -r .dedupeKey <<<"$EVENT")
224
+ STATUS=$(jq -r .payloadStatus <<<"$EVENT")
225
+ SCHEMA=$(jq -r .payloadSchemaVersion <<<"$EVENT")
226
+ KEY="${HUB}|${TEAM}|${DEDUPE}" # 幂等键:Hub + team + dedupeKey
227
+
228
+ store_claim "$KEY"; cr=$?
229
+ if [ $cr -eq 0 ]; then do_work; continue; fi
230
+ # 🔴 存储故障【不是】claim 冲突:把它当「已处理」会直接丢事件。
231
+ [ $cr -eq 10 ] || die "durable store 不可用(store_claim rc=$cr)—— 不执行、不 ack"
232
+
233
+ # 🔴 「键已存在」≠「已完成」。必须读状态再分流。
234
+ state=$(store_state "$KEY") || die "读不到 $KEY 的状态 —— fail-stop"
235
+ case "$state" in
236
+ completed|skipped_expired)
237
+ ack_now ;; # 真的做完了 → 只补 ack,绝不重放副作用
238
+ quarantined)
239
+ die "已隔离(未知 schema),永不自动 ack: $KEY" ;;
240
+ outcome_unknown)
241
+ reconcile "$KEY" "$EVENT" || die "结果不明且无法 reconcile: $KEY" # 查飞书 message id / 截图文件
242
+ mark completed; ack_now ;; # reconcile 确认已发生 → 只补 ack
243
+ started)
244
+ # 上次崩在执行中途,外部结果同样不确定:先 stale-attempt 闸门,再当 outcome_unknown 处理。
245
+ store_is_stale "$KEY" || die "另一进程正在处理 $KEY"
246
+ reconcile "$KEY" "$EVENT" && { mark completed; ack_now; } || { mark started; do_work; } ;;
247
+ failed_retryable)
248
+ mark started; do_work ;; # 确定没发生过 → 直接重试,不 ack
249
+ *)
250
+ die "未知本地状态 '$state',拒绝猜测" ;; # 绝不用 * 兜底成「重试」或「ack」
251
+ esac
252
+ done
253
+ ```
254
+
255
+ 顺序不可交换:**claim → 执行副作用 → 本地记 completed → 最后才 ack Hub**。
256
+
257
+ **只有 `completed` 与 `skipped_expired` 允许 ack。** `failed_retryable` 重试、
258
+ `started` / `outcome_unknown` 必须先 reconcile、`quarantined` 必须 fail-stop(永不自动 ack)。
259
+ 解封 `quarantined` 是**显式人工动作**(升级 handler 后 `store_unquarantine`),不会自动发生。
260
+
261
+ ## 恢复语义(每个崩溃点会发生什么)
262
+
263
+ | 崩溃点 | 重启后 |
264
+ | --------------------------------- | ------------------------------------------------------------------------------------ |
265
+ | pull 后、执行前 | 安全:重新投递,正常处理 |
266
+ | claim 后、执行前 | 该 key 停在 `started`——需要一条 stale-attempt 规则(超时后允许重试)否则永久卡住 |
267
+ | 副作用执行中 | 外部结果不确定:能查就 reconcile(飞书 message id),不能查就接受可能重复 |
268
+ | **副作用成功后、记 completed 前** | 主要的重复副作用窗口。Hub 与飞书不在同一事务,`exactly-once` 不可能 |
269
+ | 飞书接受了但客户端超时(返回 2) | 同样是重复窗口:所以必须区分 `failed_retryable` 与 `outcome_unknown`,后者不许盲重试 |
270
+ | completed 后、ack 前 | 安全:重拉时 claim 失败 → 跳过副作用,只补 ack |
271
+ | ack 成功但响应丢失 | 下次 ack 会 409(游标已推进)→ 见「游标恢复分支」,**不要**重放副作用 |
272
+ | 本地 store 不可用 | **fail-stop**:不执行、不 ack。宁可停也不要在没有幂等保护时干活 |
273
+
274
+ 降低重复窗口的手段:截图用**确定性文件名 + 原子 rename**;飞书消息带稳定 marker 并保存返回的
275
+ message id,重启时先查一次再决定是否重发。
276
+
277
+ ## 游标恢复分支(收到退出码 3 时)
278
+
279
+ ```bash
280
+ social-hub notify subscriptions get -t <team> --subscription my-mac-agent
281
+ ```
282
+
283
+ - `cursorSeq >= 你刚才的 throughSeq` → **上次 ack 其实成功了**(响应丢失)。什么都别做,继续下一轮。
284
+ - `cursorSeq` 还在原地 → receipt 已失效(epoch / filterVersion 变了,或被别处推进)。
285
+ **重新 pull 拿新 receipt**,本地 dedupeKey 已是 completed → 跳过副作用,只 ack。
286
+ - `status: "stale"`(或 pull 直接 410)→ 游标已落后于保留 floor,**可能真丢了事件**。
287
+ 这需要人来拍板:
288
+
289
+ ```bash
290
+ social-hub notify subscriptions reset -t <team> --subscription my-mac-agent \
291
+ --start earliest --apply # 重放保留期内全部事件(靠 dedupeKey 幂等)
292
+ # 或 --start now # 【放弃】当前所有未处理事件,从流末尾重新开始
293
+ ```
294
+
295
+ reset 会 **bump `receiptEpoch`**,作废全部在途 receipt。**绝不要**在脚本里自动 reset。
296
+
297
+ 关于 reset 的两条硬语义:
298
+
299
+ - **有乐观锁**:CLI 会自动带上刚读到的 `rowVersion`。若期间订阅被别处改过,
300
+ 服务端返回 `409 SUBSCRIPTION_ROW_VERSION_CONFLICT`(details 带 `expectedRowVersion` /
301
+ `actualRowVersion`)。重新 `get` 一次再重试即可。
302
+ - **reset ≠ resume**:`paused` 的订阅 reset 后**仍然是 `paused`**(只修游标);
303
+ 只有 `stale` 会被恢复成 `active`。要恢复消费请显式 `notify subscriptions resume`。
304
+
305
+ ## 常见故障对照
306
+
307
+ | 现象 | 原因 | 处理 |
308
+ | ---------------------------------------------- | --------------------------------------------------- | ---------------------------------------------------------------------------------- |
309
+ | `--follow` 一直不动、心跳报同一个 `pendingSeq` | 你没 ack(多半是未知 schema 被隔离了) | 看 stderr 的 eventId;升级 handler 后重跑 |
310
+ | 游标永远卡在某条 | 把 tombstone 当业务事件处理失败了 | `payloadStatus: "expired"` **必须** ack |
311
+ | ack 一直 409 | 在重放旧 receipt | 重新 pull 拿新 receipt;409 是 CAS 语义,不做幂等成功 |
312
+ | pull 403 且 details 有 `missingPermissions` | 字段权限不足 | **整个订阅**被拒(不做字段级脱敏):减少 `--require-fields` 或补权限 |
313
+ | 某字段突然不见了 | 后台改了 `selectedFields`(`fieldSetVersion` 会变) | 按 envelope 版本分支;**不要**自己补默认值 |
314
+ | 一整段时间没有事件 | source 被 kill switch 关了 | `notify source-configs list`;disabled 期间到点的节点标 missed,**重新启用不补发** |
315
+
316
+ ## 后台侧配置(运维,不是 agent 日常)
317
+
318
+ ```bash
319
+ # registry 声明(有哪些 source、可勾哪些字段、默认档位)+ 本 team 的 active config
320
+ social-hub notify source-configs sources -t <team>
321
+
322
+ social-hub notify source-configs list -t <team> --source post-performance-node.v1 [--limit 50]
323
+ social-hub notify source-configs get -t <team> --source post-performance-node.v1 --id <config-version-uuid>
324
+
325
+ # 新建不可变版本(默认建完即激活;只影响【新物化】的 schedule,已物化的照旧)
326
+ social-hub notify source-configs create -t <team> --source post-performance-node.v1 \
327
+ --json-file ./cfg.json --apply
328
+ social-hub notify source-configs create ... --no-activate --apply # 只建 draft
329
+ social-hub notify source-configs activate -t <team> --source post-performance-node.v1 --id <uuid> --apply
330
+
331
+ # 实时 kill switch —— 立刻影响【全部在途 schedule】,且不 bump sourceConfigVersion
332
+ social-hub notify source-configs set-enabled -t <team> \
333
+ --source post-performance-node.v1 --enabled false --apply
334
+ ```
335
+
336
+ - 后台**不能**扩大数据暴露面:`selectedFields` 只能勾 registry 声明的 optional 字段,
337
+ required 不可勾也不可取消,未知路径直接拒。
338
+ - 后台**不能**绕过 authz,也**不能**改 canonical topic。
339
+ - `enabled=false` 期间到点的节点一律标 `missed`,**重新启用不补发**(过期通知的价值为 0)。
340
+
341
+ ## 绝对不要
342
+
343
+ - ❌ 用 `--limit > 1` 然后一次 ack 全部:中间失败会被静默跳过。
344
+ - ❌ 用 `seq` 做幂等键(用 `dedupeKey`)。
345
+ - ❌ 对 `seq` 做 `Number()` / JS number 比较。
346
+ - ❌ 未处理完就 ack;或为了「不卡住」而 ack 一条未知 schema 的事件。
347
+ - ❌ 在脚本里自动 `reset`(那是承认丢事件,必须人工拍板)。
348
+ - ❌ 多台机器共用同一个订阅(除非它们共享同一个持久幂等 store)。
349
+ - ❌ 把 stdout 和日志混在一起(stdout 只放 envelope)。
350
+ - ❌ 把「本地 claim 失败」直接当成「已完成」去 ack —— 先读状态(见上面的循环)。
351
+ - ❌ 以「receipt 文件存在」当作有事件的信号:空 pull 会**删除**旧 sidecar,但仍应以 stdout 是否有行为准。
@@ -123,6 +123,42 @@ social-hub reddit batch-upsert -t <team-id> -j '{
123
123
  - **`feishuRecordId`**:飞书记录 ID。当输入来自飞书时,Hub 优先按 `(teamId, feishuRecordId)` 幂等;同一 permalink 对应不同飞书记录时,后续记录可能获得 `feishu.local` 占位 permalink
124
124
  - **无 permalink**:可只带 `feishuRecordId`(及 `baseToken`/`tableId`),Hub 会生成稳定占位 URL
125
125
 
126
+ ## 外部来源帖:回填进「外部 / 未配置」归属桶
127
+
128
+ 不属于任何 agent 团队的帖子(第三方账号发的、竞品的、纯抓取到的)应归到**归属桶**(`teams.kind='bucket'`,目前是「未配置」和「外部」两个)。桶是**团队 UUID**,但它**没有成员、不出现在 `social-hub context` / 团队列表里**,所以必须先列举拿 ID。
129
+
130
+ ### 1)列桶拿 UUID
131
+
132
+ ```bash
133
+ social-hub reddit team-buckets # 全部桶(JSON: {items:[{id,slug,name,kind}]})
134
+ social-hub reddit team-buckets --slug external # 只看某个桶
135
+ BUCKET=$(social-hub reddit team-buckets --slug external --id-only) # 直接取 UUID
136
+ ```
137
+
138
+ `--id-only` 每行一个 UUID,便于脚本赋值。找不到 slug 会退出码 1。
139
+
140
+ ### 2)批量回填(推荐路径)
141
+
142
+ 拿到桶 UUID 后,**桶就是普通的 `-t <team-id>` 参数**,所有帖子表现命令都能用:
143
+
144
+ ```bash
145
+ social-hub reddit batch-upsert -t "$BUCKET" --json-file external-posts.json --dry-run # 先看计数
146
+ social-hub reddit batch-upsert -t "$BUCKET" --json-file external-posts.json # 真写
147
+ social-hub reddit list -t "$BUCKET" -n 50 # 回读校验
148
+ social-hub reddit stats -t "$BUCKET" # 桶内聚合
149
+ ```
150
+
151
+ 单条补录用 `social-hub reddit create-snapshot -t "$BUCKET" -j '{...}'`;已在别的团队里的帖子要**改派**进桶,用 `update`/批量 patch 把 `teamId` 设成桶 UUID。
152
+
153
+ ### 3)权限与边界
154
+
155
+ - 凭证用 `social-hub auth login`(Device Code)。桶路径对**会话用户和 CLI token 一视同仁**:能写就能读回来。
156
+ - 写操作需要 `postSnapshot.create/update`(单条)或 `postSnapshotImport.create`(`batch-upsert`)——默认角色矩阵下 **`internal`/`senior` 只读**,回填账号需要 `supervisor` 或 `manager`。403 先查角色。
157
+ - `reddit team-buckets` 要求角色对帖子表现的 `teamId` **且** `teamName` 字段可见,`client` 角色一律 403。
158
+ - **桶不是权限旁路**:品牌/(账号,品牌) 成对授权在桶里照常生效,受限主体在桶路径下也只看得到自己品牌的帖子;`batch-upsert` 对受限(client)主体**整条关闭**,不看权限矩阵怎么配。
159
+ - 环境变量 API key(`SOCIAL_HUB_API_KEY`)**不能**访问桶路径,也不能把帖子改派进桶——API key 严格绑定单团队。回填一律用 `auth login` 的 CLI token。
160
+ - 桶是**多方共享**的团队:外部 agent 在桶里只用 `team-buckets` / `batch-upsert` / `create-snapshot` / `update` / `list` / `stats`。**不要**在桶上跑 `refresh-all` 或取消刷新批次——那些批次目前没有调用方归属,会波及别人的回填。
161
+
126
162
  ### 幂等与风险
127
163
 
128
164
  - 重复 `batch-upsert` 同一条外部记录应更新而非重复插入(取决于提供的幂等键)