sintbot 1.0.15 → 1.3.0
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.
- package/CHANGELOG.en.json +118 -0
- package/CHANGELOG.json +118 -0
- package/README.md +20 -156
- package/README.zh-CN.md +20 -164
- package/THIRD-PARTY-NOTICES.md +394 -0
- package/bin/sintbot-v2.js +363 -0
- package/docs/AI-ASSISTED-INSTALL.md +19 -0
- package/docs/install.md +93 -0
- package/docs/zh-CN/AI-ASSISTED-INSTALL.md +19 -0
- package/docs/zh-CN/install.md +93 -0
- package/install.sh +84 -0
- package/package.json +74 -34
- package/src/agent/claudecode.js +149 -108
- package/src/agent/codexapp.js +458 -0
- package/src/agent/codexcode.js +210 -107
- package/src/agent/errtag.js +34 -0
- package/src/agent/privatecore.js +790 -0
- package/src/agent/privatecore.wasm +0 -0
- package/src/agent/runner.js +10 -8
- package/src/agent/winexec.js +35 -5
- package/src/c2install.js +117 -0
- package/src/config.js +2 -1
- package/src/core/c2cardeffectbridge.js +51 -0
- package/src/core/c2cardterminalpath.js +216 -0
- package/src/core/c2durableingress.js +148 -0
- package/src/core/c2durablejournal.js +1560 -0
- package/src/core/c2durableruntime.js +2361 -0
- package/src/core/c2eventidentity.js +90 -0
- package/src/core/c2globalfault.js +401 -0
- package/src/core/c2localeffectexecutor.js +193 -0
- package/src/core/c2payloadhandles.js +333 -0
- package/src/core/c2persistrelation.js +179 -0
- package/src/core/c2persiststate.js +342 -0
- package/src/core/c2platformrecoverysender.js +76 -0
- package/src/core/c2recoverygate.js +385 -0
- package/src/core/c2runnerinput.js +63 -0
- package/src/core/c2servicestartgate.js +103 -0
- package/src/core/c2sessionbridge.js +72 -0
- package/src/core/c2sourcequarantine.js +30 -0
- package/src/core/c2startuprecovery.js +105 -0
- package/src/core/c2successorbridge.js +151 -0
- package/src/core/c2terminaleffectbridge.js +154 -0
- package/src/core/c2terminalprune.js +108 -0
- package/src/core/c2turnprojection.js +177 -0
- package/src/core/enginecheckpoint.js +211 -0
- package/src/core/engineeffectcursor.js +394 -0
- package/src/core/enginestepcoordinator.js +183 -0
- package/src/core/store.js +52 -8
- package/src/core/v2engine.js +1469 -0
- package/src/platform/feishu.js +324 -67
- package/src/plugins.js +1 -9
- package/assets/design-styles/acid-pop.md +0 -155
- package/assets/design-styles/archive-index.md +0 -161
- package/assets/design-styles/art-deco.md +0 -164
- package/assets/design-styles/cinematic-noir.md +0 -178
- package/assets/design-styles/editorial.md +0 -121
- package/assets/design-styles/extra/README.md +0 -23
- package/assets/design-styles/extra/constructivism.md +0 -146
- package/assets/design-styles/extra/neo-brutalism.md +0 -175
- package/assets/design-styles/extra/new-wave.md +0 -166
- package/assets/design-styles/extra/nordic-material.md +0 -162
- package/assets/design-styles/extra/paris-couture.md +0 -162
- package/assets/design-styles/extra/tropical-modern.md +0 -168
- package/assets/design-styles/extra/ulm-functional.md +0 -182
- package/assets/design-styles/extra/wa-shikimen.md +0 -160
- package/assets/design-styles/extra/y2k-chrome.md +0 -172
- package/assets/design-styles/geometric-pop.md +0 -122
- package/assets/design-styles/hand-drawn.md +0 -117
- package/assets/design-styles/islamic-geometry.md +0 -155
- package/assets/design-styles/liquid-glass.md +0 -183
- package/assets/design-styles/neo-chinese.md +0 -136
- package/assets/design-styles/precision-mono.md +0 -156
- package/assets/design-styles/quiet-luxury.md +0 -163
- package/assets/design-styles/riso-print.md +0 -116
- package/assets/design-styles/soft-friendly.md +0 -174
- package/assets/design-styles/swiss-grid.md +0 -156
- package/assets/design-styles/tech-dark.md +0 -182
- package/assets/favicon.svg +0 -6
- package/assets/logo.svg +0 -24
- package/bin/sbot.js +0 -4711
- package/docs/slack-app-manifest.yaml +0 -104
- package/sbot.config.example.json +0 -235
- package/src/agent/askonce.js +0 -76
- package/src/agent/claudemodels.js +0 -60
- package/src/agent/claudestatus.js +0 -74
- package/src/agent/claudeusage.js +0 -200
- package/src/agent/codexstatus.js +0 -181
- package/src/agent/imagen.js +0 -458
- package/src/agent/quotaerr.js +0 -44
- package/src/agent/transienterr.js +0 -32
- package/src/cdp.js +0 -254
- package/src/clilang.js +0 -31
- package/src/core/cron.js +0 -182
- package/src/core/cronfire.js +0 -120
- package/src/core/cronrun.js +0 -116
- package/src/core/engine.js +0 -2762
- package/src/core/interfaces.js +0 -114
- package/src/core/issues.js +0 -185
- package/src/core/ledger.js +0 -30
- package/src/core/opsalert.js +0 -214
- package/src/core/pending.js +0 -123
- package/src/core/proxy.js +0 -424
- package/src/core/quotaalert.js +0 -79
- package/src/core/quotaerr.js +0 -55
- package/src/core/scheduler.js +0 -59
- package/src/design.js +0 -5161
- package/src/gitrepo.js +0 -150
- package/src/health.js +0 -353
- package/src/installdeps.js +0 -72
- package/src/leftover.js +0 -174
- package/src/platform/boardnotice.js +0 -77
- package/src/platform/boardops.js +0 -36
- package/src/platform/boardwatch.js +0 -447
- package/src/platform/cli.js +0 -45
- package/src/platform/slack.js +0 -1289
- package/src/platform/takeovercard.js +0 -37
- package/src/platform/wecom.js +0 -938
- package/src/platform/wecomcli.js +0 -128
- package/src/platform/wecomgate.js +0 -108
- package/src/platform/wecomname.js +0 -43
- package/src/platform/wecompoll.js +0 -241
- package/src/preview.js +0 -581
- package/src/projectmemory.js +0 -175
- package/src/service.js +0 -317
- package/src/ui/auth.js +0 -31
- package/src/ui/browser.html +0 -392
- package/src/ui/browserroutes.js +0 -234
- package/src/ui/console.html +0 -5494
- package/src/ui/dashboard.js +0 -168
- package/src/ui/feishulogin.js +0 -136
- package/src/ui/server.js +0 -2314
- package/src/ui/sessions.js +0 -78
- package/src/ui/takeover.js +0 -108
- package/src/updater.js +0 -243
- package/src/upgrade.js +0 -35
- package/src/usage.js +0 -144
package/src/platform/slack.js
DELETED
|
@@ -1,1289 +0,0 @@
|
|
|
1
|
-
// Slack 平台适配器 —— 满足 src/core/interfaces.js 的 Platform 契约(start / reply / send / stop)。
|
|
2
|
-
// 与 feishu.js 平级、互不引用:引擎不认识 "slack" 三个字,只认这几个方法。
|
|
3
|
-
//
|
|
4
|
-
// 范围:收消息 → 回消息 → 优雅停;外加回执表情、
|
|
5
|
-
// 消息编辑(带降级)、附件下载三件顺手且互不依赖的能力。
|
|
6
|
-
// ⚠ **不含进度表达**(节流 chat.update 刷进度 + agents.sessions.setStatus 的原生 Working… / Stop)
|
|
7
|
-
// —— 那是第 2 步。契约里 createStreamingCard 是可选的:本文件不实现,引擎(engine.js:1442)
|
|
8
|
-
// 自动退回纯文本回复,不需要任何改动。
|
|
9
|
-
// 接线在 bin/sbot.js(多平台路由器 multi.js 的 `adapters.slack`);本文件自成一体、不依赖引擎。
|
|
10
|
-
//
|
|
11
|
-
// ── 为什么不用 @slack/socket-mode + @slack/web-api(docs/SLACK.md「形态」)──────────
|
|
12
|
-
// ① 那两个包不管我们真正要处理的东西:ack 先行 · event_id 去重 · 事件年龄闸门 · 自我回环丢弃 ·
|
|
13
|
-
// 429 退避 · 错误码分类 —— 引了包这些照样得写(见 SLACK.md「静默失败」 / SLACK.md「可靠性」)。
|
|
14
|
-
// ② 重连这块本仓已有 wswatchdog.js 的纯状态机,而 SDK 自己的重连有已知竞态
|
|
15
|
-
// (`too_many_websockets`,node-slack-sdk#1654)⇒ 引一个包再绕过它的主功能,不划算。
|
|
16
|
-
// ③ 传输面 `ws` 是**既有依赖**(@larksuiteoapi/node-sdk 已经装了同一个 ^8,dedupe 后零新增);
|
|
17
|
-
// web API 全部是 POST JSON,原生 fetch 就够(Node ≥22,与 package.json engines 一致 ——
|
|
18
|
-
// 原生 WebSocket 要 ≥22,不能用)。
|
|
19
|
-
// ⇒ 新增安装体积实际为零,而**所有 Slack 行为都落在本文件里**,可被零网络单测覆盖
|
|
20
|
-
// (tests/unit/slack.test.js)。
|
|
21
|
-
// ⚠ 但 SLACK.md「可靠性」 的警告仍然成立:Slack 的业务错误一律 HTTP 200 + `ok:false`,**必须显式抛**,
|
|
22
|
-
// 否则就是 v0.11.48 那次飞书事故的形状(引擎以为答案送达,群里一个字都没有)。
|
|
23
|
-
// `call()` 是本文件唯一的出站入口,那一层已经写死。
|
|
24
|
-
//
|
|
25
|
-
// 实测口径见 docs/SLACK.md(861 行);本文件注释里的 §x 全部指它。
|
|
26
|
-
|
|
27
|
-
const fs = require('node:fs')
|
|
28
|
-
const path = require('node:path')
|
|
29
|
-
const WebSocket = require('ws')
|
|
30
|
-
const { createWatchdog } = require('./wswatchdog')
|
|
31
|
-
const { getProxyFetch, getProxyAgent, proxyWarning } = require('../core/proxy') // 出网代理:Node 的 fetch 与 ws 都不读 http_proxy(见该模块头注)
|
|
32
|
-
|
|
33
|
-
// 适配器默认的出网实现。⚠ **必须现取、不能在模块加载时定形** —— `applyProxyEnv` 是在
|
|
34
|
-
// `loadConfig` 之后才把 `cfg.proxy` 写进 env 的,而本文件早就被 require 进来了。
|
|
35
|
-
// 没配代理时 `getProxyFetch()` 返回 null ⇒ 原样走全局 `fetch`,行为与改动前逐字节相同。
|
|
36
|
-
const netFetch = (...a) => (getProxyFetch() || fetch)(...a)
|
|
37
|
-
|
|
38
|
-
const API = 'https://slack.com/api/'
|
|
39
|
-
|
|
40
|
-
// ── 错误码分类(SLACK.md「可靠性」)──────────────────────────────────────────────
|
|
41
|
-
// 分类决定**处置**,不决定日志措辞:致命的必须停(否则重试循环把 app 打死),
|
|
42
|
-
// 频道级的必须把该群摘掉(否则每轮都白跑),更新级的必须降级发新消息(否则进度冻住)。
|
|
43
|
-
const FATAL = new Set(['token_revoked', 'token_expired', 'invalid_auth', 'account_inactive', 'missing_scope', 'not_authed', 'invalid_auth_token'])
|
|
44
|
-
const RETRYABLE = new Set(['ratelimited', 'rate_limited', 'internal_error', 'service_unavailable', 'fatal_error', 'request_timeout'])
|
|
45
|
-
const CHANNEL_LEVEL = new Set(['channel_not_found', 'not_in_channel', 'is_archived', 'is_inactive', 'posting_to_channel_denied', 'restricted_action', 'restricted_action_read_only_channel', 'restricted_action_thread_only_channel', 'restricted_action_non_threadable_channel'])
|
|
46
|
-
const CONTENT_LEVEL = new Set(['msg_too_long', 'msg_blocks_too_long', 'invalid_blocks', 'too_many_attachments', 'msg_blocks_too_many'])
|
|
47
|
-
// ⭐ 这一组的处置是同一个动作:**立刻改发新消息(带 thread_ts)并切换进度指针**(SLACK.md「可靠性」)。
|
|
48
|
-
const UPDATE_DEGRADE = new Set(['edit_window_closed', 'message_not_found', 'cant_update_message', 'block_mismatch', 'message_not_in_streaming_state', 'streaming_state_conflict'])
|
|
49
|
-
|
|
50
|
-
// 把 Slack 错误翻成「哪里不对 + 怎么修」的一句话,给向导拉频道列表、平台预检用(1.0.4)。
|
|
51
|
-
// 此前这两处直接显示错误码,`invalid_auth` 这类字样新手看不懂、也不知道去哪修。末尾仍带上原始码,方便搜索与报 issue。
|
|
52
|
-
const AUTH_CODES = new Set(['invalid_auth', 'not_authed', 'token_revoked', 'token_expired', 'account_inactive', 'invalid_auth_token'])
|
|
53
|
-
function slackHint(e) {
|
|
54
|
-
const code = (e && e.code) || ''
|
|
55
|
-
const p = (e && e.payload) || {}
|
|
56
|
-
if (AUTH_CODES.has(code)) return `Bot Token(xoxb-)无效或已撤销 —— 到 api.slack.com 这个应用的 OAuth & Permissions 页重新安装,换上新 token [${code}]`
|
|
57
|
-
if (code === 'missing_scope') return `缺权限${p.needed ? ' ' + p.needed : ''} —— 在 OAuth & Permissions 里加上,然后重新安装应用才生效 [missing_scope]`
|
|
58
|
-
if (code === 'ratelimited' || code === 'rate_limited') return `触发了 Slack 限流,稍等一会儿再试 [${code}]`
|
|
59
|
-
if (code === 'request_timeout') return `连不上 slack.com —— 检查网络;在中国大陆要先配好代理 [${p.cause || code}]`
|
|
60
|
-
if (code === 'service_unavailable') return 'Slack 服务暂时不可用,稍后再试 [service_unavailable]'
|
|
61
|
-
return code || (e && e.message) || String(e)
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function classifyError(code) {
|
|
65
|
-
if (!code) return 'other'
|
|
66
|
-
if (FATAL.has(code)) return 'fatal'
|
|
67
|
-
if (UPDATE_DEGRADE.has(code)) return 'degrade'
|
|
68
|
-
if (RETRYABLE.has(code)) return 'retryable'
|
|
69
|
-
if (CHANNEL_LEVEL.has(code)) return 'channel'
|
|
70
|
-
if (CONTENT_LEVEL.has(code)) return 'content'
|
|
71
|
-
return 'other'
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
class SlackError extends Error {
|
|
75
|
-
constructor(code, method, payload) {
|
|
76
|
-
super(`slack ${method} → ${code}`)
|
|
77
|
-
this.name = 'SlackError'
|
|
78
|
-
this.code = code
|
|
79
|
-
this.method = method
|
|
80
|
-
this.payload = payload || null
|
|
81
|
-
this.kind = classifyError(code)
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
86
|
-
const num = (v, d) => (Number(v) > 0 ? Number(v) : d)
|
|
87
|
-
|
|
88
|
-
// ── 出站正文(SLACK.md「富文本」)──────────────────────────────────────────────────────────────────
|
|
89
|
-
// Slack 的 `markdown_text` 吃**标准 Markdown**(实测:标题分层级、嵌套列表、原生表格、
|
|
90
|
-
// 代码块带语言标签)⇒ agent CLI 吐什么发什么,飞书那套 mdtable 转换在这里整块省掉。
|
|
91
|
-
const MD_LIMIT = 12_000 // 同 payload 内 markdown 累计上限;超出静默丢弃(SLACK.md「富文本」)
|
|
92
|
-
|
|
93
|
-
// 顶层 `text` 的纯文本回退:⚠ 移动端推送**只读 message.text**(2026-07-13 changelog),
|
|
94
|
-
// 只发 markdown_text 不填 text ⇒ 手机推送是空的 —— 对"跑完 40 分钟推我一下"是致命的(SLACK.md「进度表达」)。
|
|
95
|
-
function plainFallback(md, max = 300) {
|
|
96
|
-
const s = String(md || '')
|
|
97
|
-
.replace(/```[\s\S]*?```/g, '[代码]') // 代码块压成一个词,推送里没意义
|
|
98
|
-
.replace(/^\s{0,3}#{1,6}\s+/gm, '') // 标题符号
|
|
99
|
-
.replace(/[*_~`>]+/g, '') // 行内标记
|
|
100
|
-
.replace(/^\s*[-*+]\s+/gm, '· ')
|
|
101
|
-
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // 链接只留文字
|
|
102
|
-
.replace(/\s*\n\s*/g, ' ') // 推送是单行
|
|
103
|
-
.trim()
|
|
104
|
-
return s.length > max ? s.slice(0, max - 1) + '…' : s
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* 一条出站消息的完整 body。三处防御一次写死,所有发送路径共用:
|
|
109
|
-
* ① 永远带顶层 `text`(移动端推送,SLACK.md「进度表达」)
|
|
110
|
-
* ② 永远 `unfurl_links/unfurl_media: false` —— 否则 prompt injection 可以借 unfurl
|
|
111
|
-
* 的出站请求把数据带走(SLACK.md「收消息」,官方安全文档专章)
|
|
112
|
-
* ③ 正文超 MD_LIMIT 先截断并显式标注,不让 Slack 静默吃掉尾部(SLACK.md「富文本」)
|
|
113
|
-
*/
|
|
114
|
-
/**
|
|
115
|
-
* 群内控制面的按钮(SLACK.md「群内控制面」 的第三条入口)。
|
|
116
|
-
*
|
|
117
|
-
* ⭐ **为什么值得做**:Slack 自己吃掉 `/` 开头的输入,而**注册的斜杠命令在线程里根本调不出来**
|
|
118
|
-
* (官方明文)—— 偏偏进度消息与答案全都挂在线程里。⇒ 线程内此前只剩 `!stop` 一条路,
|
|
119
|
-
* 而那要求人先知道有这么个写法。按钮是**线程里唯一不需要先教就会用**的入口。
|
|
120
|
-
*
|
|
121
|
-
* ⭐ 清单从注入的 `commands`(引擎的 SLASH_COMMANDS 单一事实源)**过一遍**,不另抄一份 ——
|
|
122
|
-
* 否则哪天引擎去掉某个命令,这里会留下一个按下去说"未知命令"的按钮。
|
|
123
|
-
* ⚠ 故意**不放「开新」**(`/new`):它连会话一起清,而按钮是点一下就生效、没有回头路;
|
|
124
|
-
* 把破坏性最大的那个留给 `!new`(要手打,自带一道门槛)。停止那个则挂原生 confirm ——
|
|
125
|
-
* 真机上出过「跑了 42 分钟、离收尾只差一步却被 /stop 掐掉」,那次代价就是没有这道确认。
|
|
126
|
-
* ⚠ 但这道 confirm **堵不住旁边那个原生 Stop**(Agent Sessions 的 Working… 按钮):它走
|
|
127
|
-
* `agent_session_stopped` → `engine.stop()`,一键、无确认,且语义不同 —— 只放掉当前轮,
|
|
128
|
-
* **不清队列、不回群消息**。两个「停止」并排,别以为有了 confirm 就全堵上了。
|
|
129
|
-
*/
|
|
130
|
-
const CONTROL_BUTTONS = [
|
|
131
|
-
{ cmd: '/stop', label: '停止', style: 'danger', confirm: { title: '停止这个群的全部任务?', text: '正在跑的这一轮会被中断,排队里的也都不做了。只想跳过当前这一件的话,点「跳过」。', ok: '停止', cancel: '再想想' } },
|
|
132
|
-
{ cmd: '/skip', label: '跳过' },
|
|
133
|
-
{ cmd: '/status', label: '状态' },
|
|
134
|
-
]
|
|
135
|
-
|
|
136
|
-
function controlActions(commands = []) {
|
|
137
|
-
const have = new Set(commands)
|
|
138
|
-
return CONTROL_BUTTONS.filter((b) => have.has(b.cmd))
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* App Home 的「主页」内容(SLACK.md「群内控制面」 第三层)。纯函数,便于单测。
|
|
143
|
-
*
|
|
144
|
-
* ⭐ **它只讲「在 Slack 里怎么用」,不复制命令表** —— 两个理由,第二个是硬的:
|
|
145
|
-
* ① 命令表的单一事实源在引擎的 `helpText()`,抄一份过来必然漂移(而漂了的那份会长期
|
|
146
|
-
* 看起来是对的,正是最难发现的一类)。
|
|
147
|
-
* ② **App Home 是按人发布的,而那张表是按群算的** —— 权限档、provider、有没有画布,
|
|
148
|
-
* 每个群都不同。真把「完整表」放这儿,只能挑一个群的口径显示,对其他群一律是错的。
|
|
149
|
-
* ⇒ 这一层的职责是**平台专属的入门**(那三个入口、以及为什么不是 `/`),
|
|
150
|
-
* 它在别处无处可放;命令表仍然指向 `!help`,那才是按群算得准的地方。
|
|
151
|
-
*
|
|
152
|
-
* ⭐ 这一层的另一半价值是**改文案不用重装 app**:usage_hint 写在 manifest 里,改一次要重装;
|
|
153
|
-
* 这里是运行时 `views.publish`,改完下次打开就是新的。
|
|
154
|
-
*/
|
|
155
|
-
/**
|
|
156
|
-
* 该不该给这次 `app_home_opened` 发布主页。
|
|
157
|
-
* ⚠ 抽成纯函数只为一件事:**这个筛子没有测试就等于没有** —— 帧处理那层单测够不着,
|
|
158
|
-
* 而漏筛的代价是每次有人点开 DM 都白打一次 `views.publish`(Messages 页签也发这个事件)。
|
|
159
|
-
*/
|
|
160
|
-
function shouldPublishHome(event) {
|
|
161
|
-
return !!(event && event.type === 'app_home_opened' && event.tab === 'home' && event.user)
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
function homeView({ alias = '!', umbrella = '/sint' } = {}) {
|
|
165
|
-
const md = (text) => ({ type: 'section', text: { type: 'mrkdwn', text } })
|
|
166
|
-
return {
|
|
167
|
-
type: 'home',
|
|
168
|
-
blocks: [
|
|
169
|
-
md('*在 Slack 里怎么用*'),
|
|
170
|
-
{ type: 'divider' },
|
|
171
|
-
// ⚠ 不承诺「直接发消息就行」:开了 onlyWhenMentioned 的群必须 @;而主页是按人发的,
|
|
172
|
-
// 不知道对方在哪个群 ⇒ 只说所有群都成立的那半句。
|
|
173
|
-
md(`*1 · 直接说话* —— 在已绑定的频道里 @ 我(有的群不用 @ 也行,看群的设置)。跑起来之后,进度和答案都挂在那条消息的线程里。`),
|
|
174
|
-
md(`*2 · 线程里点按钮* —— 任务跑着的时候,进度消息上有「停止 / 跳过 / 状态」。`
|
|
175
|
-
+ `\n_线程里没法用斜杠命令(Slack 的限制),按钮是线程里最快的那条路。_`),
|
|
176
|
-
md(`*3 · 用 \`${alias}\` 前缀发命令* —— 比如 \`${alias}status\`、\`${alias}stop\`。`
|
|
177
|
-
+ `\n_为什么不是 \`/\`:\`/\` 开头的输入被 Slack 自己吃掉了,根本到不了我这儿。_`
|
|
178
|
-
+ `\n在频道里(不是线程里)也可以用 \`${umbrella}\`,它有自动补全。`),
|
|
179
|
-
{ type: 'divider' },
|
|
180
|
-
md(`发 \`${alias}help\` 看这个群能用哪些命令 —— *那份是按群算的*,权限和模型不同,能用的也不同。`),
|
|
181
|
-
],
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* 按钮点击载荷 → 引擎消息(纯函数,不认得就返回 null)。
|
|
187
|
-
* 抽出来是为了**可单测** —— 帧处理那层要真凭据才跑得起来,而这条路上最该测的两件事
|
|
188
|
-
* (只认自己的按钮 · 不拿本机时钟推水位线)全在这个翻译里。
|
|
189
|
-
*/
|
|
190
|
-
function actionMessage(payload, { commands = [], now = () => Date.now(), nameOf = () => null } = {}) {
|
|
191
|
-
if (!payload || payload.type !== 'block_actions') return null // view_submission 等一律不接
|
|
192
|
-
const act = (payload.actions || [])[0]
|
|
193
|
-
const cmd = act && String(act.value || '')
|
|
194
|
-
// ⚠ **只认自己发出去的那几个按钮**,两道判据缺一不可:载荷是外部输入,别人 app 的按钮
|
|
195
|
-
// (或伪造的 value)不该被当成命令喂进引擎。
|
|
196
|
-
// ① action_id 带我们的前缀 —— 认「是不是我们发的」;② value 在 commands 里 —— 认「引擎
|
|
197
|
-
// 认不认」(commands = 引擎的 SLASH_COMMANDS 单一事实源)。只查②的话,别人 app 里恰好
|
|
198
|
-
// value 也叫 `/stop` 的按钮会被当真。
|
|
199
|
-
if (!cmd || !commands.includes(cmd)) return null
|
|
200
|
-
if (!String(act.action_id || '').startsWith(ACTION_PREFIX)) return null
|
|
201
|
-
const channel = payload.channel && payload.channel.id
|
|
202
|
-
if (!channel) return null
|
|
203
|
-
const u = payload.user || {}
|
|
204
|
-
// ⚠ 去重键用 action_ts 而不是 trigger_id:trigger_id 是一次性的,同一次点击被重投时会变,
|
|
205
|
-
// 而 action_ts 是那次点击本身的时刻 —— 只有它认得出「这是同一下」。
|
|
206
|
-
const key = act.action_ts || payload.trigger_id || String(now())
|
|
207
|
-
// 名字与消息路径**同源**(users.info 的真名缓存):载荷里的 `name`/`username` 都是 handle,
|
|
208
|
-
// 同一个人发消息叫「小王」、点按钮叫「xiaowang」,群里会以为是两个人。缓存没命中才退到 handle。
|
|
209
|
-
const cached = u.id ? nameOf(u.id) : null
|
|
210
|
-
return {
|
|
211
|
-
chatId: channel,
|
|
212
|
-
userId: u.id || 'unknown',
|
|
213
|
-
senderName: cached || u.name || u.username || u.id || 'user',
|
|
214
|
-
text: cmd,
|
|
215
|
-
msgId: 'action:' + key,
|
|
216
|
-
// ⚠ 同斜杠命令那条:载荷里**没有平台 ts**,只有本机时钟 —— 拿它推水位线就是 v0.11.12
|
|
217
|
-
// 那个「两把钟比大小」的坑(主机钟快 N 秒 = 开了个 N 秒黑洞吞掉真人消息)。
|
|
218
|
-
synthetic: true,
|
|
219
|
-
wantsAck: false,
|
|
220
|
-
createTimeMs: now(),
|
|
221
|
-
// ⭐ 回执落在**按钮所在的那条线程**里:点击本身只有点的人看得见,不回一句的话
|
|
222
|
-
// 其他人只会看到任务莫名停了。挂线程而不是发频道 —— 任务的上下文就在这条线程里。
|
|
223
|
-
// ⚠ 但**只在真有线程时**挂(`thread_ts` 存在 = 这条消息在线程里;顶层消息没有这个字段):
|
|
224
|
-
// 合成轮(定时任务/插话)的进度消息发在频道顶层,若拿它的 `ts` 当线程,回执会开在
|
|
225
|
-
// 一条 finish 时就被删掉的消息下面,挂在「此消息已删除」的墓碑上。那种情况与斜杠命令
|
|
226
|
-
// 同款处置 —— 发频道。
|
|
227
|
-
replyCtx: {
|
|
228
|
-
channel,
|
|
229
|
-
threadTs: (payload.message && payload.message.thread_ts) || null,
|
|
230
|
-
},
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
// 按钮身份前缀。⚠ 不含工具名(用户面零工具名规则的边界②虽豁免不可见标识符,但它会出现在
|
|
235
|
-
// workspace 数据导出里;此刻改零成本,真机验过之后再改就得考虑在途消息)。
|
|
236
|
-
// `actionMessage` 回来时**真的校验它**(见那边)—— 不是装饰。
|
|
237
|
-
const ACTION_PREFIX = 'sint_'
|
|
238
|
-
|
|
239
|
-
function actionsBlock(buttons) {
|
|
240
|
-
if (!buttons || !buttons.length) return null
|
|
241
|
-
return {
|
|
242
|
-
type: 'actions',
|
|
243
|
-
// 官方建议 block_id 每次迭代都换 —— 那是为 `state.values` 准备的;纯按钮场景复用无害。
|
|
244
|
-
// 这里稳定是为了 `actionMessage` 能按前缀认出「这是我们的按钮」(它与 value 一起校验)。
|
|
245
|
-
block_id: ACTION_PREFIX + 'controls',
|
|
246
|
-
elements: buttons.map((b) => ({
|
|
247
|
-
type: 'button',
|
|
248
|
-
text: { type: 'plain_text', text: b.label, emoji: false },
|
|
249
|
-
action_id: ACTION_PREFIX + b.cmd.replace('/', ''), // /stop → sint_stop
|
|
250
|
-
value: b.cmd,
|
|
251
|
-
...(b.style ? { style: b.style } : {}),
|
|
252
|
-
...(b.confirm ? {
|
|
253
|
-
confirm: {
|
|
254
|
-
title: { type: 'plain_text', text: b.confirm.title },
|
|
255
|
-
text: { type: 'plain_text', text: b.confirm.text },
|
|
256
|
-
confirm: { type: 'plain_text', text: b.confirm.ok },
|
|
257
|
-
deny: { type: 'plain_text', text: b.confirm.cancel },
|
|
258
|
-
style: 'danger',
|
|
259
|
-
},
|
|
260
|
-
} : {}),
|
|
261
|
-
})),
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
function messagePayload(channel, content, { threadTs = null, ts = null, actions = null } = {}) {
|
|
266
|
-
let md = String(content == null ? '' : content)
|
|
267
|
-
if (md.length > MD_LIMIT) md = md.slice(0, MD_LIMIT - 40) + '\n\n… (超出 Slack 单条上限,已截断)'
|
|
268
|
-
// ⚠⚠ **`markdown_text` 与顶层 `text` 互斥**(2026-09-01 真机:`markdown_text_conflict`),
|
|
269
|
-
// 而两者各自都必需 —— text 管移动端推送、markdown 管渲染。**解法是走 `markdown` block**:
|
|
270
|
-
// blocks + text 可以共存,实测发得出、渲染正确(读回时 Slack 已把它转成 rich_text)。
|
|
271
|
-
// ⚠ 顺带解决 SLACK.md「静默失败」 那条「chat.update 只传 text 会静默清空 blocks」—— 这里两者永远同发。
|
|
272
|
-
const body = { channel, text: plainFallback(md), blocks: [{ type: 'markdown', text: md }] }
|
|
273
|
-
// ⚠ 不传 actions 时输出**逐字节与从前相同** —— 出站正文是全链路最热的一段,
|
|
274
|
-
// 加个可选参数不该让既有的每一条消息都变形。
|
|
275
|
-
const ab = actionsBlock(actions)
|
|
276
|
-
if (ab) body.blocks.push(ab)
|
|
277
|
-
if (threadTs) body.thread_ts = threadTs
|
|
278
|
-
// ⚠ unfurl 开关只在**发**的时候有意义(发出去那一刻就定了);chat.update 不收这两个参数。
|
|
279
|
-
if (ts) body.ts = ts
|
|
280
|
-
else Object.assign(body, { unfurl_links: false, unfurl_media: false })
|
|
281
|
-
return body
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
// ── 入站正文(SLACK.md「收消息」)──────────────────────────────────────────────────────────────────
|
|
285
|
-
// ⚠ 绝不抄 cc-bot 的 sanitize():它把换行压成空格(`.replace(/[\r\n]/g,' ')`)——
|
|
286
|
-
// sbot 要把多行 prompt 原样喂给 agent,压掉换行就是**毁掉代码块**(SLACK.md「收消息」)。
|
|
287
|
-
// ⚠ 顺序要紧:先 lt/gt 再 amp。反过来的话 `&lt;`(用户真的打了 "<")会被解成 `<`。
|
|
288
|
-
const ENTITIES = [['<', '<'], ['>', '>'], ['&', '&']]
|
|
289
|
-
|
|
290
|
-
/**
|
|
291
|
-
* Slack 的 text 是"半转义"的:只转 & < >,并把 @提及/频道/链接包成 <…> 记号。
|
|
292
|
-
* 展开成人能读、agent 也能读的普通文本;`botUserId` 的提及直接剥掉(不然每句都以 @bot 开头)。
|
|
293
|
-
*/
|
|
294
|
-
function parseText(raw, { botUserId = '' } = {}) {
|
|
295
|
-
let s = String(raw == null ? '' : raw)
|
|
296
|
-
if (botUserId) s = s.split(`<@${botUserId}>`).join(' ')
|
|
297
|
-
s = s
|
|
298
|
-
.replace(/<@([UWB][A-Z0-9]+)(\|([^>]*))?>/g, (_m, id, _p, label) => '@' + (label || id))
|
|
299
|
-
.replace(/<#([CGD][A-Z0-9]+)(\|([^>]*))?>/g, (_m, id, _p, label) => '#' + (label || id))
|
|
300
|
-
.replace(/<!(here|channel|everyone)(\|[^>]*)?>/g, '@$1')
|
|
301
|
-
.replace(/<!subteam\^[A-Z0-9]+(\|([^>]*))?>/g, (_m, _p, label) => label || '@group')
|
|
302
|
-
// 链接:有标签的还原成 Markdown 链接(agent 读得懂、回得出),裸链接保持原样
|
|
303
|
-
.replace(/<((?:https?|mailto):[^>|]+)\|([^>]*)>/g, '[$2]($1)')
|
|
304
|
-
.replace(/<((?:https?|mailto):[^>|]+)>/g, '$1')
|
|
305
|
-
for (const [k, v] of ENTITIES) s = s.split(k).join(v)
|
|
306
|
-
return s.trim()
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
/**
|
|
310
|
-
* ⚠⚠ **Slack 把 `/` 开头的输入当成它自己的斜杠命令吃掉**(真机 2026-09-01:群里发 `/status`,
|
|
311
|
-
* 消息**根本没到过 bot**)—— 也就是说 sbot 那套群内控制面(/stop /skip /new /status …)
|
|
312
|
-
* 在 Slack 群里整套失效,而 `/stop` 是出事时唯一的刹车。
|
|
313
|
-
*
|
|
314
|
-
* 两条对策,这里实现第一条(零 Slack 侧配置、立刻可用):**替代前缀** `!stop` → `/stop`。
|
|
315
|
-
* 第二条是在 manifest 注册一个 `/sint` 总入口(有自动补全,但要重装 app),走 slash_commands 事件。
|
|
316
|
-
*
|
|
317
|
-
* ⚠ 只还原**清单里真有的**命令 —— 否则一句以 `!` 开头的普通话会被引擎回一句「未知命令」。
|
|
318
|
-
* 清单由 bin 从 `src/core/engine.js` 的 SLASH_COMMANDS 注入(单一事实源,那边有漂移守卫)。
|
|
319
|
-
*/
|
|
320
|
-
function restoreCommand(text, { commands = [], alias = '!' } = {}) {
|
|
321
|
-
const s = String(text || '')
|
|
322
|
-
if (!alias || !s.startsWith(alias) || !commands.length) return s
|
|
323
|
-
const rest = s.slice(alias.length)
|
|
324
|
-
const head = rest.split(/\s/)[0].toLowerCase()
|
|
325
|
-
if (!commands.includes('/' + head)) return s
|
|
326
|
-
// ⚠ 命令词归一成小写再交给引擎 —— 引擎的 switch 是大小写敏感的,识别了却给它一个 `/STOP`
|
|
327
|
-
// 等于回一句「未知命令」。慌起来打 `!STOP` 的人正是最需要这条命令生效的人。
|
|
328
|
-
return '/' + head + rest.slice(head.length)
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
/**
|
|
332
|
-
* 出站文案里的命令写法本地化:`/stop` → `!stop`。与 `restoreCommand` 正好对称
|
|
333
|
-
* (入站 `!`→`/`,出站 `/`→`!`),两边共用同一份命令清单。
|
|
334
|
-
*
|
|
335
|
-
* ⚠ 为什么必须做:引擎的文案是平台无关的,里面写死了 `/` —— 比如换模型的提示原文是
|
|
336
|
-
* 「换模型:发『/model 名字』」。**在 Slack 群里照做是发不出去的**(平台把 `/` 开头
|
|
337
|
-
* 吞了),群成员照着做一次不灵,就会认为这个 bot 坏了。2026-09-01 真机撞见。
|
|
338
|
-
*
|
|
339
|
-
* ⚠ **只用在引擎自己说的话上(reply/send),绝不用在 agent 的答案上** —— 答案里出现
|
|
340
|
-
* `/status` 完全可能是一条 HTTP 路由,改了就是篡改内容。答案走的是卡片的 finish 那条路,
|
|
341
|
-
* 不经这里。
|
|
342
|
-
* ⚠ 代码块与行内代码整段跳过:那里面的 `/model` 多半是在讲别的系统。
|
|
343
|
-
*/
|
|
344
|
-
function localizeCommands(text, { commands = [], alias = '!' } = {}) {
|
|
345
|
-
const s = String(text == null ? '' : text)
|
|
346
|
-
if (!alias || alias === '/' || !commands.length) return s
|
|
347
|
-
const names = commands.map((c) => c.replace(/^\//, '')).filter(Boolean)
|
|
348
|
-
if (!names.length) return s
|
|
349
|
-
const re = new RegExp('(^|[\\s((「『"\'*_\\[·、,,。;;])\\/(' + names.join('|') + ')\\b', 'g')
|
|
350
|
-
// 用代码围栏/行内代码切段,奇数段是代码 —— 原样保留
|
|
351
|
-
return s.split(/(```[\s\S]*?```|`[^`\n]*`)/g)
|
|
352
|
-
.map((seg, i) => (i % 2 ? seg : seg.replace(re, (m, pre, cmd) => pre + alias + cmd)))
|
|
353
|
-
.join('')
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
/**
|
|
357
|
-
* 附件标记(与 feishu.js v0.11.7 同构):图片/文件消息**不能整条丢** —— 飞书那边真机踩过
|
|
358
|
-
* 「我发给你的图里的那张表」,图被静默丢掉,agent 完全不知道有图。标记里的 file_id 正是
|
|
359
|
-
* downloadResource 要的那个参数 ⇒ agent 顺着它自己就能把文件取下来。
|
|
360
|
-
*/
|
|
361
|
-
function attachmentMarks(files) {
|
|
362
|
-
return (Array.isArray(files) ? files : []).filter((f) => f && f.id).map((f) => {
|
|
363
|
-
const kind = String(f.mimetype || '').startsWith('image/') ? '图片' : '文件'
|
|
364
|
-
const name = f.name || f.title || ''
|
|
365
|
-
return `[${kind}${name ? ' ' + name : ''} file_id=${f.id}]`
|
|
366
|
-
})
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
/**
|
|
370
|
-
* 这条 message 事件要不要交给引擎。返回 'handle' | 'recall' | 丢弃原因(字符串)。
|
|
371
|
-
* ⚠ 三条自我回环防护(SLACK.md「收消息」)全在这里,漏一条就是无限自问自答:
|
|
372
|
-
* ① `message_changed` 必丢 —— chat.update 会把编辑事件打回给自己(30 分钟任务按 3 秒节流
|
|
373
|
-
* = 600 条回声),而且 Slack 的自动语言检测在你没动手时也会发它
|
|
374
|
-
* ② bot 判定看 `bot_id` 字段在不在,**不能**用 `subtype === 'bot_message'`(那是 classic app)
|
|
375
|
-
* ③ `message_deleted` 要放行给 onRecall,但**自己 chat.delete 触发的那条必须先滤掉**
|
|
376
|
-
* ⚠ `file_share` 单独放行 —— cc-bot 踩过"带图消息被 subtype 一刀切"(SLACK.md「收消息」)。
|
|
377
|
-
*/
|
|
378
|
-
function shouldHandle(event, { botUserId = '', onlyWhenMentioned = false, ownTs = null } = {}) {
|
|
379
|
-
if (!event || event.type !== 'message') return 'not-message'
|
|
380
|
-
const sub = event.subtype
|
|
381
|
-
if (sub === 'message_deleted') {
|
|
382
|
-
const dts = event.deleted_ts // ⚠ ts 是删除动作的时间,deleted_ts 才是被删的那条(SLACK.md「收消息」)
|
|
383
|
-
if (!dts) return 'deleted-no-ts'
|
|
384
|
-
if (ownTs && ownTs.has(dts)) return 'own-delete'
|
|
385
|
-
return 'recall'
|
|
386
|
-
}
|
|
387
|
-
if (sub === 'message_changed') return 'echo-changed'
|
|
388
|
-
if (sub && sub !== 'file_share') return 'subtype:' + sub
|
|
389
|
-
if (event.bot_id) return 'bot-message'
|
|
390
|
-
if (botUserId && event.user === botUserId) return 'self'
|
|
391
|
-
if (!event.channel) return 'no-channel'
|
|
392
|
-
if (onlyWhenMentioned && event.channel_type !== 'im' && !(botUserId && String(event.text || '').includes(`<@${botUserId}>`))) return 'not-mentioned'
|
|
393
|
-
return 'handle'
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
// ── event_id 去重(SLACK.md「可靠性」)────────────────────────────────────────────────────────
|
|
397
|
-
// ⚠ Socket Mode 的 envelope **没有 retry 标记**(官方未记载),分不出首投还是重投 ——
|
|
398
|
-
// 只能靠 event_id。TTL 要盖住 Slack 的重试点(1 分钟 / 5 分钟),默认 10 分钟。
|
|
399
|
-
function createDedupe({ ttlMs = 10 * 60 * 1000, max = 5000, now = () => Date.now() } = {}) {
|
|
400
|
-
const seen = new Map() // id -> 过期时刻
|
|
401
|
-
return {
|
|
402
|
-
/** true = 第一次见(应处理);false = 重投(丢弃) */
|
|
403
|
-
take(id) {
|
|
404
|
-
if (!id) return true // 没 id 就没法去重,宁可重复也不丢
|
|
405
|
-
const t = now()
|
|
406
|
-
const exp = seen.get(id)
|
|
407
|
-
if (exp && exp > t) return false
|
|
408
|
-
seen.delete(id) // 重新插到队尾,淘汰按插入序才准
|
|
409
|
-
seen.set(id, t + ttlMs)
|
|
410
|
-
if (seen.size > max) {
|
|
411
|
-
for (const [k, e] of seen) if (e <= t) seen.delete(k) // 先清过期
|
|
412
|
-
for (const k of seen.keys()) { if (seen.size <= max) break; seen.delete(k) } // 还超就淘汰最旧
|
|
413
|
-
}
|
|
414
|
-
return true
|
|
415
|
-
},
|
|
416
|
-
size: () => seen.size,
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
// ── 事件年龄闸门(SLACK.md「可靠性」)────────────────────────────────────────────
|
|
421
|
-
// ⚠ 开了 Delayed Events,守护进程宕机 3 小时后恢复,会收到一堆 20 小时前的"帮我跑一下"
|
|
422
|
-
// 并**直接启动一堆过期任务**。⇒ 超龄的一律丢弃(带日志),别让它们进引擎。
|
|
423
|
-
function isStale(eventTimeSec, { maxAgeMs = 10 * 60 * 1000, now = () => Date.now() } = {}) {
|
|
424
|
-
const t = Number(eventTimeSec)
|
|
425
|
-
if (!t) return false // 没有时间戳就别猜
|
|
426
|
-
return now() - t * 1000 > maxAgeMs
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
// ── Web API 封装 ──────────────────────────────────────────────────────────────────
|
|
430
|
-
/**
|
|
431
|
-
* 唯一的出站入口。四条纪律焊死在这里:
|
|
432
|
-
* ① `ok:false` 显式抛(SLACK.md「可靠性」)—— 不抛就会把"没发出去"当成发出去了
|
|
433
|
-
* ② 任何 warning 都上报(SLACK.md「静默失败」)—— 那张静默失败表近一半只在这个字段里露头
|
|
434
|
-
* ③ 重试**短而有界**(SLACK.md「可靠性」):默认最多 2 次、总等待 ≤10 秒。SDK 默认那套
|
|
435
|
-
* "10 次 / 约 30 分钟"会让 postMessage 重复发、让进度更新卡在队列里冻住
|
|
436
|
-
* ④ 只对 429 / 5xx / 网络错误重试;业务错误一次就抛(重试也不会变对)
|
|
437
|
-
*/
|
|
438
|
-
/**
|
|
439
|
-
* ⚠⚠ **出站一律 form 编码,不许用 JSON body**(2026-09-01 真机实测,官方文档没有这一条)。
|
|
440
|
-
* 读类方法会**静默忽略** JSON body 里的参数:`users.list {limit:2}` 走 JSON 返回 **8 个人且
|
|
441
|
-
* `ok:true`**,走 form 才返回 2 个;`users.info {user:U…}` 走 JSON 直接报 `user_not_found`
|
|
442
|
-
* (参数根本没被读到,不是没这个人)。⇒ 这是 SLACK.md「静默失败」「ok:true 但什么都没发生」那一族的新成员,
|
|
443
|
-
* 而且是最阴的一种:**分页/过滤参数被忽略时连错误都没有**。
|
|
444
|
-
* 嵌套值(blocks / files)按官方 form 约定序列化成 JSON 字符串,写方法实测通过。
|
|
445
|
-
*/
|
|
446
|
-
function encodeParams(body) {
|
|
447
|
-
const p = new URLSearchParams()
|
|
448
|
-
for (const [k, v] of Object.entries(body || {})) {
|
|
449
|
-
if (v === undefined || v === null) continue
|
|
450
|
-
p.set(k, typeof v === 'object' ? JSON.stringify(v) : String(v))
|
|
451
|
-
}
|
|
452
|
-
return p
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
function createSlackApi(token, { log = () => {}, fetchImpl = null, sleepImpl = sleep, timeoutMs = 15_000, retryCapMs = 10_000 } = {}) {
|
|
456
|
-
const doFetch = fetchImpl || netFetch
|
|
457
|
-
return async function call(method, body = {}, { retries = 2, tokenOverride = null, onHeaders = null } = {}) {
|
|
458
|
-
let attempt = 0
|
|
459
|
-
let waited = 0
|
|
460
|
-
for (;;) {
|
|
461
|
-
let res
|
|
462
|
-
try {
|
|
463
|
-
res = await doFetch(API + method, {
|
|
464
|
-
method: 'POST',
|
|
465
|
-
headers: {
|
|
466
|
-
Authorization: `Bearer ${tokenOverride || token}`,
|
|
467
|
-
// ⚠ 不带 charset —— 带了 Slack 每条响应都回一个 `superfluous_charset` warning
|
|
468
|
-
// (真机 2026-09-01 实测)。form 编码本来就是 UTF-8,写了纯属噪音,
|
|
469
|
-
// 而噪音会把真正的 warning(那张静默失败表)淹掉。
|
|
470
|
-
'Content-Type': 'application/x-www-form-urlencoded',
|
|
471
|
-
},
|
|
472
|
-
body: encodeParams(body),
|
|
473
|
-
signal: AbortSignal.timeout(timeoutMs),
|
|
474
|
-
})
|
|
475
|
-
} catch (e) {
|
|
476
|
-
if (attempt++ < retries) { const w = Math.min(1000 * 2 ** attempt, retryCapMs - waited); if (w > 0) { waited += w; await sleepImpl(w); continue } }
|
|
477
|
-
throw new SlackError('request_timeout', method, { cause: (e && e.message) || String(e) })
|
|
478
|
-
}
|
|
479
|
-
if (res.status === 429) {
|
|
480
|
-
// ⚠ 超限后继续硬发,官方警告 app 有被永久停用的风险 ⇒ 严格按 Retry-After 等
|
|
481
|
-
const after = Math.max(1, Number(res.headers && res.headers.get && res.headers.get('retry-after')) || 1) * 1000
|
|
482
|
-
if (attempt++ < retries && waited + after <= retryCapMs) { waited += after; log(`slack ${method}: 429,等 ${after / 1000}s 重试`); await sleepImpl(after); continue }
|
|
483
|
-
throw new SlackError('ratelimited', method, { retryAfterMs: after })
|
|
484
|
-
}
|
|
485
|
-
if (res.status >= 500) {
|
|
486
|
-
if (attempt++ < retries) { const w = Math.min(1000 * 2 ** attempt, Math.max(0, retryCapMs - waited)); if (w > 0) { waited += w; await sleepImpl(w); continue } }
|
|
487
|
-
throw new SlackError('service_unavailable', method, { status: res.status })
|
|
488
|
-
}
|
|
489
|
-
// 已授予的 scope 只出现在响应头里(没有专门的接口)⇒ 预检要用就得在这层给出去
|
|
490
|
-
if (onHeaders) { try { onHeaders(res.headers) } catch {} }
|
|
491
|
-
const j = await res.json().catch(() => null)
|
|
492
|
-
if (!j || typeof j !== 'object') throw new SlackError('invalid_response', method, { status: res.status })
|
|
493
|
-
if (!j.ok) throw new SlackError(j.error || 'unknown_error', method, j)
|
|
494
|
-
const warn = j.warning || (j.response_metadata && j.response_metadata.warnings)
|
|
495
|
-
if (warn) log(`⚠ slack ${method} 有 warning(可能是静默失败,见 https://github.com/sintlab/sintbot/blob/main/docs/zh-CN/SLACK.md「静默失败」): ${JSON.stringify(warn).slice(0, 200)}`)
|
|
496
|
-
return j
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
// ── 附件下载(SLACK.md「收消息」)────────────────────────────────────────────────────────────────
|
|
502
|
-
// ⚠ 认证缺失时 Slack 返回 **HTTP 200 + HTML 登录页**,不是 401 —— 不嗅探就会把一堆 HTML
|
|
503
|
-
// 写进 .png,之后 vision 那条链路报的错完全指不到这里。
|
|
504
|
-
async function downloadFile(call, token, fileId, destPath, { fetchImpl = null } = {}) {
|
|
505
|
-
const doFetch = fetchImpl || netFetch
|
|
506
|
-
const info = await call('files.info', { file: fileId })
|
|
507
|
-
const f = (info && info.file) || {}
|
|
508
|
-
const url = f.url_private_download || f.url_private
|
|
509
|
-
if (!url) throw new SlackError('file_not_found', 'files.info', { fileId })
|
|
510
|
-
const res = await doFetch(url, { headers: { Authorization: `Bearer ${token}` }, redirect: 'follow' })
|
|
511
|
-
if (!res.ok) throw new SlackError('download_failed', 'files.download', { status: res.status })
|
|
512
|
-
const ctype = String((res.headers && res.headers.get && res.headers.get('content-type')) || '')
|
|
513
|
-
const buf = Buffer.from(await res.arrayBuffer())
|
|
514
|
-
const head = buf.slice(0, 64).toString('utf8').trimStart().toLowerCase()
|
|
515
|
-
if (ctype.includes('text/html') || head.startsWith('<!doctype') || head.startsWith('<html')) {
|
|
516
|
-
throw new SlackError('download_got_login_page', 'files.download', { hint: '凭据无效或缺 files:read —— Slack 用 200 + 登录页表达这件事' })
|
|
517
|
-
}
|
|
518
|
-
const part = destPath + '.part' // 写完再改名:半截文件绝不出现在最终路径上
|
|
519
|
-
fs.mkdirSync(path.dirname(destPath), { recursive: true })
|
|
520
|
-
fs.writeFileSync(part, buf)
|
|
521
|
-
fs.renameSync(part, destPath)
|
|
522
|
-
return { path: destPath, bytes: buf.length, name: f.name || path.basename(destPath), mimetype: f.mimetype || ctype }
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
/**
|
|
526
|
-
* 进度更新节流器。⚠ 引擎每来一段助手文本就调一次 `update`(engine.js:1749),外加 15 秒一跳的
|
|
527
|
-
* 脉搏 —— 不节流会直接把 `chat.update` 的 **Tier 3(50/min,per workspace per app,不按频道拆)**
|
|
528
|
-
* 打满,而配额是整个 app 共享的:12 个并发任务按 4 次/分就到顶了(SLACK.md「进度表达」)。
|
|
529
|
-
* ⭐ 撞到 429 就自动把间隔翻倍(封顶 60s)—— 官方警告超限后继续硬发有被永久停用的风险。
|
|
530
|
-
* 语义:窗口内只留最后一帧(进度是全量重写,中间帧丢了无所谓);内容没变则整帧跳过。
|
|
531
|
-
*/
|
|
532
|
-
function createProgressThrottle({ intervalMs = 20_000, send, now = () => Date.now(), setTimer = setTimeout, clearTimer = clearTimeout, log = () => {} } = {}) {
|
|
533
|
-
let last = 0, pending = null, timer = null, sentText = null, closed = false
|
|
534
|
-
let gap = Math.max(1000, Number(intervalMs) || 20_000)
|
|
535
|
-
const fire = async () => {
|
|
536
|
-
timer = null
|
|
537
|
-
if (closed || pending == null) return
|
|
538
|
-
const text = pending
|
|
539
|
-
pending = null
|
|
540
|
-
if (text === sentText) return // 没变就别发 —— 一次白发也是一次配额
|
|
541
|
-
last = now()
|
|
542
|
-
sentText = text
|
|
543
|
-
try {
|
|
544
|
-
await send(text)
|
|
545
|
-
} catch (e) {
|
|
546
|
-
if (e && (e.code === 'ratelimited' || e.code === 'rate_limited')) {
|
|
547
|
-
gap = Math.min(60_000, gap * 2)
|
|
548
|
-
log(`slack 进度刷新撞到限流,间隔放大到 ${Math.round(gap / 1000)}s`)
|
|
549
|
-
}
|
|
550
|
-
sentText = null // 没发成功就别把它记成已发,下一帧照发
|
|
551
|
-
}
|
|
552
|
-
}
|
|
553
|
-
return {
|
|
554
|
-
update(text) {
|
|
555
|
-
if (closed) return
|
|
556
|
-
pending = String(text == null ? '' : text)
|
|
557
|
-
if (timer) return
|
|
558
|
-
const wait = Math.max(0, gap - (now() - last))
|
|
559
|
-
timer = setTimer(fire, wait)
|
|
560
|
-
if (timer && timer.unref) timer.unref()
|
|
561
|
-
},
|
|
562
|
-
/** 排干在途的一帧 —— 收尾前必须调,否则收尾后还会被补一帧盖回去 */
|
|
563
|
-
async settle() {
|
|
564
|
-
if (timer) { clearTimer(timer); timer = null }
|
|
565
|
-
await fire()
|
|
566
|
-
},
|
|
567
|
-
close() { closed = true; if (timer) { clearTimer(timer); timer = null } },
|
|
568
|
-
get intervalMs() { return gap },
|
|
569
|
-
}
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
// ── 控制台助手(与 feishu.js 的 feishuPrecheck / fetchChats 平级)────────────────────
|
|
573
|
-
// 这些不参与消息收发,只服务控制台的「Slack 应用」卡与绑群下拉。
|
|
574
|
-
|
|
575
|
-
// 少一条就会静默失效的 scope(SLACK.md「scope 与 manifest」;分类依据是"缺了会怎样",不是官方文档的排列)
|
|
576
|
-
const REQUIRED_SCOPES = ['chat:write', 'channels:history', 'channels:read', 'users:read']
|
|
577
|
-
const RECOMMENDED_SCOPES = {
|
|
578
|
-
'groups:history': '私有频道收不到消息',
|
|
579
|
-
'im:history': '单人 DM 收不到消息',
|
|
580
|
-
'mpim:history': '群组 DM 收不到消息',
|
|
581
|
-
'groups:read': '私有频道不出现在绑群列表里',
|
|
582
|
-
'reactions:write': '收到消息时的 👀 回执发不出',
|
|
583
|
-
'files:read': '群成员发的图片/文件下载不了',
|
|
584
|
-
'chat:write.public': '没被 /invite 的公开频道发不了消息',
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
/**
|
|
588
|
-
* 一次性把「能不能用」查清楚,每条都给可照做的下一步 —— 与飞书那张预检卡同一形态。
|
|
589
|
-
* ⚠ scope 从响应头 `x-oauth-scopes` 读:Slack 没有"查我有哪些 scope"的接口,
|
|
590
|
-
* 而缺 scope 的典型症状是**事件静默不来**(SLACK.md「静默失败」),不主动对照就只能靠猜。
|
|
591
|
-
*/
|
|
592
|
-
async function slackPrecheck(botToken, appToken, { fetchImpl = null } = {}) {
|
|
593
|
-
const call = createSlackApi(botToken, { fetchImpl })
|
|
594
|
-
const checks = []
|
|
595
|
-
let botUserId = null
|
|
596
|
-
let identity = null
|
|
597
|
-
let scopes = []
|
|
598
|
-
|
|
599
|
-
try {
|
|
600
|
-
const me = await call('auth.test', {}, { retries: 1, onHeaders: (h) => { scopes = String((h.get && h.get('x-oauth-scopes')) || '').split(',').map((x) => x.trim()).filter(Boolean) } })
|
|
601
|
-
botUserId = me.user_id || null
|
|
602
|
-
identity = { botName: me.user || '', botUserId, team: me.team || '', teamId: me.team_id || '', url: me.url || '' }
|
|
603
|
-
checks.push({ key: 'auth', status: 'ok', label: 'bot 凭据', detail: `${me.user || 'bot'} · ${me.team || ''}` })
|
|
604
|
-
} catch (e) {
|
|
605
|
-
checks.push({ key: 'auth', status: 'fail', label: 'bot 凭据', detail: slackHint(e) })
|
|
606
|
-
return { checks, botUserId, identity, scopes }
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
const missing = REQUIRED_SCOPES.filter((x) => !scopes.includes(x))
|
|
610
|
-
checks.push(missing.length
|
|
611
|
-
? { key: 'scopes', status: 'fail', label: '必需权限', detail: `缺 ${missing.join(' / ')} —— 改 manifest 后要**重新安装**才生效` }
|
|
612
|
-
: { key: 'scopes', status: 'ok', label: '必需权限', detail: `${scopes.length} 项已授予` })
|
|
613
|
-
const weak = Object.entries(RECOMMENDED_SCOPES).filter(([k]) => !scopes.includes(k))
|
|
614
|
-
if (weak.length) checks.push({ key: 'scopes-opt', status: 'warn', label: '可选权限', detail: weak.map(([k, why]) => `${k}(${why})`).join(' · ') })
|
|
615
|
-
|
|
616
|
-
// app-level token:除了真去开一次连接,没有别的验法(拿到 URL 就够了,不连)
|
|
617
|
-
try {
|
|
618
|
-
await call('apps.connections.open', {}, { retries: 0, tokenOverride: appToken })
|
|
619
|
-
checks.push({ key: 'socket', status: 'ok', label: 'Socket Mode', detail: 'app token 有效,长连接可建立' })
|
|
620
|
-
} catch (e) {
|
|
621
|
-
const code = (e && e.code) || String(e)
|
|
622
|
-
checks.push({ key: 'socket', status: 'fail', label: 'Socket Mode', detail: code === 'invalid_auth' ? 'xapp 无效 —— Basic Information → App-Level Tokens 重取(要勾 connections:write)' : code })
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
try {
|
|
626
|
-
const chans = await fetchSlackChannels(botToken, { fetchImpl })
|
|
627
|
-
checks.push(chans.length
|
|
628
|
-
? { key: 'channels', status: 'ok', label: '已加入的频道', detail: `${chans.length} 个 —— 绑群时从这里选` }
|
|
629
|
-
: { key: 'channels', status: 'warn', label: '已加入的频道', detail: '一个都没有 —— 去频道里发 `/invite @bot`,不做这步收不到任何消息' })
|
|
630
|
-
} catch (e) {
|
|
631
|
-
checks.push({ key: 'channels', status: 'warn', label: '已加入的频道', detail: slackHint(e) })
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
return { checks, botUserId, identity, scopes }
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
/**
|
|
638
|
-
* bot 已加入的频道(绑群下拉的数据源)。
|
|
639
|
-
* ⚠ 两个静默失败点都在这三行里(SLACK.md「静默失败」):**`types` 必须显式给全** —— 默认只有
|
|
640
|
-
* `public_channel`,私有群/DM 会整体缺席且不报错;**分页必须走 cursor** ——
|
|
641
|
-
* 服务端过滤在分页之后,某页只回几条但 `next_cursor` 非空,`len<limit` 判不出结束。
|
|
642
|
-
* ⭐ 用 `users.conversations` 而不是 `conversations.list`:前者只回**bot 自己在里面的**频道,
|
|
643
|
-
* 而 bot 不在的频道绑了也收不到消息 —— 列出来只会让人绑一个死群。
|
|
644
|
-
*/
|
|
645
|
-
async function fetchSlackChannels(botToken, { fetchImpl = null, maxPages = 10 } = {}) {
|
|
646
|
-
const call = createSlackApi(botToken, { fetchImpl })
|
|
647
|
-
const out = []
|
|
648
|
-
let cursor = ''
|
|
649
|
-
for (let i = 0; i < maxPages; i++) {
|
|
650
|
-
const r = await call('users.conversations', {
|
|
651
|
-
// ⚠ **只列频道,不列 DM** —— 两个理由:① sbot 是"一群一项目",DM 不是绑定对象;
|
|
652
|
-
// ② 真机 2026-09-01 实测:带上 `mpim,im` 会报 `missing_scope needed=mpim:read` ——
|
|
653
|
-
// **`mpim:history` 并不蕴含 `mpim:read`**(收得到消息 ≠ 列得出会话),要 DM 就得多要
|
|
654
|
-
// 两个 scope 并重装 app。不值当。
|
|
655
|
-
types: 'public_channel,private_channel',
|
|
656
|
-
exclude_archived: true,
|
|
657
|
-
limit: 200,
|
|
658
|
-
...(cursor ? { cursor } : {}),
|
|
659
|
-
}, { retries: 1 })
|
|
660
|
-
for (const c of r.channels || []) {
|
|
661
|
-
out.push({
|
|
662
|
-
id: c.id,
|
|
663
|
-
name: c.is_im ? `DM · ${c.user || c.id}` : (c.name || c.id),
|
|
664
|
-
isPrivate: !!c.is_private,
|
|
665
|
-
isIm: !!c.is_im,
|
|
666
|
-
members: Number(c.num_members) || 0,
|
|
667
|
-
})
|
|
668
|
-
}
|
|
669
|
-
cursor = (r.response_metadata && r.response_metadata.next_cursor) || ''
|
|
670
|
-
if (!cursor) break
|
|
671
|
-
}
|
|
672
|
-
return out
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
/** 一次性发一条文本(控制台绑群回执用;与 feishu.js 的 sendChatText 平级)。 */
|
|
676
|
-
async function sendSlackText(botToken, channel, text, { fetchImpl = null } = {}) {
|
|
677
|
-
const call = createSlackApi(botToken, { fetchImpl })
|
|
678
|
-
const r = await call('chat.postMessage', messagePayload(channel, text), { retries: 1 })
|
|
679
|
-
return (r && r.ts) || null
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
// ── 平台本体 ──────────────────────────────────────────────────────────────────────
|
|
683
|
-
/**
|
|
684
|
-
* @param {object} o
|
|
685
|
-
* botToken xoxb-… (chat:write, channels:history, reactions:write, files:read, users:read)
|
|
686
|
-
* appToken xapp-… (connections:write) —— 只用于 apps.connections.open
|
|
687
|
-
* botUserId 可选;不传则 start() 时用 auth.test 自取(顺带验证凭据)
|
|
688
|
-
* onlyWhenMentioned 公开频道里只在被 @ 时响应(DM 永远响应)
|
|
689
|
-
* replyInThread 回复挂进线程(默认 true,SLACK.md「收消息」:Slack 的"回复"就是 thread)
|
|
690
|
-
* maxEventAgeMs 事件年龄闸门,默认 10 分钟(SLACK.md「可靠性」)
|
|
691
|
-
* ws {} 重连调优:backoffBaseMs / backoffCapMs / staleReconnectingMs /
|
|
692
|
-
* tickIntervalMs / refreshRatio / debugReconnects
|
|
693
|
-
*/
|
|
694
|
-
function createSlackPlatform({
|
|
695
|
-
botToken, appToken, botUserId = '', onlyWhenMentioned = false,
|
|
696
|
-
replyInThread = true, maxEventAgeMs = 10 * 60 * 1000,
|
|
697
|
-
commands = [], commandAlias = '!', umbrellaCommand = '/sint',
|
|
698
|
-
card = {}, log = () => {}, onConn = () => {}, ws = {}, fetchImpl = null,
|
|
699
|
-
} = {}) {
|
|
700
|
-
if (!botToken || !appToken) throw new Error('slack: botToken (xoxb-) and appToken (xapp-) required')
|
|
701
|
-
// env 里有代理变量但我们用不了(典型:shell 的 all_proxy=socks5://…)⇒ 出网会直连,**要说出来**:
|
|
702
|
-
// 直连被墙的错误形态完全指不到"代理没生效"。只在没注 fetchImpl 时说(注了 = 单测/探针,不出网)。
|
|
703
|
-
if (!fetchImpl) { const w = proxyWarning(); if (w) log('⚠ ' + w) }
|
|
704
|
-
|
|
705
|
-
const call = createSlackApi(botToken, { log, fetchImpl })
|
|
706
|
-
const dedupe = createDedupe()
|
|
707
|
-
const ownTs = new Set() // 自己发过/删过的 ts —— 回环与"自杀式撤回"防护(SLACK.md「收消息」)
|
|
708
|
-
const nameCache = new Map() // userId -> 名字 | null
|
|
709
|
-
let selfId = botUserId
|
|
710
|
-
let sock = null // 当前生效的连接
|
|
711
|
-
let standby = null // 无缝切换期间的新连接(disconnect:warning / 主动刷新)
|
|
712
|
-
let connecting = false
|
|
713
|
-
let stopped = false
|
|
714
|
-
let fatal = null // 致命凭据错误 ⇒ 停止一切重试(SLACK.md「可靠性」)
|
|
715
|
-
let fatalAt = 0
|
|
716
|
-
// 启动期就撞上致命凭据错误时没有看门狗,健康快照由它出(形状与看门狗快照一致)
|
|
717
|
-
const fatalSnapshot = () => ({ state: 'failed', since: fatalAt, lastReadyAt: 0, lastEventAt: 0, reconnects: 0, rebuilds: 0, lastError: `凭据失效(${fatal})` })
|
|
718
|
-
let watchdog = null
|
|
719
|
-
let refreshTimer = null, retryTimer = null, tickTimer = null
|
|
720
|
-
let approxLifeSec = 0
|
|
721
|
-
let consecFail = 0
|
|
722
|
-
|
|
723
|
-
const tickIntervalMs = num(ws.tickIntervalMs, 30_000)
|
|
724
|
-
const refreshRatio = Number(ws.refreshRatio) > 0 && Number(ws.refreshRatio) < 1 ? Number(ws.refreshRatio) : 0.9
|
|
725
|
-
const wdOpts = {
|
|
726
|
-
staleReconnectingMs: num(ws.staleReconnectingMs, 120_000),
|
|
727
|
-
backoffBaseMs: num(ws.backoffBaseMs, 2_000),
|
|
728
|
-
backoffCapMs: num(ws.backoffCapMs, 60_000),
|
|
729
|
-
}
|
|
730
|
-
// ⚠ apps.connections.open 是 Tier 3(50/min),重连间隔**不得低于 1.2 秒**,
|
|
731
|
-
// 否则崩溃循环会撞 429 → 更连不上(SLACK.md「可靠性」)。抖动是为了避免双机同时重连。
|
|
732
|
-
const reconnectDelay = () => Math.min(wdOpts.backoffCapMs, Math.max(1200, wdOpts.backoffBaseMs * 2 ** Math.min(consecFail, 5))) * (0.8 + Math.random() * 0.4)
|
|
733
|
-
|
|
734
|
-
const clearTimer = (t) => { if (t) { try { clearTimeout(t) } catch {} } return null }
|
|
735
|
-
const remember = (ts) => {
|
|
736
|
-
if (!ts) return
|
|
737
|
-
ownTs.add(ts)
|
|
738
|
-
for (const k of ownTs) { if (ownTs.size <= 2000) break; ownTs.delete(k) } // Set 保序,淘汰最旧
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
async function nameOf(userId) {
|
|
742
|
-
if (!userId) return null
|
|
743
|
-
if (nameCache.has(userId)) return nameCache.get(userId)
|
|
744
|
-
try {
|
|
745
|
-
const r = await call('users.info', { user: userId }, { retries: 0 })
|
|
746
|
-
const u = (r && r.user) || {}
|
|
747
|
-
const name = (u.profile && (u.profile.display_name || u.profile.real_name)) || u.real_name || u.name || null
|
|
748
|
-
nameCache.set(userId, name)
|
|
749
|
-
return name
|
|
750
|
-
} catch (e) {
|
|
751
|
-
nameCache.set(userId, null) // 缓存失败,别每条消息都去撞一次(缺 users:read 时尤其)
|
|
752
|
-
log('slack users.info 失败(发送者名回退为 user id): ' + (e && e.message))
|
|
753
|
-
return null
|
|
754
|
-
}
|
|
755
|
-
}
|
|
756
|
-
|
|
757
|
-
// ⚠ 这里的重试是**明示的取舍**:网络超时但服务端其实已成功时,重试会把同一条答案发两遍
|
|
758
|
-
// (SLACK.md「可靠性」 点名的风险)。Slack 的 chat.postMessage 没有幂等键,二选一 ——
|
|
759
|
-
// 而本仓的口径一向是「把消息弄丢才是事故」(见 feishu.js reply 的兜底注释),
|
|
760
|
-
// 所以选重复。⭐ 进度更新走 updateMessage(retries:0),不吃这个取舍。
|
|
761
|
-
async function postMessage(channel, content, { threadTs = null, retries = 2, actions = null } = {}) {
|
|
762
|
-
if (!channel || !content) return null
|
|
763
|
-
try {
|
|
764
|
-
const r = await call('chat.postMessage', messagePayload(channel, content, { threadTs, actions }), { retries })
|
|
765
|
-
// ⚠ "送达"以副作用为准,不以 ok:true 为准(SLACK.md「静默失败」):没有 ts 就是没发出去
|
|
766
|
-
if (!r || !r.ts) { log('slack postMessage: ok 但没有 ts —— 按未送达处理'); return null }
|
|
767
|
-
remember(r.ts)
|
|
768
|
-
return r.ts
|
|
769
|
-
} catch (e) {
|
|
770
|
-
onApiError('chat.postMessage', e, channel)
|
|
771
|
-
return null
|
|
772
|
-
}
|
|
773
|
-
}
|
|
774
|
-
|
|
775
|
-
// 进度消息的初值。引擎马上就会用真正的进度覆盖它;它只是"这一轮已经开始了"的占位。
|
|
776
|
-
const T_PROGRESS_INIT = '⏳ 处理中…'
|
|
777
|
-
|
|
778
|
-
/** 👀/⏳/✅ 这类回执。already_reacted 视为成功(重启后补发同一个不该报错)。 */
|
|
779
|
-
async function addReactionTo(channel, ts, name) {
|
|
780
|
-
if (!channel || !ts || !name) return false
|
|
781
|
-
try { await call('reactions.add', { channel, timestamp: ts, name }, { retries: 1 }); return true } catch (e) {
|
|
782
|
-
if (e && e.code === 'already_reacted') return true
|
|
783
|
-
log(`slack reactions.add(${name}) 失败: ${(e && e.code) || e}`)
|
|
784
|
-
return false
|
|
785
|
-
}
|
|
786
|
-
}
|
|
787
|
-
async function removeReactionFrom(channel, ts, name) {
|
|
788
|
-
if (!channel || !ts || !name) return false
|
|
789
|
-
try { await call('reactions.remove', { channel, timestamp: ts, name }, { retries: 0 }); return true } catch (e) {
|
|
790
|
-
if (e && (e.code === 'no_reaction' || e.code === 'message_not_found')) return true
|
|
791
|
-
log(`slack reactions.remove(${name}) 失败: ${(e && e.code) || e}`)
|
|
792
|
-
return false
|
|
793
|
-
}
|
|
794
|
-
}
|
|
795
|
-
/** 删自己发的消息。⚠ 先记下 ts —— 否则删除事件打回来会被当成"用户撤回即停"(SLACK.md「收消息」)。 */
|
|
796
|
-
async function deleteOwnMessage(channel, ts) {
|
|
797
|
-
if (!channel || !ts) return false
|
|
798
|
-
remember(ts)
|
|
799
|
-
try { await call('chat.delete', { channel, ts }, { retries: 1 }); return true } catch (e) { log('slack chat.delete 失败: ' + ((e && e.code) || e)); return false }
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
function onApiError(where, e, channel) {
|
|
803
|
-
const kind = (e && e.kind) || 'other'
|
|
804
|
-
if (kind === 'fatal') {
|
|
805
|
-
fatal = e.code
|
|
806
|
-
log(`🔴 slack ${where} 致命错误 ${e.code} —— 凭据失效,已停止重试。去 api.slack.com 重取 token 后重启守护进程。`)
|
|
807
|
-
try { if (watchdog) watchdog.onError(e) } catch {}
|
|
808
|
-
} else if (kind === 'channel') {
|
|
809
|
-
log(`slack ${where}: 频道级错误 ${e.code}(channel=${channel || '?'})—— 该群应从路由表摘掉`)
|
|
810
|
-
} else {
|
|
811
|
-
log(`slack ${where} error: ${(e && e.message) || e}`)
|
|
812
|
-
}
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
// ── Socket Mode ─────────────────────────────────────────────────────────────
|
|
816
|
-
function closeSocket(s, why) {
|
|
817
|
-
if (!s) return
|
|
818
|
-
try { s.__intentional = true; s.close() } catch {}
|
|
819
|
-
log(`slack socket closed (${why})`)
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
function scheduleReconnect() {
|
|
823
|
-
if (stopped || fatal || retryTimer || connecting || standby) return // ⚠ 候补在途时再开一条 = too_many_websockets
|
|
824
|
-
const d = Math.round(reconnectDelay())
|
|
825
|
-
retryTimer = setTimeout(() => { retryTimer = null; openSocket({ replacement: false }) }, d)
|
|
826
|
-
if (retryTimer.unref) retryTimer.unref()
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
async function openSocket({ replacement = false } = {}) {
|
|
830
|
-
if (stopped || fatal || connecting) return
|
|
831
|
-
if (replacement && standby) return // ⚠ 同时只允许一条候补 —— 多开就是 too_many_websockets(SLACK.md「可靠性」)
|
|
832
|
-
connecting = true
|
|
833
|
-
let url
|
|
834
|
-
try {
|
|
835
|
-
// ⚠ app-level token 必须放 Authorization header,做 POST 参数会报错(2026-08-31 实测)
|
|
836
|
-
const r = await call('apps.connections.open', {}, { retries: 1, tokenOverride: appToken })
|
|
837
|
-
url = r && r.url
|
|
838
|
-
if (!url) throw new SlackError('no_url', 'apps.connections.open', r)
|
|
839
|
-
} catch (e) {
|
|
840
|
-
connecting = false
|
|
841
|
-
consecFail++
|
|
842
|
-
onApiError('apps.connections.open', e, null)
|
|
843
|
-
if (!fatal) { if (watchdog) watchdog.onReconnecting(); scheduleReconnect() }
|
|
844
|
-
return
|
|
845
|
-
}
|
|
846
|
-
// ⭐ 调试神技:连接寿命缩到 360 秒,断线重连逻辑 6 分钟一轮而不是几小时(SLACK.md「可靠性」)
|
|
847
|
-
if (ws.debugReconnects) url += (url.includes('?') ? '&' : '?') + 'debug_reconnects=true'
|
|
848
|
-
|
|
849
|
-
// ⚠ WS 与 REST **共用同一个隧道 agent**:`ws` 同样不读 `http_proxy`,而它不走代理时的
|
|
850
|
-
// 表现是**「连上了但一直没 Hello」** —— 连 error 都不报,最像"Slack 挂了"的一种失败。
|
|
851
|
-
const s = new WebSocket(url, { agent: getProxyAgent() || undefined })
|
|
852
|
-
s.__replacement = replacement
|
|
853
|
-
s.addEventListener('open', () => { connecting = false })
|
|
854
|
-
s.addEventListener('message', (ev) => onFrame(s, ev && ev.data))
|
|
855
|
-
s.addEventListener('error', (ev) => {
|
|
856
|
-
connecting = false
|
|
857
|
-
const msg = (ev && (ev.message || (ev.error && ev.error.message))) || 'websocket error'
|
|
858
|
-
log('slack socket error: ' + msg)
|
|
859
|
-
})
|
|
860
|
-
s.addEventListener('close', () => {
|
|
861
|
-
connecting = false
|
|
862
|
-
const wasStandby = s === standby
|
|
863
|
-
if (wasStandby) standby = null
|
|
864
|
-
else if (s === sock) sock = null
|
|
865
|
-
else return // 交接时关掉的旧连接 —— 早就不是当前连接了,不触发任何动作
|
|
866
|
-
if (stopped || fatal) return
|
|
867
|
-
if (sock) return // 主连接还在(候补没起来 / 已交接完)—— 别再开一条
|
|
868
|
-
if (s.__intentional && !wasStandby) return
|
|
869
|
-
consecFail++
|
|
870
|
-
if (watchdog) watchdog.onReconnecting()
|
|
871
|
-
// ⚠ 断线期间的事件 Slack **不补发**(SLACK.md「可靠性」:"you may lose events until you
|
|
872
|
-
// establish a connection")—— 这里只能尽快连回来;补拉历史要等接线时按群做。
|
|
873
|
-
scheduleReconnect()
|
|
874
|
-
})
|
|
875
|
-
if (replacement) standby = s
|
|
876
|
-
else sock = s
|
|
877
|
-
}
|
|
878
|
-
|
|
879
|
-
function promote(s) {
|
|
880
|
-
if (s !== standby) return
|
|
881
|
-
const old = sock
|
|
882
|
-
sock = s
|
|
883
|
-
standby = null
|
|
884
|
-
closeSocket(old, 'handover') // 先连上新的再关旧的:官方给 10 秒预告就是为了这个
|
|
885
|
-
}
|
|
886
|
-
|
|
887
|
-
function onFrame(s, raw) {
|
|
888
|
-
let m
|
|
889
|
-
try { m = JSON.parse(typeof raw === 'string' ? raw : String(raw)) } catch { return }
|
|
890
|
-
|
|
891
|
-
if (m.type === 'hello') {
|
|
892
|
-
approxLifeSec = (m.debug_info && Number(m.debug_info.approximate_connection_time)) || 0
|
|
893
|
-
if (s.__replacement) promote(s)
|
|
894
|
-
consecFail = 0
|
|
895
|
-
if (watchdog) (s.__replacement ? watchdog.onReconnected() : watchdog.onReady())
|
|
896
|
-
s.__replacement = false
|
|
897
|
-
log(`slack socket hello(连接数 ${m.num_connections || '?'} · 预计存活≈${approxLifeSec || '?'}s · host=${(m.debug_info && m.debug_info.host) || '?'})`)
|
|
898
|
-
// ⭐ 服务端明说这条连接大概能活多久(实测 ≈5 小时)⇒ 提前 10% 主动换,别等它断(SLACK.md「可靠性」)
|
|
899
|
-
refreshTimer = clearTimer(refreshTimer)
|
|
900
|
-
if (approxLifeSec > 60) {
|
|
901
|
-
refreshTimer = setTimeout(() => { if (!stopped && !fatal) openSocket({ replacement: true }) }, Math.round(approxLifeSec * 1000 * refreshRatio))
|
|
902
|
-
if (refreshTimer.unref) refreshTimer.unref()
|
|
903
|
-
}
|
|
904
|
-
return
|
|
905
|
-
}
|
|
906
|
-
|
|
907
|
-
if (m.type === 'disconnect') {
|
|
908
|
-
log(`slack socket disconnect: reason=${m.reason || '?'}`)
|
|
909
|
-
if (m.reason === 'link_disabled') {
|
|
910
|
-
// 有人在 app 设置里关了 Socket Mode —— 重连一万次也没用,停下来并说清楚
|
|
911
|
-
fatal = 'link_disabled'
|
|
912
|
-
if (watchdog) watchdog.onError(new Error('Socket Mode 已在 app 设置里被关闭'))
|
|
913
|
-
return
|
|
914
|
-
}
|
|
915
|
-
// warning ≈ 10 秒后断(⚠ 官方明说可能收不到)/ refresh_requested 例行刷新 /
|
|
916
|
-
// too_many_websockets(不在官方文档里但生产必现,node-slack-sdk#1654)
|
|
917
|
-
if (s === sock && !standby) openSocket({ replacement: true })
|
|
918
|
-
return
|
|
919
|
-
}
|
|
920
|
-
|
|
921
|
-
// ⚠ 收到就立刻 ack,业务异步跑(SLACK.md「可靠性」)。Socket Mode 下 ack 超时全站没写明,
|
|
922
|
-
// 而 60 分钟内失败率超 95% 会让事件订阅被临时禁用 ⇒ ack 之前永远不 await 任何东西。
|
|
923
|
-
if (m.envelope_id) { try { s.send(JSON.stringify({ envelope_id: m.envelope_id })) } catch (e) { log('slack ack 失败: ' + (e && e.message)) } }
|
|
924
|
-
if (watchdog) watchdog.onEvent()
|
|
925
|
-
|
|
926
|
-
const payload = m.payload || {}
|
|
927
|
-
|
|
928
|
-
// 注册过的斜杠命令(manifest 里的 `/sint`)走这条,不是 events_api。
|
|
929
|
-
// ⚠ Slack 的斜杠命令**只有发起人看得见**(它不是频道消息)⇒ 我们照常用 chat.postMessage
|
|
930
|
-
// 把回执发进频道,群里其他人才知道谁停了什么。
|
|
931
|
-
if (m.type === 'slash_commands') {
|
|
932
|
-
const cmdText = String(payload.text || '').trim()
|
|
933
|
-
// `/sint stop` ⇒ 引擎要的是 `/stop`;若将来直接注册了 `/stop`,则原样用它。
|
|
934
|
-
const asEngine = payload.command === umbrellaCommand ? '/' + cmdText : `${payload.command} ${cmdText}`.trim()
|
|
935
|
-
if (!dedupe.take('slash:' + payload.trigger_id)) return
|
|
936
|
-
const now = Date.now()
|
|
937
|
-
Promise.resolve(emit({
|
|
938
|
-
chatId: payload.channel_id,
|
|
939
|
-
userId: payload.user_id || 'unknown',
|
|
940
|
-
senderName: payload.user_name || payload.user_id || 'user',
|
|
941
|
-
text: asEngine,
|
|
942
|
-
msgId: 'slash:' + (payload.trigger_id || now),
|
|
943
|
-
// ⚠ synthetic:斜杠命令的载荷**没有平台 ts**,只有本机时钟 —— 拿它推水位线就是
|
|
944
|
-
// v0.11.12 那个「两把钟比大小」的坑(会静默吞掉真人消息)。
|
|
945
|
-
synthetic: true,
|
|
946
|
-
wantsAck: false,
|
|
947
|
-
createTimeMs: now,
|
|
948
|
-
replyCtx: { channel: payload.channel_id }, // 不挂线程:控制面的回执该让全群看见
|
|
949
|
-
})).catch((e) => log('slack slash onMessage error: ' + (e && e.message)))
|
|
950
|
-
return
|
|
951
|
-
}
|
|
952
|
-
|
|
953
|
-
// 按钮点击(Block Kit)。⭐ **不需要改 manifest 也不用重装** —— `interactivity` 早就开着,
|
|
954
|
-
// Socket Mode 下这类载荷本来就在投递,此前只是被上面那行 `return` 丢掉了。
|
|
955
|
-
// ⚠ 斜杠命令在**线程里调不出来**,而进度消息就在线程里 ⇒ 这是线程内唯一免教学的控制入口。
|
|
956
|
-
if (m.type === 'interactive') {
|
|
957
|
-
// 名字取消息路径同一份缓存(同步读;没命中就退回载荷里的 handle,不为一个按钮去打 users.info)
|
|
958
|
-
const msg = actionMessage(payload, { commands, nameOf: (id) => (nameCache.has(id) ? nameCache.get(id) : null) })
|
|
959
|
-
if (!msg) return
|
|
960
|
-
if (!dedupe.take(msg.msgId)) return
|
|
961
|
-
Promise.resolve(emit(msg)).catch((e) => log('slack action onMessage error: ' + (e && e.message)))
|
|
962
|
-
return
|
|
963
|
-
}
|
|
964
|
-
|
|
965
|
-
if (m.type !== 'events_api') return
|
|
966
|
-
const eventId = payload.event_id
|
|
967
|
-
const eventTime = payload.event_time
|
|
968
|
-
const event = payload.event
|
|
969
|
-
if (!event) return
|
|
970
|
-
if (!dedupe.take(eventId)) { log(`slack 重投事件已丢弃 event_id=${eventId}`); return }
|
|
971
|
-
if (isStale(eventTime, { maxAgeMs: maxEventAgeMs })) {
|
|
972
|
-
// Delayed Events 开启后最多补投 24 小时 —— 过期的"帮我跑一下"绝不能真去跑(SLACK.md「可靠性」)
|
|
973
|
-
log(`slack 事件超龄已丢弃(${Math.round((Date.now() / 1000 - eventTime) / 60)} 分钟前): ${event.type}`)
|
|
974
|
-
return
|
|
975
|
-
}
|
|
976
|
-
try { handleEvent(event) } catch (e) { log('slack event error: ' + (e && e.message)) }
|
|
977
|
-
}
|
|
978
|
-
|
|
979
|
-
let emit = () => {}
|
|
980
|
-
let hooks = {}
|
|
981
|
-
|
|
982
|
-
function handleEvent(event) {
|
|
983
|
-
if (event.type === 'app_mention') return // 被 @ 时 message 与 app_mention 同时来,以 message 为准(SLACK.md「收消息」)
|
|
984
|
-
if (event.type === 'app_rate_limited') { log('🔴 slack app_rate_limited —— 事件投递已超 30,000/小时,期间的消息全部丢失'); return }
|
|
985
|
-
if (event.type === 'agent_session_stopped') { if (hooks.onAgentStop) hooks.onAgentStop({ chatId: event.channel, threadTs: event.thread_ts, userId: event.user }); return }
|
|
986
|
-
// App Home 主页。⚠ 这个事件**两个页签都会来**(Messages 也算),不筛就等于每次有人点开
|
|
987
|
-
// DM 都白发一次 views.publish。⚠ 失败一律只记日志:主页发不出来不该影响任何一轮任务。
|
|
988
|
-
if (event.type === 'app_home_opened') {
|
|
989
|
-
if (!shouldPublishHome(event)) return
|
|
990
|
-
call('views.publish', { user_id: event.user, view: homeView({ alias: commandAlias, umbrella: umbrellaCommand }) }, { retries: 0 })
|
|
991
|
-
.catch((e) => log(`slack App Home 发布失败(${(e && e.code) || e})—— 不影响任务;`
|
|
992
|
-
+ `多半是 manifest 里没开 app_home.home_tab_enabled,改完要重装 app`))
|
|
993
|
-
return
|
|
994
|
-
}
|
|
995
|
-
if (event.type !== 'message') return
|
|
996
|
-
|
|
997
|
-
const verdict = shouldHandle(event, { botUserId: selfId, onlyWhenMentioned, ownTs })
|
|
998
|
-
if (verdict === 'recall') {
|
|
999
|
-
log(`slack recall event: chat=${event.channel || '?'} msg=${event.deleted_ts}`)
|
|
1000
|
-
if (hooks.onRecall) hooks.onRecall({ chatId: event.channel, msgId: event.deleted_ts })
|
|
1001
|
-
return
|
|
1002
|
-
}
|
|
1003
|
-
if (verdict !== 'handle') return
|
|
1004
|
-
|
|
1005
|
-
const files = Array.isArray(event.files) ? event.files : []
|
|
1006
|
-
const body = restoreCommand(parseText(event.text, { botUserId: selfId }), { commands, alias: commandAlias })
|
|
1007
|
-
const text = [body, ...attachmentMarks(files)].filter(Boolean).join('\n')
|
|
1008
|
-
if (!text) return
|
|
1009
|
-
|
|
1010
|
-
// 发送者名:**绝不在这里 await**。命中缓存就同步用,未命中回退 user id 并后台预热 ——
|
|
1011
|
-
// 这条纪律照搬 feishu.js:每帧是独立的并发 listener,一个 await 会让后到但已缓存的消息
|
|
1012
|
-
// 先进引擎、推高该群的水位线,更早那条反被当成过期丢掉(engine.js 单调水位线)。
|
|
1013
|
-
let senderName = event.user || 'user'
|
|
1014
|
-
if (event.user) {
|
|
1015
|
-
if (nameCache.has(event.user)) senderName = nameCache.get(event.user) || event.user
|
|
1016
|
-
else nameOf(event.user).catch(() => {})
|
|
1017
|
-
}
|
|
1018
|
-
|
|
1019
|
-
const images = files.filter((f) => String(f.mimetype || '').startsWith('image/')).map((f) => ({ key: f.id, name: f.name || f.title || f.id }))
|
|
1020
|
-
|
|
1021
|
-
Promise.resolve(emit({
|
|
1022
|
-
chatId: event.channel,
|
|
1023
|
-
userId: event.user || 'unknown',
|
|
1024
|
-
senderName,
|
|
1025
|
-
text,
|
|
1026
|
-
msgId: event.ts,
|
|
1027
|
-
...(images.length ? { images } : {}),
|
|
1028
|
-
createTimeMs: Math.round(parseFloat(event.ts) * 1000) || Date.now(), // ts 是 epoch 秒(带微秒小数)
|
|
1029
|
-
// userId 进 replyCtx 是给 agents.sessions.setStatus 当 initiator_user_id 用的 ——
|
|
1030
|
-
// 原生 Working… 那条要显示"谁发起的"。⚠ 它只在会话**首次创建**时生效,之后静默忽略。
|
|
1031
|
-
replyCtx: { channel: event.channel, ts: event.ts, threadTs: event.thread_ts || event.ts, userId: event.user || '' },
|
|
1032
|
-
})).catch((e) => log('slack onMessage error: ' + (e && e.message)))
|
|
1033
|
-
}
|
|
1034
|
-
|
|
1035
|
-
return {
|
|
1036
|
-
// 自描述(引擎按它改写自知清单;core 不认平台名,只读这几个字段)。不声明 skillCmd:Slack 还没有
|
|
1037
|
-
// 基础技能命令(文件上下行没接,docs/SLACK.md 第 3 步)⇒ features.skills:false 让清单把那一段整段换成「写路径」。
|
|
1038
|
-
// 回复是独立落地的消息、不是钉在轮次开头的卡片;读历史 / 回复编辑撤回 / 表情 / 下载 / 画布 / 生图通知 / 接管卡都没接。
|
|
1039
|
-
chatLabel: 'Slack 群',
|
|
1040
|
-
features: { skills: false, history: false, editRecall: false, react: false, download: false, boards: false, imagen: false, takeoverCard: false, cardPinned: false },
|
|
1041
|
-
|
|
1042
|
-
async start(onMessage, h = {}) {
|
|
1043
|
-
emit = onMessage
|
|
1044
|
-
hooks = h || {}
|
|
1045
|
-
// auth.test 一石二鸟:验凭据(错了立刻在启动期炸,而不是第一条消息时静默不回)+ 取自己的
|
|
1046
|
-
// user id(回环防护与 @ 门禁都靠它)。⚠ 没有它 onlyWhenMentioned 形同虚设。
|
|
1047
|
-
try {
|
|
1048
|
-
const me = await call('auth.test', {}, { retries: 1 })
|
|
1049
|
-
selfId = selfId || me.user_id
|
|
1050
|
-
log(`slack 已认证:bot=${me.user || '?'}(${selfId}) team=${me.team || '?'}`)
|
|
1051
|
-
} catch (e) {
|
|
1052
|
-
onApiError('auth.test', e, null)
|
|
1053
|
-
// 凭据本身就是错的 ⇒ Slack 这一路停在「连接失败」、不再重试 —— 但**不抛**:多平台路由器里飞书 / 企微与它同进程,
|
|
1054
|
-
// 抛出去 = 整个 daemon 起不来(launchd 还会反复拉起再退),别的平台的群全跟着停(企微适配器同样从不在 start 里抛)。
|
|
1055
|
-
// 失败态经 connHealth(控制台「Slack 连接」行)与 onConn(运维告警的断线观察)报出去;换好 token 重启即恢复。
|
|
1056
|
-
if (fatal) { fatalAt = Date.now(); try { onConn(fatalSnapshot()) } catch {} ; return }
|
|
1057
|
-
}
|
|
1058
|
-
watchdog = createWatchdog({ rebuild: () => { closeSocket(sock, 'watchdog-rebuild'); sock = null; standby = null; connecting = false; openSocket({ replacement: false }) }, log, onConn, ...wdOpts })
|
|
1059
|
-
await openSocket({ replacement: false })
|
|
1060
|
-
tickTimer = setInterval(() => { if (watchdog) watchdog.tick() }, tickIntervalMs)
|
|
1061
|
-
if (tickTimer.unref) tickTimer.unref()
|
|
1062
|
-
log('slack Socket Mode 已启动(watchdog on)')
|
|
1063
|
-
},
|
|
1064
|
-
|
|
1065
|
-
// 控制台健康行读它;字段与飞书同口径(src/ui/server.js connHealthStatus)
|
|
1066
|
-
connHealth() {
|
|
1067
|
-
const base = watchdog ? watchdog.snapshot() : fatal ? fatalSnapshot() : { state: 'stopped', since: 0, lastReadyAt: 0, lastEventAt: 0, reconnects: 0, rebuilds: 0, lastError: null }
|
|
1068
|
-
return { ...base, sdkState: sock ? ['connecting', 'open', 'closing', 'closed'][sock.readyState] || null : null, reconnectAttempts: consecFail, approxLifeSec, fatal }
|
|
1069
|
-
},
|
|
1070
|
-
|
|
1071
|
-
async reply(replyCtx, content) {
|
|
1072
|
-
// ⚠ `chatId` 是合成消息的写法:定时任务 / 控制台插话 / 重启补做 / 画布确认都构造
|
|
1073
|
-
// `replyCtx: { chatId }`(那套写在只有飞书的时候,飞书的 replyCtx 就叫 chatId)。
|
|
1074
|
-
// 不认它的话,这几条路在 Slack 群里会**静默不回**——而它们恰恰是没人盯着的那几条。
|
|
1075
|
-
const channel = replyCtx && (replyCtx.channel || replyCtx.chatId)
|
|
1076
|
-
// SLACK.md「收消息」:Slack 的"回复"就是 thread。挂进线程,群里多人交叉说话时看得出这句在回应谁;
|
|
1077
|
-
// replyInThread:false ⇒ 直接发在频道里(见 docs/SLACK.md「进度表达」)。
|
|
1078
|
-
const threadTs = replyInThread ? (replyCtx && (replyCtx.threadTs || replyCtx.ts)) || null : null
|
|
1079
|
-
// ⚠ 只有引擎自己说的话(命令回执 / 排队提示 / 报错)走这条 —— agent 的答案走卡片的
|
|
1080
|
-
// finish,那里**不做**改写(答案里的 `/status` 可能真是一条路由)。
|
|
1081
|
-
return postMessage(channel, localizeCommands(content, { commands, alias: commandAlias }), { threadTs })
|
|
1082
|
-
},
|
|
1083
|
-
|
|
1084
|
-
async send(chatId, content) {
|
|
1085
|
-
return postMessage(chatId, localizeCommands(content, { commands, alias: commandAlias }))
|
|
1086
|
-
},
|
|
1087
|
-
|
|
1088
|
-
/**
|
|
1089
|
-
* 进度表达(docs/SLACK.md「进度表达」)。引擎只认这一个可选方法,拿到的对象要有
|
|
1090
|
-
* update / finish / recall —— 与飞书的流式卡片同契约,但底下完全是另一套:
|
|
1091
|
-
*
|
|
1092
|
-
* 建进度消息(挂线程) → setStatus(processing) 出**原生 Working… + Stop 按钮**
|
|
1093
|
-
* → 节流 chat.update 刷进度 → 最终答案**独立 postMessage** → finally setStatus(active)
|
|
1094
|
-
*
|
|
1095
|
-
* ⚠ 三条硬要求,少一条就是一类事故:
|
|
1096
|
-
* ① **最终答案必须独立发**,不能只靠 update 送达(SLACK.md「进度表达」 铁律 —— v0.11.48「844 轮里 34 轮
|
|
1097
|
-
* 答案没进过群」换来的)。⭐ 附带一个 Slack 特有的好处:**编辑不发通知,新消息才推人** ——
|
|
1098
|
-
* "跑完 40 分钟推我一下"这个核心场景,靠 update 是推不动的。
|
|
1099
|
-
* ② **`setStatus(active)` 必须无条件收尾**:发消息不会自动清掉 loading UX,不显式转就一直
|
|
1100
|
-
* 转圈**到 1 小时超时**,用户看到的是"它卡死了",而答案其实早发了(SLACK.md「静默失败」)。
|
|
1101
|
-
* ③ processing **1 小时会超时**,长任务要心跳续期(重发即重置计时器)。
|
|
1102
|
-
*/
|
|
1103
|
-
async createStreamingCard(replyCtx) {
|
|
1104
|
-
const channel = replyCtx && (replyCtx.channel || replyCtx.chatId)
|
|
1105
|
-
if (!channel) return null
|
|
1106
|
-
// ⚠ 频道内的原生会话**必须挂线程**(SLACK.md「进度表达」 组件二);合成消息(定时任务/插话)没有 ts,
|
|
1107
|
-
// 那就退化成频道里的一条独立进度消息,原生状态那半自然也就没有——不影响答案送达。
|
|
1108
|
-
const threadTs = (replyCtx && (replyCtx.threadTs || replyCtx.ts)) || null
|
|
1109
|
-
// 控制按钮只挂在**进度消息**上 —— 它是"这一轮还在跑"的那条,按钮的语义天然绑在它身上;
|
|
1110
|
-
// 答案那条是终态,挂个能点的「停止」在上面就是骗人(见 finish 的兜底分支)。
|
|
1111
|
-
const btns = controlActions(commands)
|
|
1112
|
-
let ts = null
|
|
1113
|
-
try { ts = await postMessage(channel, T_PROGRESS_INIT, { threadTs, retries: 1, actions: btns }) } catch { ts = null }
|
|
1114
|
-
if (!ts) return null // 建不出来就退回纯文本回复(引擎自己会兜)
|
|
1115
|
-
|
|
1116
|
-
let statusOff = false // 拿不到原生状态(比如 app 没开 agent_view)⇒ 只降级,绝不影响这一轮
|
|
1117
|
-
const setStatus = async (status, first = false) => {
|
|
1118
|
-
if (statusOff || !threadTs) return
|
|
1119
|
-
try {
|
|
1120
|
-
const r = await call('agents.sessions.setStatus', {
|
|
1121
|
-
channel_id: channel,
|
|
1122
|
-
thread_ts: threadTs,
|
|
1123
|
-
status,
|
|
1124
|
-
// ⚠ 只在会话首次创建时生效,之后静默忽略;指定的人不在频道里会静默回退成 bot 自己
|
|
1125
|
-
...(first && replyCtx.userId ? { initiator_user_id: replyCtx.userId } : {}),
|
|
1126
|
-
}, { retries: 0 })
|
|
1127
|
-
// SLACK.md「静默失败」:以副作用为准,不以 ok:true 为准
|
|
1128
|
-
if (r && r.agent_status && r.agent_status !== status) log(`⚠ slack 会话状态写入 ${status} 但读回是 ${r.agent_status}`)
|
|
1129
|
-
} catch (e) {
|
|
1130
|
-
statusOff = true // 一次不行就别每 5 分钟再撞一次
|
|
1131
|
-
const code = (e && e.code) || String(e)
|
|
1132
|
-
log(`slack 原生状态不可用(${code})—— 进度照常刷,只是没有 Working…/Stop。`
|
|
1133
|
-
+ (code === 'not_authorized' ? ' 多半是 app 没开 agent_view,改 manifest 后要重装。' : ''))
|
|
1134
|
-
}
|
|
1135
|
-
}
|
|
1136
|
-
await setStatus('processing', true)
|
|
1137
|
-
// 心跳:processing 一小时超时,重发即重置。5 分钟一次,Tier 3 下成本可忽略。
|
|
1138
|
-
const beat = setInterval(() => { setStatus('processing') }, 5 * 60 * 1000)
|
|
1139
|
-
if (beat.unref) beat.unref()
|
|
1140
|
-
|
|
1141
|
-
// ⏳ 打在**用户那条消息**上:线程折叠着的时候,频道里也看得出在跑。
|
|
1142
|
-
const react = (name) => { if (replyCtx.ts) addReactionTo(channel, replyCtx.ts, name).catch(() => {}) }
|
|
1143
|
-
react('hourglass_flowing_sand')
|
|
1144
|
-
|
|
1145
|
-
const throttle = createProgressThrottle({
|
|
1146
|
-
intervalMs: Number(card.throttleMs) > 0 ? Number(card.throttleMs) : 20_000,
|
|
1147
|
-
log,
|
|
1148
|
-
send: async (text) => {
|
|
1149
|
-
// ⚠ 每帧都要重新带上按钮:`chat.update` 是**整条替换**,少传 blocks 就等于把按钮删了
|
|
1150
|
-
// (同 SLACK.md「静默失败」 那条「只传 text 会静默清空 blocks」的形状)。
|
|
1151
|
-
const r = await call('chat.update', messagePayload(channel, text, { ts, actions: btns }), { retries: 0 })
|
|
1152
|
-
return r
|
|
1153
|
-
},
|
|
1154
|
-
})
|
|
1155
|
-
|
|
1156
|
-
let closed = false
|
|
1157
|
-
const teardown = () => { closed = true; clearInterval(beat); throttle.close() }
|
|
1158
|
-
|
|
1159
|
-
return {
|
|
1160
|
-
update(text) { if (!closed) throttle.update(text) },
|
|
1161
|
-
|
|
1162
|
-
async finish(text) {
|
|
1163
|
-
if (closed) return
|
|
1164
|
-
closed = true
|
|
1165
|
-
clearInterval(beat)
|
|
1166
|
-
throttle.close()
|
|
1167
|
-
try { await throttle.settle() } catch { /* 在途那帧失败无所谓,下面就落地了 */ }
|
|
1168
|
-
let answerTs = null
|
|
1169
|
-
try { answerTs = await postMessage(channel, text, { threadTs, retries: 2 }) } catch { answerTs = null }
|
|
1170
|
-
if (answerTs) {
|
|
1171
|
-
// 答案已独立落地 ⇒ 进度消息功成身退,线程里只留一条终态(不刷屏)。
|
|
1172
|
-
// ⚠ 顺序不能反:先删后发的话,发失败就把两头都丢了。
|
|
1173
|
-
try { await deleteOwnMessage(channel, ts) } catch { /* 删不掉就留着,顶多多一条 */ }
|
|
1174
|
-
} else {
|
|
1175
|
-
// 独立那条没发出去(限流/频道级错误)⇒ 至少把答案写进进度消息,别让它烂在半路
|
|
1176
|
-
// ⚠ 这里**故意不带 actions** —— 轮次已经结束,`chat.update` 整条替换正好把按钮抹掉。
|
|
1177
|
-
// 留着的话就是一条"答案已出、旁边还有个能点的停止"的消息,点下去只会得到一句
|
|
1178
|
-
// 「当前没有在跑的任务」,把人教成"这按钮坏的"。
|
|
1179
|
-
log('🔴 slack 最终答案独立发送失败 —— 退回写入进度消息')
|
|
1180
|
-
try { await call('chat.update', messagePayload(channel, text, { ts }), { retries: 1 }) } catch (e) { log('slack 收尾写入也失败: ' + ((e && e.code) || e)) }
|
|
1181
|
-
}
|
|
1182
|
-
react('white_check_mark')
|
|
1183
|
-
if (replyCtx.ts) removeReactionFrom(channel, replyCtx.ts, 'hourglass_flowing_sand').catch(() => {})
|
|
1184
|
-
await setStatus('active') // ⚠ 无条件 —— 不转就转圈到一小时超时
|
|
1185
|
-
return answerTs
|
|
1186
|
-
},
|
|
1187
|
-
|
|
1188
|
-
// ack 后转静默:撤掉进度消息,保住"静默轮群里不留痕迹"(engine.js dropAckCard)
|
|
1189
|
-
async recall() {
|
|
1190
|
-
if (closed) return false
|
|
1191
|
-
teardown()
|
|
1192
|
-
try { await throttle.settle() } catch {}
|
|
1193
|
-
let ok = false
|
|
1194
|
-
try { ok = await deleteOwnMessage(channel, ts) } catch { ok = false }
|
|
1195
|
-
if (replyCtx.ts) removeReactionFrom(channel, replyCtx.ts, 'hourglass_flowing_sand').catch(() => {})
|
|
1196
|
-
await setStatus('active')
|
|
1197
|
-
return ok
|
|
1198
|
-
},
|
|
1199
|
-
}
|
|
1200
|
-
},
|
|
1201
|
-
|
|
1202
|
-
/**
|
|
1203
|
-
* 就地改写一条消息。SLACK.md「可靠性」:改不动就**立刻降级发新消息**并把指针换过去 ——
|
|
1204
|
-
* 冻住的进度条比丑一点的进度条糟得多。返回 { ts, degraded }。
|
|
1205
|
-
* ⚠ 进度更新用 retries:0(SLACK.md「可靠性」:失败就跳过本轮,别排队重试把进度拖冻)。
|
|
1206
|
-
*/
|
|
1207
|
-
async updateMessage(channel, ts, content, { threadTs = null } = {}) {
|
|
1208
|
-
try {
|
|
1209
|
-
const r = await call('chat.update', messagePayload(channel, content, { ts }), { retries: 0 })
|
|
1210
|
-
return { ts: (r && r.ts) || ts, degraded: false }
|
|
1211
|
-
} catch (e) {
|
|
1212
|
-
if (e && e.kind === 'degrade') {
|
|
1213
|
-
log(`slack chat.update ${e.code} ⇒ 降级发新消息并切换进度指针`)
|
|
1214
|
-
const fresh = await postMessage(channel, content, { threadTs, retries: 1 })
|
|
1215
|
-
return { ts: fresh, degraded: true }
|
|
1216
|
-
}
|
|
1217
|
-
onApiError('chat.update', e, channel)
|
|
1218
|
-
return { ts, degraded: false, failed: true }
|
|
1219
|
-
}
|
|
1220
|
-
},
|
|
1221
|
-
|
|
1222
|
-
/** 👀 回执(实现见闭包里的 addReactionTo —— 卡片收尾那条 ✅ 走的是同一处)。 */
|
|
1223
|
-
addReaction(channel, ts, name = 'eyes') { return addReactionTo(channel, ts, name) },
|
|
1224
|
-
removeReaction(channel, ts, name) { return removeReactionFrom(channel, ts, name) },
|
|
1225
|
-
|
|
1226
|
-
/** 删除自己发的消息(同上,与进度消息的收尾共用一处实现)。 */
|
|
1227
|
-
deleteMessage(channel, ts) { return deleteOwnMessage(channel, ts) },
|
|
1228
|
-
|
|
1229
|
-
/** ⭐ 对账通道(SLACK.md「静默失败」):唯一能独立验证"这条消息真的存在于该频道"的手段,成本可忽略。 */
|
|
1230
|
-
async permalink(channel, ts) {
|
|
1231
|
-
try { const r = await call('chat.getPermalink', { channel, message_ts: ts }, { retries: 1 }); return (r && r.permalink) || null } catch { return null }
|
|
1232
|
-
},
|
|
1233
|
-
|
|
1234
|
-
/** 下载群里的图片/文件到本地绝对路径。 */
|
|
1235
|
-
async downloadResource(fileId, destPath) {
|
|
1236
|
-
return downloadFile(call, botToken, fileId, destPath, { fetchImpl })
|
|
1237
|
-
},
|
|
1238
|
-
|
|
1239
|
-
/** 逃生口:让调用方直接打任意 Web API(第 2 步接进度表达时会用到 agents.sessions.*)。 */
|
|
1240
|
-
api: call,
|
|
1241
|
-
|
|
1242
|
-
async stop() {
|
|
1243
|
-
stopped = true
|
|
1244
|
-
if (tickTimer) { try { clearInterval(tickTimer) } catch {} tickTimer = null }
|
|
1245
|
-
refreshTimer = clearTimer(refreshTimer)
|
|
1246
|
-
retryTimer = clearTimer(retryTimer)
|
|
1247
|
-
if (watchdog) { try { watchdog.stop() } catch {} }
|
|
1248
|
-
closeSocket(standby, 'stop')
|
|
1249
|
-
closeSocket(sock, 'stop')
|
|
1250
|
-
standby = null
|
|
1251
|
-
sock = null
|
|
1252
|
-
},
|
|
1253
|
-
}
|
|
1254
|
-
}
|
|
1255
|
-
|
|
1256
|
-
module.exports = {
|
|
1257
|
-
createSlackPlatform,
|
|
1258
|
-
createProgressThrottle,
|
|
1259
|
-
slackPrecheck,
|
|
1260
|
-
fetchSlackChannels,
|
|
1261
|
-
sendSlackText,
|
|
1262
|
-
REQUIRED_SCOPES,
|
|
1263
|
-
RECOMMENDED_SCOPES,
|
|
1264
|
-
createSlackApi,
|
|
1265
|
-
createDedupe,
|
|
1266
|
-
downloadFile,
|
|
1267
|
-
parseText,
|
|
1268
|
-
restoreCommand,
|
|
1269
|
-
localizeCommands,
|
|
1270
|
-
attachmentMarks,
|
|
1271
|
-
shouldHandle,
|
|
1272
|
-
isStale,
|
|
1273
|
-
messagePayload,
|
|
1274
|
-
controlActions,
|
|
1275
|
-
actionsBlock,
|
|
1276
|
-
actionMessage,
|
|
1277
|
-
homeView,
|
|
1278
|
-
shouldPublishHome,
|
|
1279
|
-
CONTROL_BUTTONS,
|
|
1280
|
-
plainFallback,
|
|
1281
|
-
classifyError,
|
|
1282
|
-
slackHint,
|
|
1283
|
-
SlackError,
|
|
1284
|
-
FATAL,
|
|
1285
|
-
RETRYABLE,
|
|
1286
|
-
CHANNEL_LEVEL,
|
|
1287
|
-
UPDATE_DEGRADE,
|
|
1288
|
-
MD_LIMIT,
|
|
1289
|
-
}
|