sintbot 1.4.7 → 1.4.8
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 +23 -0
- package/CHANGELOG.json +23 -0
- package/bin/sintbot-v2.js +36 -1
- package/docs/AI-ASSISTED-INSTALL.md +2 -2
- package/docs/install.md +3 -3
- package/docs/zh-CN/AI-ASSISTED-INSTALL.md +2 -2
- package/docs/zh-CN/install.md +3 -3
- package/install.sh +1 -1
- package/package.json +8 -1
- package/src/c2console.js +50 -3
- package/src/c2multigroupservice.js +88 -4
- package/src/c2updater.js +20 -3
- package/src/consolesay.js +229 -0
- package/src/core/c2sourceidentity.js +167 -0
- package/src/core/v2engine.js +41 -3
- package/src/feishucli.js +211 -0
- package/src/platform/noticecard.js +104 -0
- package/src/platform/noticesend.js +47 -0
- package/src/ui/console.html +52 -13
- package/src/ui/serverbase.js +19 -2
- package/src/upgrade.js +58 -0
- package/src/upgradeannounce.js +62 -0
package/src/feishucli.js
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Messaging actions of the `feishu` subcommand, shared by the legacy entry and
|
|
4
|
+
// the release entry so a group's agent gets the same skills under both:
|
|
5
|
+
// send-msg · send-notice · send-file · send-image · history · reply · edit ·
|
|
6
|
+
// recall (alias delete) · download · react. Canvas actions stay in the legacy
|
|
7
|
+
// entry. None of them needs the service: each call uses the bot's own app
|
|
8
|
+
// credentials directly.
|
|
9
|
+
//
|
|
10
|
+
// Target chat: --chat, else $SBOT_CHAT_ID (injected for every group turn).
|
|
11
|
+
// Configuration: --config, else $SBOT_CONFIG (also injected), else the usual
|
|
12
|
+
// lookup. Paths are relative to the working directory and never leave it;
|
|
13
|
+
// outbound text is redacted like the engine's replies (PII per group).
|
|
14
|
+
const fs = require('node:fs')
|
|
15
|
+
const config = require('./config')
|
|
16
|
+
const feishu = require('./platform/feishu')
|
|
17
|
+
const { cliNotice, deliverNotice } = require('./platform/noticesend')
|
|
18
|
+
const { TONES, resolveTone } = require('./platform/noticecard')
|
|
19
|
+
const { redact } = require('./core/redact')
|
|
20
|
+
const { fmtStamp, normalizeTz } = require('./core/time')
|
|
21
|
+
|
|
22
|
+
const FEISHU_MESSAGE_ACTIONS = Object.freeze(['send-msg', 'send-notice', 'send-file', 'send-image',
|
|
23
|
+
'history', 'reply', 'edit', 'recall', 'delete', 'download', 'react'])
|
|
24
|
+
|
|
25
|
+
const dim = (s) => `\x1b[2m${s}\x1b[0m`
|
|
26
|
+
const green = (s) => `\x1b[32m${s}\x1b[0m`
|
|
27
|
+
|
|
28
|
+
// A refusal with its own wording: callers print it as is and exit 1.
|
|
29
|
+
class FeishuCliError extends Error {
|
|
30
|
+
constructor(message) { super(message); this.name = 'FeishuCliError' }
|
|
31
|
+
}
|
|
32
|
+
const refuse = (message) => { throw new FeishuCliError(message) }
|
|
33
|
+
|
|
34
|
+
// Hand-roll option parsing (not util.parseArgs): send-msg bodies / filenames legitimately start
|
|
35
|
+
// with '-' (Markdown lists like "- item"), which parseArgs(strict) rejects as an unknown option —
|
|
36
|
+
// and the self-knowledge manifest teaches agents exactly that form. Pull out --chat/--config; the
|
|
37
|
+
// rest stays positional (message text / path), leading '-' and all. Canvas options are taken out
|
|
38
|
+
// for every action, as they always were.
|
|
39
|
+
function parseFeishuArgs(action, rawArgs) {
|
|
40
|
+
const o = { warnings: [], positionals: [] }
|
|
41
|
+
for (let i = 0; i < rawArgs.length; i++) {
|
|
42
|
+
const a = rawArgs[i]
|
|
43
|
+
if (a === '--chat') o.chat = rawArgs[++i]
|
|
44
|
+
else if (a.startsWith('--chat=')) o.chat = a.slice(7)
|
|
45
|
+
else if (a === '--config') o.config = rawArgs[++i]
|
|
46
|
+
else if (a.startsWith('--config=')) o.config = a.slice(9)
|
|
47
|
+
else if (action === 'send-notice' && a === '--sub') o.subtitle = rawArgs[++i]
|
|
48
|
+
else if (action === 'send-notice' && a === '--tone') o.tone = rawArgs[++i]
|
|
49
|
+
else if (action === 'send-notice' && a === '--warn') o.warnings.push(rawArgs[++i])
|
|
50
|
+
else if (action === 'send-notice' && a === '--from') o.from = rawArgs[++i]
|
|
51
|
+
else if (a === '--title') o.title = rawArgs[++i]
|
|
52
|
+
else if (a === '--new') o.new = true
|
|
53
|
+
else if (a === '--x') o.x = rawArgs[++i]
|
|
54
|
+
else if (a === '--y') o.y = rawArgs[++i]
|
|
55
|
+
else if (a === '--width') o.width = rawArgs[++i]
|
|
56
|
+
else if (a === '--height') o.height = rawArgs[++i]
|
|
57
|
+
else if (a === '--label') o.label = rawArgs[++i]
|
|
58
|
+
else if (a === '--raw') o.raw = true
|
|
59
|
+
else if (a === '--board') o.board = rawArgs[++i]
|
|
60
|
+
else if (a === '--beside') o.beside = rawArgs[++i] // 参照节点 id:新图放它右侧(按标注改的排版)
|
|
61
|
+
else if (a === '--deliver') o.deliver = true // 交付结果卡:上板 + 回填进原播报卡(单卡)/ 无原卡则新发
|
|
62
|
+
else if (a === '--note') o.note = rawArgs[++i] // 结果卡里的一句改动说明
|
|
63
|
+
else if (a === '--type') o.type = rawArgs[++i] // 图表类型(flow/mindmap/sequence/… 或中文别名)
|
|
64
|
+
else if (a === '--syntax') o.syntax = rawArgs[++i] // 图表语法(mermaid | plantuml;覆盖扩展名识别)
|
|
65
|
+
else if (a === '--as-image') o.asImage = true // 图表渲染成整张图片,否则多节点可编辑
|
|
66
|
+
else if (a === '--no-cascade') o.noCascade = true // board-del-node:只删点名的,不连带容器内容/导图子树
|
|
67
|
+
else o.positionals.push(a)
|
|
68
|
+
}
|
|
69
|
+
return o
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The checks every action shares, in the legacy order: Slack groups, then
|
|
73
|
+
// configuration, then the target chat.
|
|
74
|
+
function feishuContext(opts, { env = process.env, loadConfig = config.loadConfig,
|
|
75
|
+
findConfigPath = config.findConfigPath, applyProxyEnv = config.applyProxyEnv,
|
|
76
|
+
feishuClient = feishu.feishuClient, client } = {}) {
|
|
77
|
+
// Slack 群:引擎注入 SBOT_PLATFORM=slack。飞书这族动作在那儿一概走不通(文件上下行还没接到
|
|
78
|
+
// Slack,docs/SLACK.md 第 3 步)。不拦的话要么打到飞书 API 报 lark 错、要么 feishu 没配时报「未配置」——
|
|
79
|
+
// 两种都不说人话,agent 会转头重试。显式 --chat 指向飞书群的手工用法不拦。
|
|
80
|
+
if (!opts.chat && env.SBOT_PLATFORM === 'slack') refuse('本群在 Slack 上 —— sbot feishu 的动作(发文件/发图/读历史/回复/编辑/撤回/下载/表情)在 Slack 群暂不可用,Slack 侧文件上下行还没接。要给群成员看的文件或图片:把项目内相对路径与打开方式写进回复正文;不要重试本命令。')
|
|
81
|
+
const configPath = opts.config || env.SBOT_CONFIG
|
|
82
|
+
if (!findConfigPath(configPath)) refuse('feishu 未配置:找不到配置文件 — 传 --config <path> 或设置 env SBOT_CONFIG')
|
|
83
|
+
const cfg = loadConfig(configPath)
|
|
84
|
+
if (!cfg.feishu || !cfg.feishu.appId || !cfg.feishu.appSecret) refuse('feishu 未配置(sbot.config.json 缺 feishu.appId/appSecret)')
|
|
85
|
+
const chatId = opts.chat || env.SBOT_CHAT_ID
|
|
86
|
+
if (!chatId) refuse('缺少目标群 — 传 --chat <oc_...> 或设置 env SBOT_CHAT_ID')
|
|
87
|
+
applyProxyEnv(cfg)
|
|
88
|
+
// 群面出站脱敏(与 engine 同口径):PII 按群开关(redactPii:false ⇒ 手机号/邮箱明文),密钥类恒遮盖。
|
|
89
|
+
const red = (s) => redact(s, { pii: !(cfg.groups && cfg.groups[chatId] && cfg.groups[chatId].redactPii === false) })
|
|
90
|
+
return { cfg, chatId, red, client: client || feishuClient(cfg.feishu.appId, cfg.feishu.appSecret) }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// args = [action, ...rest]. Confirmations go to stderr; what a program reads
|
|
94
|
+
// (history lines, message ids) goes to stdout.
|
|
95
|
+
async function runFeishuAction(args, deps = {}) {
|
|
96
|
+
const action = args[0]
|
|
97
|
+
if (!FEISHU_MESSAGE_ACTIONS.includes(action)) refuse(`unknown feishu action '${action || ''}' (${FEISHU_MESSAGE_ACTIONS.filter((a) => a !== 'delete').join(' | ')})`)
|
|
98
|
+
const opts = parseFeishuArgs(action, args.slice(1))
|
|
99
|
+
const { cfg, chatId, red, client } = feishuContext(opts, deps)
|
|
100
|
+
const cwd = deps.cwd || process.cwd()
|
|
101
|
+
const now = deps.now || Date.now
|
|
102
|
+
const out = deps.stdout || ((s) => process.stdout.write(s))
|
|
103
|
+
const err = deps.stderr || ((s) => process.stderr.write(s))
|
|
104
|
+
const p = opts.positionals
|
|
105
|
+
// Usage errors before any network call, worded as the legacy entry did.
|
|
106
|
+
let notice
|
|
107
|
+
if (action === 'send-msg' && !p.join(' ').trim()) refuse('缺少消息内容 — sbot feishu send-msg "<文本>"')
|
|
108
|
+
if (action === 'send-notice') {
|
|
109
|
+
// 群通知卡:标题 + 正文(其余位置参数各占一行)+ 可选副标题 / 类别 / 注意事项 / 来源。
|
|
110
|
+
// 与 send-msg 的分工:这张卡是「对大家宣布一件事」,阶段性结果与定时提醒仍走 send-msg。
|
|
111
|
+
if (!String(p[0] || '').trim()) refuse('缺少标题 — sbot feishu send-notice "<标题>" ["<正文…>"] [--sub <副标题>] [--tone 公告|升级|提醒|故障|变更|状态] [--warn <注意事项>]… [--from <来源>]')
|
|
112
|
+
try {
|
|
113
|
+
notice = cliNotice(p, { subtitle: opts.subtitle, tone: opts.tone, warnings: opts.warnings,
|
|
114
|
+
from: opts.from, stamp: fmtStamp(now(), normalizeTz(cfg.timezone)) })
|
|
115
|
+
} catch (e) { refuse(e.message) }
|
|
116
|
+
}
|
|
117
|
+
if ((action === 'reply' || action === 'edit') && (!p[0] || !p.slice(1).join(' ').trim())) {
|
|
118
|
+
refuse(action === 'reply' ? '用法: sbot feishu reply <message_id> "<文本>"' : '用法: sbot feishu edit <message_id> "<文本>"(仅能编辑 bot 自己发的消息)')
|
|
119
|
+
}
|
|
120
|
+
if ((action === 'recall' || action === 'delete') && !p[0]) refuse('用法: sbot feishu recall <message_id>(仅能撤回 bot 自己发的消息)')
|
|
121
|
+
if (action === 'download' && (!p[0] || !p[1])) refuse('用法: sbot feishu download <message_id> <file_key> <file|image> [目标相对路径]')
|
|
122
|
+
if (action === 'react' && (!p[0] || !p[1])) refuse('用法: sbot feishu react <message_id> <emoji>(如 THUMBSUP / 👍)')
|
|
123
|
+
try {
|
|
124
|
+
if (action === 'send-file' || action === 'send-image') {
|
|
125
|
+
const abs = feishu.resolveSendPath(cwd, p[0])
|
|
126
|
+
if (action === 'send-file') await feishu.sendChatFile(client, chatId, abs)
|
|
127
|
+
else await feishu.sendChatImage(client, chatId, abs)
|
|
128
|
+
err(green(` ✓ 已发送${action === 'send-file' ? '文件' : '图片'}到群 `) + dim(chatId) + ' ' + p[0] + '\n')
|
|
129
|
+
return { action, chatId, path: abs }
|
|
130
|
+
}
|
|
131
|
+
if (action === 'send-msg') {
|
|
132
|
+
await feishu.sendChatText(client, chatId, red(p.join(' '))) // 出站脱敏,与 engine 群回复同口径(PII 按群)
|
|
133
|
+
err(green(' ✓ 已发送消息到群 ') + dim(chatId) + '\n')
|
|
134
|
+
return { action, chatId }
|
|
135
|
+
}
|
|
136
|
+
if (action === 'send-notice') {
|
|
137
|
+
// 有卡能力就发卡(逐字段脱敏);建卡被拒或没送达时退回同一份通知的文本。
|
|
138
|
+
const sent = await deliverNotice({ features: { noticeCard: true },
|
|
139
|
+
sendCard: (id, card) => feishu.sendChatCard(client, id, card),
|
|
140
|
+
send: (id, body) => feishu.sendChatText(client, id, body) }, chatId, notice, { redact: red, log: (m) => err(dim(' (' + m + ')') + '\n') })
|
|
141
|
+
if (sent.mode === 'card') {
|
|
142
|
+
err(green(' ✓ 已发送通知卡到群 ') + dim(chatId) + ` ${TONES[resolveTone(notice.tone)].label}「${notice.title}」\n`)
|
|
143
|
+
if (sent.result && sent.result.msgId) out(sent.result.msgId + '\n') // 给程序读:message_id(要撤回 / 回复它时用)
|
|
144
|
+
} else {
|
|
145
|
+
err(green(' ✓ 已发送通知到群 ') + dim(chatId) + '(文本)\n')
|
|
146
|
+
}
|
|
147
|
+
return { action, chatId, mode: sent.mode, msgId: (sent.result && sent.result.msgId) || null }
|
|
148
|
+
}
|
|
149
|
+
if (action === 'history') {
|
|
150
|
+
const n = p[0] ? parseInt(p[0], 10) : 20
|
|
151
|
+
const msgs = await feishu.fetchChatHistory(client, chatId, n)
|
|
152
|
+
const tz = normalizeTz(cfg.timezone)
|
|
153
|
+
// @ 标记(裁决在 feishu.js 的 historyAtMark,这里只消费):「@了别人未@bot」的消息按设计不触发轮次,
|
|
154
|
+
// 历史里不标就与真问 bot 的长得一模一样。bot 自己的 open_id 从配置来。
|
|
155
|
+
const botOid = String((cfg.feishu && cfg.feishu.botOpenId) || '')
|
|
156
|
+
const lines = msgs.map((m) => {
|
|
157
|
+
const who = m.senderType === 'app' ? 'bot' : (m.senderId || '?')
|
|
158
|
+
const body = m.text != null ? m.text : `(${m.msgType})`
|
|
159
|
+
return `[${m.createTimeMs ? fmtStamp(m.createTimeMs, tz) : '?'} ${who}${feishu.historyAtMark(m.mentions, botOid)}] ${body} {${m.msgId}}`
|
|
160
|
+
})
|
|
161
|
+
// 群消息内容是入站上下文(给 agent 读),不脱敏;message_id 用于后续 reply/edit/recall
|
|
162
|
+
out(`共 ${msgs.length} 条群消息(新→旧):\n` + (lines.join('\n') || '(空)') + '\n')
|
|
163
|
+
return { action, chatId, count: msgs.length }
|
|
164
|
+
}
|
|
165
|
+
if (action === 'reply' || action === 'edit') {
|
|
166
|
+
const msgId = p[0]
|
|
167
|
+
await (action === 'reply' ? feishu.replyMessage : feishu.editMessage)(client, msgId, red(p.slice(1).join(' ')))
|
|
168
|
+
err(green(action === 'reply' ? ' ✓ 已回复消息 ' : ' ✓ 已编辑消息 ') + dim(msgId) + '\n')
|
|
169
|
+
return { action, chatId, msgId }
|
|
170
|
+
}
|
|
171
|
+
if (action === 'recall' || action === 'delete') {
|
|
172
|
+
await feishu.recallMessage(client, p[0])
|
|
173
|
+
err(green(' ✓ 已撤回消息 ') + dim(p[0]) + '\n')
|
|
174
|
+
return { action, chatId, msgId: p[0] }
|
|
175
|
+
}
|
|
176
|
+
if (action === 'download') {
|
|
177
|
+
const [msgId, fileKey, type] = p
|
|
178
|
+
const dest = p[3] || fileKey
|
|
179
|
+
const abs = feishu.resolveSendPath(cwd, dest, false) // 写模式:目标文件尚不存在
|
|
180
|
+
await feishu.downloadResource(client, msgId, fileKey, type === 'image' ? 'image' : 'file', abs)
|
|
181
|
+
err(green(' ✓ 已下载到 ') + dim(dest) + '\n')
|
|
182
|
+
return { action, chatId, path: abs }
|
|
183
|
+
}
|
|
184
|
+
// react
|
|
185
|
+
await feishu.reactMessage(client, p[0], p[1])
|
|
186
|
+
err(green(' ✓ 已回应 ') + dim(p[0]) + ' ' + p[1] + '\n')
|
|
187
|
+
return { action, chatId, msgId: p[0] }
|
|
188
|
+
} catch (e) {
|
|
189
|
+
throw new FeishuCliError('feishu 操作失败:' + feishu.larkErrMsg(e))
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// `--help` text of the release entry. The legacy entry keeps its own, which
|
|
194
|
+
// also lists the canvas actions.
|
|
195
|
+
function feishuUsage(cmd = 'sintbot') {
|
|
196
|
+
return `用法: ${cmd} feishu <动作> [参数…] [--chat <oc_…>] [--config <path>]\n`
|
|
197
|
+
+ ' 目标群:--chat,不传取 $SBOT_CHAT_ID(群里跑时已注入);配置:--config,不传取 $SBOT_CONFIG;路径一律项目内相对路径\n'
|
|
198
|
+
+ '\n 消息\n'
|
|
199
|
+
+ ' send-msg "<文本>" 发文本(正文以 - 开头也照收)\n'
|
|
200
|
+
+ ' send-notice "<标题>" "<正文>" 发群通知卡(可加 --sub / --tone / --warn / --from)\n'
|
|
201
|
+
+ ' send-file <路径> · send-image <路径> 发文件 / 图片\n'
|
|
202
|
+
+ ' history [n] 拉最近 n 条(默认 20,最多 50):每条带 message_id,并标 →@某某·未@bot / →@bot\n'
|
|
203
|
+
+ ' reply <message_id> "<文本>" 回复某条\n'
|
|
204
|
+
+ ' edit <message_id> "<文本>" 改自己发过的消息\n'
|
|
205
|
+
+ ' recall <message_id> 撤回自己发过的消息(别名 delete)\n'
|
|
206
|
+
+ ' download <message_id> <file_key> <file|image> [目标路径] 下载群里的附件 / 图片(目标默认取 file_key)\n'
|
|
207
|
+
+ ' react <message_id> <表情> 表情回应(THUMBSUP 之类的枚举名,或直接给 👍)\n'
|
|
208
|
+
+ '\n 确认走 stderr;给程序读的(history / message_id)走 stdout;失败退出码 1\n'
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
module.exports = { FEISHU_MESSAGE_ACTIONS, FeishuCliError, parseFeishuArgs, feishuContext, runFeishuAction, feishuUsage }
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// 群通知卡(v0.11.137)。群里「机器人对大家说的话」—— 公告 / 升级播报 / 提醒 / 故障 / 变更 / 状态 —— 此前全是纯文本,
|
|
2
|
+
// 只能靠【】与「·」排版,和回答卡、和成员的闲聊混在一起。这里给它们一个共用的形状:
|
|
3
|
+
// · 彩色标题栏说性质(看颜色就知道是公告还是故障),标题一句话,副标题说「和我有什么关系 / 要我做什么」
|
|
4
|
+
// · 正文 markdown,最多几条要点(加粗小标题 + 一句话;命令用行内代码)
|
|
5
|
+
// · 注意事项:分隔线下的灰字,⚠ 开头,不抢主文
|
|
6
|
+
// · 右下角小字:时间 · 来源(与回答卡右下角小字同一位置、同一字号,风格一致)
|
|
7
|
+
// 纯函数、零依赖:飞书卡片 schema 2.0 的 JSON 形状只在此一处;没有这种卡的平台(企微)由调用方退回 noticeText()。
|
|
8
|
+
// ⚠ 2.0 不认 note 组件(真机被拒,见 takeovercard.js),小字一律 markdown + text_size notation + <font color='grey'>。
|
|
9
|
+
// ⚠ 脱敏:卡是 JSON 不能整体 redact,构卡时逐字段过 opts.redact(接管卡 / 生图卡同一纪律)。
|
|
10
|
+
|
|
11
|
+
// template = 标题栏底色;tagColor = 标题栏右侧类别小标签的色(text_tag 的色名表里没有 grey,用 neutral);emoji 只给文本兜底用 ——
|
|
12
|
+
// 卡面上颜色已经说明性质,标题再带 emoji 就显得满(v0.11.138 用户:「小一点、精致一些」)。
|
|
13
|
+
const TONES = {
|
|
14
|
+
notice: { template: 'blue', tagColor: 'blue', emoji: '📣', label: '公告' },
|
|
15
|
+
upgrade: { template: 'green', tagColor: 'green', emoji: '🚀', label: '升级' },
|
|
16
|
+
action: { template: 'orange', tagColor: 'orange', emoji: '👋', label: '提醒' },
|
|
17
|
+
alert: { template: 'red', tagColor: 'red', emoji: '⚠️', label: '故障' },
|
|
18
|
+
change: { template: 'indigo', tagColor: 'indigo', emoji: '🔁', label: '变更' },
|
|
19
|
+
status: { template: 'grey', tagColor: 'neutral', emoji: '🔄', label: '状态' },
|
|
20
|
+
}
|
|
21
|
+
// 群命令 --tone 收中文;英文键也认(大小写不限)。认不出返回 null,让调用方报「不认识的类别」而不是悄悄当公告发
|
|
22
|
+
const ALIASES = { 公告: 'notice', 通知: 'notice', 升级: 'upgrade', 提醒: 'action', 故障: 'alert', 告警: 'alert', 额度: 'alert', 变更: 'change', 切换: 'change', 状态: 'status' }
|
|
23
|
+
|
|
24
|
+
function resolveTone(x) {
|
|
25
|
+
const raw = String(x == null ? '' : x).trim()
|
|
26
|
+
if (!raw) return 'notice'
|
|
27
|
+
const k = raw.toLowerCase()
|
|
28
|
+
if (TONES[k]) return k
|
|
29
|
+
return ALIASES[raw] || null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
33
|
+
const asLines = (v) => (Array.isArray(v) ? v : (v == null ? [] : [v])).map((x) => String(x == null ? '' : x).trim()).filter(Boolean)
|
|
34
|
+
|
|
35
|
+
// 把调用方给的字段收拾成一份干净的通知:tone 归一、标题兜底、注意事项去掉重复的 ⚠(这里统一加一个)、逐字段脱敏
|
|
36
|
+
function normalize(n = {}, { redact } = {}) {
|
|
37
|
+
const r = typeof redact === 'function' ? (s) => String(redact(String(s))) : (s) => String(s)
|
|
38
|
+
const tone = resolveTone(n.tone) || 'notice'
|
|
39
|
+
const t = TONES[tone]
|
|
40
|
+
const title = r(String(n.title == null ? '' : n.title).trim() || t.label)
|
|
41
|
+
const subtitle = n.subtitle ? r(String(n.subtitle).trim()) : ''
|
|
42
|
+
const body = n.body ? r(String(n.body).replace(/^\n+|\n+$/g, '')) : ''
|
|
43
|
+
const warnings = asLines(n.warnings).map((w) => r(w.replace(/^(?:⚠️|⚠|\s)+/u, '').trim())).filter(Boolean)
|
|
44
|
+
const footer = n.footer ? r(String(n.footer).trim()) : ''
|
|
45
|
+
return { tone, emoji: t.emoji, label: t.label, template: t.template, tagColor: t.tagColor, title, subtitle, body, warnings, footer }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 正文里「**标签** 说明」这种要点行(v0.11.138):排成两栏对齐(标签窄栏加粗、说明宽栏),不再靠全角空格硬顶。
|
|
49
|
+
// 只认这一种写法,其余行原样 markdown;连续的普通行合成一个 markdown 元素,免得每行一个元素把行距撑开。
|
|
50
|
+
const ROW_RE = /^\*\*(.+?)\*\*[\s ::]*(\S.*)$/
|
|
51
|
+
function bodyElements(body) {
|
|
52
|
+
const out = []
|
|
53
|
+
let plain = []
|
|
54
|
+
const flush = () => { if (plain.length) { out.push({ tag: 'markdown', text_size: 'normal', content: plain.join('\n') }); plain = [] } }
|
|
55
|
+
for (const line of body.split('\n')) {
|
|
56
|
+
const m = ROW_RE.exec(line.trim())
|
|
57
|
+
if (!m) { plain.push(line); continue }
|
|
58
|
+
flush()
|
|
59
|
+
out.push({
|
|
60
|
+
tag: 'column_set', flex_mode: 'none', horizontal_spacing: '8px', horizontal_align: 'left',
|
|
61
|
+
columns: [
|
|
62
|
+
{ tag: 'column', width: 'weighted', weight: 1, vertical_align: 'top', elements: [{ tag: 'markdown', text_size: 'normal', content: `**${m[1]}**` }] },
|
|
63
|
+
{ tag: 'column', width: 'weighted', weight: 3, vertical_align: 'top', elements: [{ tag: 'markdown', text_size: 'normal', content: m[2] }] },
|
|
64
|
+
],
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
flush()
|
|
68
|
+
return out
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 飞书卡片(schema 2.0),撑满宽(fill)。标题栏:底色按类别 + 标题 + 副标题 + 右侧类别小标签;卡身 12px 内边距 / 8px 行距;
|
|
72
|
+
// 注意事项与右下角都是最小字号灰字(notation),正文 normal。标题 / 副标题 / 模板色那组字段与接管卡同款(服务端已收);
|
|
73
|
+
// text_tag_list / column_set / 内边距已在飞书上实发验证过。宽度:飞书只有 compact / default / fill 三档没有数值,
|
|
74
|
+
// .138 用的窄版用户看了「有点太窄」,三档对照后拍板**全部撑满**(v0.11.139),不按内容自动选档。
|
|
75
|
+
function buildNoticeCard(n, opts) {
|
|
76
|
+
const x = normalize(n, opts)
|
|
77
|
+
const elements = x.body ? bodyElements(x.body) : []
|
|
78
|
+
if (x.warnings.length) elements.push({ tag: 'markdown', text_size: 'notation', content: x.warnings.map((w) => `<font color='grey'>⚠ ${esc(w)}</font>`).join('\n') })
|
|
79
|
+
if (x.footer) elements.push({ tag: 'markdown', text_size: 'notation', text_align: 'right', content: `<font color='grey'>${esc(x.footer)}</font>` })
|
|
80
|
+
// 只有标题的通知:卡身不能空(空 elements 服务端会拒),把副标题 / 标题落一行
|
|
81
|
+
if (!elements.length) elements.push({ tag: 'markdown', text_size: 'normal', content: x.subtitle || x.title })
|
|
82
|
+
const header = {
|
|
83
|
+
title: { tag: 'plain_text', content: x.title },
|
|
84
|
+
template: x.template,
|
|
85
|
+
text_tag_list: [{ tag: 'text_tag', text: { tag: 'plain_text', content: x.label }, color: x.tagColor }],
|
|
86
|
+
padding: '12px 12px 12px 12px',
|
|
87
|
+
}
|
|
88
|
+
if (x.subtitle) header.subtitle = { tag: 'plain_text', content: x.subtitle }
|
|
89
|
+
return { schema: '2.0', config: { width_mode: 'fill' }, header, body: { padding: '12px 12px 12px 12px', vertical_spacing: '8px', elements } }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 纯文本版:同一份内容、同一个顺序(标题 → 副标题 → 正文 → ⚠ 注意 → 页脚)。没有卡的平台、发卡失败的兜底都用它,
|
|
93
|
+
// 两条路说的话必须一样 —— 卡与文本各写一份文案,迟早漂。
|
|
94
|
+
function noticeText(n, opts) {
|
|
95
|
+
const x = normalize(n, opts)
|
|
96
|
+
const L = [`${x.emoji} ${x.title}`]
|
|
97
|
+
if (x.subtitle) L.push(x.subtitle)
|
|
98
|
+
if (x.body) L.push(x.body)
|
|
99
|
+
for (const w of x.warnings) L.push(`⚠ ${w}`)
|
|
100
|
+
if (x.footer) L.push(x.footer)
|
|
101
|
+
return L.join('\n')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
module.exports = { TONES, resolveTone, buildNoticeCard, noticeText, bodyElements }
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// The CLI and the startup upgrade announcement share one delivery path. The
|
|
4
|
+
// platform router returns null when a chat has no card capability (WeCom and
|
|
5
|
+
// Slack); the text version is built from the same notice fields.
|
|
6
|
+
const { buildNoticeCard, noticeText, resolveTone, TONES } = require('./noticecard')
|
|
7
|
+
const { buildUpgradeNoticeParts } = require('../upgrade')
|
|
8
|
+
|
|
9
|
+
function cliNotice(positionals, { subtitle = '', tone = '', warnings = [], from = '', stamp = '' } = {}) {
|
|
10
|
+
const title = String(positionals && positionals[0] || '').trim()
|
|
11
|
+
if (!title) throw new Error('缺少标题')
|
|
12
|
+
const resolved = resolveTone(tone)
|
|
13
|
+
if (!resolved) throw new Error(`不认识的类别「${tone}」 — 可选:${Object.values(TONES).map((x) => x.label).join(' / ')}`)
|
|
14
|
+
return {
|
|
15
|
+
tone: resolved,
|
|
16
|
+
title,
|
|
17
|
+
subtitle,
|
|
18
|
+
body: (positionals || []).slice(1).join('\n'),
|
|
19
|
+
warnings,
|
|
20
|
+
footer: [stamp, from || '通知'].filter(Boolean).join(' · '),
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function deliverNotice(platform, chatId, notice, { redact, log = () => {} } = {}) {
|
|
25
|
+
if (!platform || typeof platform.send !== 'function') throw new TypeError('notice platform requires send')
|
|
26
|
+
// WeCom has sendCard for its own template cards, but cannot render a Feishu
|
|
27
|
+
// notice card. Check the target adapter when using the multi-platform router.
|
|
28
|
+
const target = typeof platform.forChat === 'function' ? platform.forChat(chatId) : platform
|
|
29
|
+
if (target && target.features?.noticeCard !== false && typeof platform.sendCard === 'function') {
|
|
30
|
+
try {
|
|
31
|
+
const result = await platform.sendCard(chatId, buildNoticeCard(notice, { redact }))
|
|
32
|
+
if (result != null) return { mode: 'card', result }
|
|
33
|
+
} catch (error) {
|
|
34
|
+
log(`通知卡发送失败(${chatId}),退回文本: ${error && error.message}`)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const result = await platform.send(chatId, noticeText(notice, { redact }))
|
|
38
|
+
return { mode: 'text', result }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function deliverUpgradeNotice(platform, chatId, lastVersion, currentVersion, changelog, brand, options) {
|
|
42
|
+
const notice = buildUpgradeNoticeParts(lastVersion, currentVersion, changelog, brand)
|
|
43
|
+
if (!notice) return null
|
|
44
|
+
return deliverNotice(platform, chatId, notice, options)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = { cliNotice, deliverNotice, deliverUpgradeNotice }
|
package/src/ui/console.html
CHANGED
|
@@ -1160,7 +1160,7 @@ function renderOverview(){
|
|
|
1160
1160
|
<span class="tag prov" title="${(()=>{const pn=g.provider||S.defaultProvider;const pv=(S.providers||[]).find(p=>p.name===pn);const m=g.model||(pv&&pv.model);return '模型大脑:'+esc(pn)+(m?' · '+esc(m):'')+'(在「群·项目」页切换)';})()}">${esc(g.provider||S.defaultProvider)}</span>
|
|
1161
1161
|
${g.paused?'<span class="tag pz" title="已暂停(「群·项目」页可恢复)">'+IC.pause+' 暂停</span>':`<span class="tag glive" data-g="${esc(g.chatId)}" title="空闲">idle</span>`}
|
|
1162
1162
|
<span class="meta" title="${esc(g.workdir)}">${esc(shortPath(g.workdir))}</span>
|
|
1163
|
-
${liveOn&&!g.paused?`<button class="iconbtn sm" data-osay="${esc(g.chatId)}" title="向这个群插一句 —— 空闲时马上处理;正在忙时,加入当前任务或排在它后面(看「忙时跟进」)" style="flex:none">${IC.say}</button>`:''}
|
|
1163
|
+
${liveOn&&!g.paused?`<button class="iconbtn sm" data-osay="${esc(g.chatId)}" title="${S.runtimeCapabilities?'向这个群插一句 —— 当前版本只在该群空闲时可用;这句话和回复都会发进群':'向这个群插一句 —— 空闲时马上处理;正在忙时,加入当前任务或排在它后面(看「忙时跟进」)'}" style="flex:none">${IC.say}</button>`:''}
|
|
1164
1164
|
${liveOn?`<button class="iconbtn sm" data-ostop="${esc(g.chatId)}" title="停止当前任务(保留会话)" style="flex:none">${IC.square}</button>
|
|
1165
1165
|
<button class="iconbtn sm" data-onew="${esc(g.chatId)}" title="${S.runtimeCapabilities?'开新会话 — 仅在该群空闲、记录全部结账后可用;运行中请先停止,结束后再开(同群内 /new)':'开新会话 — 运行中会先中断当前任务并清空队列(同群内 /new)'}" style="flex:none">${IC.rotate}</button>`:''}</div>`).join('')
|
|
1166
1166
|
:'<div class="empty">还没有群项目 · 去「群·项目」配置</div>';
|
|
@@ -2610,7 +2610,7 @@ function cronFullHtml(text){
|
|
|
2610
2610
|
}).join('');
|
|
2611
2611
|
}
|
|
2612
2612
|
const RUNTIME_UI_TEXT={
|
|
2613
|
-
"summary": "
|
|
2613
|
+
"summary": "当前支持多群消息、流式卡片回复、忙时跟进、停止任务、空闲群开新会话、空闲群的控制台插话(这句话和回复都进群)、状态查看、认证管理、绑定新群、空闲群的模型(Codex / Claude Code)和项目目录切换、模型的添加与设置,以及后台运行时的软件升级。群忙时的插话、静默插话、解除绑定、移除模型、定时任务、生图、画布与预览暂不可用。",
|
|
2614
2614
|
"groups": "可绑定新群,修改模型、项目目录、权限、数据输出和忙时跟进;该群须没有进行中的任务和未结记录。切模型或项目会开始新的一段对话。停止任务和开新会话在「概览」,开新会话须等该群空闲。群里发 /model 也能切换模型。",
|
|
2615
2615
|
"skills": "当前支持飞书文字消息、忙时跟进、任务回复和停止。定时任务、生图、群消息管理、画布与预览暂未接入。",
|
|
2616
2616
|
"cron": "当前不执行定时任务;已有定义仅供查看。",
|
|
@@ -2619,7 +2619,7 @@ const RUNTIME_UI_TEXT={
|
|
|
2619
2619
|
"paused": "已暂停:不接收本群新消息,配置与会话保留。",
|
|
2620
2620
|
"resume": "恢复本群:重新接收本群消息,配置与会话保留。",
|
|
2621
2621
|
"resumeLabel": "恢复",
|
|
2622
|
-
"steer": "
|
|
2622
|
+
"steer": "群成员的后续文字消息可加入当前任务;只有确认消息已进入模型后才算送达。控制台插话只在群空闲时可用,不加入当前任务;定时任务暂不可用。",
|
|
2623
2623
|
"preview": "当前版本未接入预览服务。",
|
|
2624
2624
|
"providers": "可添加模型,修改型号、端点、密钥、思考深度与推理强度,测试、隐藏或拖动排序;各群的模型在「群·项目」里切换。改型号、后端或端点时,用它的群须空闲,并会开始新的一段对话;默认模型和群正在用的模型不能隐藏。移除模型暂不可用。",
|
|
2625
2625
|
"providersView": "当前可查看模型配置与登录状态;各群的模型在「群·项目」里切换。添加或修改模型暂不可用。",
|
|
@@ -3337,6 +3337,20 @@ function sayPlan(chatId,reply){
|
|
|
3337
3337
|
function runsCodex(g){const pr=(S.providers||[]).find(pp=>pp.name===g.effectiveProvider);return !!pr&&pr.runner==='codex';}
|
|
3338
3338
|
function sayAge(ms){const m=Math.floor((ms||0)/60e3);if(m<1)return '不到 1 分钟';if(m<60)return m+' 分钟';return Math.floor(m/60)+' 小时'+(m%60?' '+(m%60)+' 分钟':'');}
|
|
3339
3339
|
let sayRefresh=null; // 浮层开着时,概览的实时刷新顺手重算一遍(打字的这会儿任务可能刚好跑完)
|
|
3340
|
+
/* 持久处理流程(当前版本)的插话只有一种形态:空闲群、这句话进群、回复进群。群忙 / 待人工 / 升级中等
|
|
3341
|
+
直接说明白、按钮不可点 —— 不再说「加入当前任务 / 排队」,那是旧流程的行为。原因由服务端实时状态
|
|
3342
|
+
(live.groups[chatId].sayBlocked,与插话接口同一个判据)给出;没有实时状态时以接口回报为准。 */
|
|
3343
|
+
const SAY_V1={
|
|
3344
|
+
ok:'当前版本的插话:这句话(标着「管理员」)和它做完的回复都会发进群;静默插话、不回复的插话和群忙时的插话暂不支持。',
|
|
3345
|
+
busy:'该群有任务进行中,当前版本的插话不排队也不加入当前任务,等它结束再插。',
|
|
3346
|
+
held:'该群上一个任务结果不明,已暂停处理、等待人工确认;现在不能插话。',
|
|
3347
|
+
upgrading:'正在升级,现在不能插话,请一分钟后再试。',
|
|
3348
|
+
paused:'这个群项目已暂停,先恢复再插话。',
|
|
3349
|
+
closed:'服务正在启动或停止,或该群当前未开放;现在不能插话。'
|
|
3350
|
+
};
|
|
3351
|
+
function sayV1Blocked(chatId){const lv=((S.live||{}).groups||{})[chatId]||{};return lv.sayBlocked||(lv.busy?'busy':null);}
|
|
3352
|
+
/* 每次提交一个 32 位十六进制幂等键:同一段文字没成功前重点一次,沿用同一个键,服务端据此不重复处理 */
|
|
3353
|
+
function sayRequestId(){const a=new Uint8Array(16);crypto.getRandomValues(a);return Array.from(a,b=>b.toString(16).padStart(2,'0')).join('');}
|
|
3340
3354
|
/* 「向群里插一句」浮层。入口在概览「群·项目」每一行的 ✎ —— 动作长在对象身上,
|
|
3341
3355
|
目标群由那一行决定,所以这里不再有下拉。
|
|
3342
3356
|
⚠ 两个开关**平级**(不是嵌套):四种组合都要够得着,其中「都不勾」= 群里彻底无痕,
|
|
@@ -3361,7 +3375,17 @@ function openSay(chatId){
|
|
|
3361
3375
|
</div>`;
|
|
3362
3376
|
$('#modalBg').classList.add('show');
|
|
3363
3377
|
const ta=$('#sayT'), sw=$('#sayShow'), rp=$('#sayReply'), pv=$('#sayPreview'), go=$('#sayGo'), goTxt=$('#sayGoTxt'), bz=$('#sayBusy');
|
|
3364
|
-
const
|
|
3378
|
+
const v1=!!S.runtimeCapabilities; let sending=false, req=null;
|
|
3379
|
+
// 当前版本两个开关固定勾上:只支持「这句话进群 + 回复进群」,开关留着,让人看见这句会以什么样子出现
|
|
3380
|
+
if(v1){ for(const c of [sw,rp]){ c.checked=true; c.disabled=true; c.parentElement.title='当前版本固定:这句话和回复都发进群'; } }
|
|
3381
|
+
const preview=()=>{ const a=sw.checked,b=rp.checked, plan=v1?null:sayPlan(chatId,b);
|
|
3382
|
+
if(v1){
|
|
3383
|
+
const why=sayV1Blocked(chatId), lv=((S.live||{}).groups||{})[chatId]||{};
|
|
3384
|
+
bz.innerHTML=why==='busy'?`<span class="dot"></span>${lv.busy?'正在处理任务 · 已 '+sayAge(lv.sinceMs):'正在收尾'}`:why==='held'?'<span class="dot"></span>待人工确认':'';
|
|
3385
|
+
pv.textContent=why?(SAY_V1[why]||SAY_V1.closed):SAY_V1.ok;
|
|
3386
|
+
if(!sending){ go.disabled=!!why; goTxt.textContent=why?'暂不能插':'插入'; }
|
|
3387
|
+
return;
|
|
3388
|
+
}
|
|
3365
3389
|
const vis = a&&b ? '群里会看到:你插的这句话(标着「管理员」)和它做完的回复。'
|
|
3366
3390
|
: a&&!b ? '群里只看到你插的这句话 —— 它做完<b>不出声</b>,结果去「实时活动」看。'
|
|
3367
3391
|
: !a&&b ? '群里只看到它的回复 —— 群成员看不出是谁让做的。'
|
|
@@ -3386,11 +3410,13 @@ function openSay(chatId){
|
|
|
3386
3410
|
$('#sayCancel').onclick=closeModal;
|
|
3387
3411
|
go.onclick=async()=>{
|
|
3388
3412
|
const text=(ta.value||'').trim(); if(!text){ ta.focus(); return }
|
|
3389
|
-
|
|
3413
|
+
if(!req||req.text!==text) req={id:sayRequestId(),text}; // 没成功前同一段文字重试 = 同一个键
|
|
3414
|
+
go.disabled=true; sending=true; const old=goTxt.textContent; goTxt.textContent='插入中…';
|
|
3390
3415
|
try{
|
|
3391
|
-
const r=await api('/api/groups/say','POST',{chatId,text,show:sw.checked,reply:rp.checked});
|
|
3416
|
+
const r=await api('/api/groups/say','POST',{chatId,text,show:sw.checked,reply:rp.checked,requestId:req.id});
|
|
3392
3417
|
const inj=r.sayInjected; delete r.sayInjected; // 瞬态字段,读完即删(别进状态签名)
|
|
3393
3418
|
S=r; closeModal(); renderOverview();
|
|
3419
|
+
if(v1&&inj&&inj.where&&inj.where.state==='duplicate'){ toast('这次提交已经插入过,没有重复处理','ok'); return }
|
|
3394
3420
|
// 回报照实说清两件事:这句**落在哪**(服务端按引擎实际落点回报),以及群里会/不会出现什么 —— 只说「已插入」等于没说
|
|
3395
3421
|
const vis = inj&&inj.show ? (inj.reply?'群里能看到这句话和回复':'群里只看到这句话') : (inj&&inj.reply?'群里只会出现回复':'群里不会有任何动静');
|
|
3396
3422
|
const w=(inj&&inj.where)||{};
|
|
@@ -3398,7 +3424,7 @@ function openSay(chatId){
|
|
|
3398
3424
|
: w.state==='queued' ? (w.join ? '已排队 · 当前任务一就绪就会加入' : '群正忙,已排队'+(w.ahead?`(前面还有 ${w.ahead} 条)`:'')+' · 当前任务结束后处理')
|
|
3399
3425
|
: '已插入';
|
|
3400
3426
|
toast(head+' · '+vis,'ok');
|
|
3401
|
-
}catch(e){ toast(e.message,'err'); go.disabled=false; const t2=$('#sayGoTxt'); if(t2) t2.textContent=old }
|
|
3427
|
+
}catch(e){ toast(e.message,'err'); sending=false; go.disabled=false; const t2=$('#sayGoTxt'); if(t2) t2.textContent=old; if(v1&&$('#sayT')) preview() }
|
|
3402
3428
|
};
|
|
3403
3429
|
}
|
|
3404
3430
|
function closeModal(){clearTimeout(nsTimer);nsTimer=null;nsSeq++;sayRefresh=null;$('#modalBg').classList.remove('show');$('#modal').className='modal';
|
|
@@ -3517,12 +3543,12 @@ function renderSettings(){
|
|
|
3517
3543
|
<div class="hint">代理第一次填,新的对话轮次就生效;改已有的地址要重启后台服务(sintbot service restart)。</div></div>
|
|
3518
3544
|
</div>`;
|
|
3519
3545
|
const updateJob=upd&&upd.job;
|
|
3520
|
-
// 1.4.x 多群服务的升级器带 mode
|
|
3546
|
+
// 1.4.x 多群服务的升级器带 mode:后台托管才能一键升级,前台与开发版只给说明。版本变化后的群播报由启动时的公告卡片负责(控制台可关)。
|
|
3521
3547
|
const updMode=upd&&upd.mode||'';
|
|
3522
3548
|
const updateBusy=!!(updateJob&&['queued','installing','switching','restarting','restart-needed'].includes(updateJob.state));
|
|
3523
3549
|
const updCanInstall=!!(upd&&(updMode?upd.upgradeSupported:true));
|
|
3524
3550
|
const updateText=!upd?'后台版本检查不可用':updateJob&&updateJob.state!=='done'||updateJob&&!upd.available&&updMode?esc(updateJob.message||updateJob.state):upd.available?`检测到新版本 v${esc(upd.latestVersion)} · 当前 v${esc(upd.currentVersion)}`:updMode==='dev'&&upd.latestVersion?`最新正式版 v${esc(upd.latestVersion)} · 当前开发版 v${esc(upd.currentVersion)}`:upd.checkedAt&&upd.latestVersion?`当前为最新版本 v${esc(upd.currentVersion)}`:upd.checkedAt?'暂时无法确认最新版本':'尚未检查新版';
|
|
3525
|
-
const updHint=updMode==='managed'?'只安装检测到的稳定版本:等所有群空闲、记录结清后装到单独的目录,再切换后台服务;新版本没在 90
|
|
3551
|
+
const updHint=updMode==='managed'?'只安装检测到的稳定版本:等所有群空闲、记录结清后装到单独的目录,再切换后台服务;新版本没在 90 秒内连上全部群会自动换回。旧版本保留用于回滚,不提权。'
|
|
3526
3552
|
:updMode==='foreground'?'前台运行不能在控制台升级:在运行 SintBot 的终端按 Ctrl-C 等待退出,重新运行一行安装命令 curl -fsSL https://sintbot.com/install.sh | bash,再用原来的启动命令启动。'
|
|
3527
3553
|
:updMode==='dev'?'开发版不在控制台升级,请用部署脚本。':'只安装检测到的稳定版本;所有群任务与队列清空后才会安装。需要可写的全局安装目录,不会提权;后台服务会自动重启,前台运行需手动重启。';
|
|
3528
3554
|
const updateCard=`<div class="card" style="grid-column:1/-1" id="updateCard"><div class="lbl">软件更新</div>
|
|
@@ -4276,7 +4302,7 @@ updateThemeBtn();
|
|
|
4276
4302
|
const I18N_EN=[
|
|
4277
4303
|
["需要处理", "Needs attention"],
|
|
4278
4304
|
["当前可用操作", "Available controls"],
|
|
4279
|
-
["
|
|
4305
|
+
["当前支持多群消息、流式卡片回复、忙时跟进、停止任务、空闲群开新会话、空闲群的控制台插话(这句话和回复都进群)、状态查看、认证管理、绑定新群、空闲群的模型(Codex / Claude Code)和项目目录切换、模型的添加与设置,以及后台运行时的软件升级。群忙时的插话、静默插话、解除绑定、移除模型、定时任务、生图、画布与预览暂不可用。", "Available: group messages, streaming reply cards, busy-turn followups, stopping tasks, new sessions for idle groups, console messages to idle groups (the line and the reply both appear in the group), live status, authentication, binding new groups, switching the model (Codex / Claude Code) or project of an idle group, adding and configuring models, and software upgrades in a background run. Console messages to busy groups, silent console messages, unbinding, removing models, schedules, image generation, canvas and previews are unavailable."],
|
|
4280
4306
|
["可绑定新群,修改模型、项目目录、权限、数据输出和忙时跟进;该群须没有进行中的任务和未结记录。切模型或项目会开始新的一段对话。停止任务和开新会话在「概览」,开新会话须等该群空闲。群里发 /model 也能切换模型。", "Bind new groups and change the model, project folder, permissions, data display and busy-turn followups of a group with no running task or unsettled record. Changing the model or project starts a new conversation. Stop tasks and start new sessions in Overview; a new session needs the group to be idle. Members can also switch models with /model in the group."],
|
|
4281
4307
|
["当前支持飞书文字消息、忙时跟进、任务回复和停止。定时任务、生图、群消息管理、画布与预览暂未接入。", "Feishu text messages, busy-turn followups, task replies and stopping are supported. Schedules, image generation, group message management, canvas and previews are unavailable."],
|
|
4282
4308
|
["当前不执行定时任务;已有定义仅供查看。", "Schedules are not executed. Existing definitions are shown for reference only."],
|
|
@@ -4285,7 +4311,7 @@ const I18N_EN=[
|
|
|
4285
4311
|
["已暂停:不接收本群新消息,配置与会话保留。", "Paused: no new group messages are accepted; configuration and sessions are retained."],
|
|
4286
4312
|
["恢复本群:重新接收本群消息,配置与会话保留。", "Resume this group: its messages are accepted again; configuration and sessions are retained."],
|
|
4287
4313
|
["恢复", "Resume"],
|
|
4288
|
-
["
|
|
4314
|
+
["群成员的后续文字消息可加入当前任务;只有确认消息已进入模型后才算送达。控制台插话只在群空闲时可用,不加入当前任务;定时任务暂不可用。", "Followup text messages from group members can join the current task. Delivery is confirmed only when the model receives the message. Console messages work only while the group is idle and never join the current task; schedules are unavailable."],
|
|
4289
4315
|
["当前版本未接入预览服务。", "Preview services are unavailable in this version."],
|
|
4290
4316
|
["当前可查看模型配置与登录状态;各群的模型在「群·项目」里切换。添加或修改模型暂不可用。", "Model configuration and login status are shown here. Switch each group's model under Groups. Adding or changing models is unavailable."],
|
|
4291
4317
|
["可添加模型,修改型号、端点、密钥、思考深度与推理强度,测试、隐藏或拖动排序;各群的模型在「群·项目」里切换。改型号、后端或端点时,用它的群须空闲,并会开始新的一段对话;默认模型和群正在用的模型不能隐藏。移除模型暂不可用。", "Add models; change their model id, endpoint, key, thinking depth and reasoning effort; test, hide or drag to reorder them. Switch each group's model under Groups. Changing a model id, backend or endpoint needs the groups using it to be idle and starts a new conversation for them. The default model and models in use by a group cannot be hidden. Removing models is unavailable."],
|
|
@@ -4973,6 +4999,17 @@ const I18N_EN=[
|
|
|
4973
4999
|
["什么都不会出现","shows nothing at all"],
|
|
4974
5000
|
[",连出错也不会说 —— 状态去「实时活动」看。",", not even errors — check the status under \"Live activity\"."],
|
|
4975
5001
|
["插入中…","Inserting…"],
|
|
5002
|
+
["当前版本的插话:这句话(标着「管理员」)和它做完的回复都会发进群;静默插话、不回复的插话和群忙时的插话暂不支持。","In this version a console message always posts the line (marked \"admin\") and its reply to the group; silent messages, messages without a reply and messages to a busy group are unavailable."],
|
|
5003
|
+
["该群有任务进行中,当前版本的插话不排队也不加入当前任务,等它结束再插。","This group has a task in progress. In this version a console message neither queues nor joins the current task; send it once the task ends."],
|
|
5004
|
+
["该群上一个任务结果不明,已暂停处理、等待人工确认;现在不能插话。","The outcome of this group's last task is unknown; the group is paused for operator review, so console messages are unavailable."],
|
|
5005
|
+
["正在升级,现在不能插话,请一分钟后再试。","An upgrade is in progress; try the console message again in a minute."],
|
|
5006
|
+
["这个群项目已暂停,先恢复再插话。","This group project is paused; resume it before sending a console message."],
|
|
5007
|
+
["服务正在启动或停止,或该群当前未开放;现在不能插话。","The service is starting or stopping, or this group is not open right now; console messages are unavailable."],
|
|
5008
|
+
["暂不能插","Unavailable"],
|
|
5009
|
+
["待人工确认","Awaiting operator review"],
|
|
5010
|
+
["当前版本固定:这句话和回复都发进群","Fixed in this version: the line and the reply both go to the group"],
|
|
5011
|
+
["这次提交已经插入过,没有重复处理","This submission was already delivered; it was not processed again"],
|
|
5012
|
+
["向这个群插一句 —— 当前版本只在该群空闲时可用;这句话和回复都会发进群","Interject in this group — in this version only while the group is idle; the line and the reply both go to the group"],
|
|
4976
5013
|
["已插入 · 群里能看到这句话和回复","Interjected · the group can see this line and the reply"],
|
|
4977
5014
|
["已插入 · 群里只看到这句话","Interjected · the group sees only this line"],
|
|
4978
5015
|
["已插入 · 群里只会出现回复","Interjected · only the reply shows up in the group"],
|
|
@@ -5023,7 +5060,7 @@ const I18N_EN=[
|
|
|
5023
5060
|
["版本检查完成","Version check complete"],
|
|
5024
5061
|
["暂时无法确认最新版本","The latest version could not be confirmed yet"],
|
|
5025
5062
|
["升级已排队,所有群空闲后开始","Upgrade queued; it starts when every group is idle"],
|
|
5026
|
-
["只安装检测到的稳定版本:等所有群空闲、记录结清后装到单独的目录,再切换后台服务;新版本没在 90
|
|
5063
|
+
["只安装检测到的稳定版本:等所有群空闲、记录结清后装到单独的目录,再切换后台服务;新版本没在 90 秒内连上全部群会自动换回。旧版本保留用于回滚,不提权。","Installs only the detected stable version: once every group is idle and settled, it goes into its own folder and the background service switches to it; if the new version does not connect every group within 90 seconds, the previous one is restored. The previous version is kept for rollback. No elevated privileges."],
|
|
5027
5064
|
["前台运行不能在控制台升级:在运行 SintBot 的终端按 Ctrl-C 等待退出,重新运行一行安装命令 curl -fsSL https://sintbot.com/install.sh | bash,再用原来的启动命令启动。","A foreground run cannot upgrade from the console: press Ctrl-C in the terminal running SintBot and wait for it to exit, rerun the one-line installer curl -fsSL https://sintbot.com/install.sh | bash, then start with your usual command."],
|
|
5028
5065
|
["开发版不在控制台升级,请用部署脚本。","Development builds are not upgraded from the console; use the deployment scripts."],
|
|
5029
5066
|
["查看进度","View progress"],
|
|
@@ -5906,7 +5943,7 @@ async function fetchCodexModels(){if(CX_MODELS)return CX_MODELS;try{CX_MODELS=aw
|
|
|
5906
5943
|
function cxDefEffortLabel(model){const m=CX_MODELS&&(CX_MODELS.models||[]).find(x=>x.model===model);return '默认'+(m&&m.defEffort?'('+m.defEffort+')':'');}
|
|
5907
5944
|
/* 有 codex provider 时启动即预热模型列表(server 缓存 10min)→ 卡片默认档标注补齐 + 面板秒开 */
|
|
5908
5945
|
function warmCodexModels(){if(!CX_MODELS&&S&&(S.providers||[]).some(x=>x.runner==='codex'&&!x.hidden))fetchCodexModels().then(()=>renderProviders());}
|
|
5909
|
-
const RUNTIME_DISABLED_SELECTORS='
|
|
5946
|
+
const RUNTIME_DISABLED_SELECTORS='.gpv,[data-rm],[data-gcfg],[data-cronnew],[data-cronadd],[data-crontgl],[data-cronrm],[data-reposave],[data-repotest],[data-iconuse],[data-iconsys],[data-iconstyle],[data-stylecollect],#mNewChat,#amDel,#mNewGo,#pgSend,#pgNew,#fSave,#slSave,#wSave,#bSave,#styleNew,[data-styleedit],[data-styledup],[data-styledel],#nsSave,#nsExtract,#clSave,[data-pvstop],[data-lvkill],[data-lvkillall]';
|
|
5910
5947
|
// 持久处理流程按服务端白名单放行:前端照同一份名单判断某个动作能不能做,而不是一刀切。
|
|
5911
5948
|
function runtimeAllows(method,path){
|
|
5912
5949
|
const caps=S&&S.runtimeCapabilities;if(!caps)return true;
|
|
@@ -5932,6 +5969,8 @@ function applyRuntimeCapabilities(){
|
|
|
5932
5969
|
if(!S||!S.runtimeCapabilities)return;
|
|
5933
5970
|
if(banner){const body=banner.querySelector('[data-runtime-summary]'),text=trText(RUNTIME_UI_TEXT.summary);if(body&&body.textContent!==text)body.textContent=text;}
|
|
5934
5971
|
document.querySelectorAll(RUNTIME_DISABLED_SELECTORS).forEach(el=>disableRuntimeControl(el,RUNTIME_UI_TEXT.unavailable));
|
|
5972
|
+
// 插话照服务端白名单放行(第 1 版只接空闲群,忙不忙在浮层里按实时状态说)
|
|
5973
|
+
if(!runtimeAllows('POST','/api/groups/say'))document.querySelectorAll('[data-osay]').forEach(el=>disableRuntimeControl(el,RUNTIME_UI_TEXT.unavailable));
|
|
5935
5974
|
if(!runtimeAllows('POST','/api/providers/order'))document.querySelectorAll('#pgrid .drag').forEach(el=>{el.draggable=false;el.title=trText(RUNTIME_UI_TEXT.unavailable);});
|
|
5936
5975
|
}
|
|
5937
5976
|
document.addEventListener('click',event=>{
|
package/src/ui/serverbase.js
CHANGED
|
@@ -1109,8 +1109,25 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
|
|
|
1109
1109
|
// 说不清就别说成功:与 issues 那条 canNotify 同一条纪律 —— 「让人以为已经插进去了」
|
|
1110
1110
|
// 比直接报错更糟,因为他会等一个永远不来的回应。
|
|
1111
1111
|
if (!r.ok) {
|
|
1112
|
-
const why = { 'no-daemon': '守护进程没在跑(纯配置模式)—— 插话需要它在线', paused: '这个群项目已暂停,先恢复再插话', unbound: '没有这个群项目', 'inject-failed': '注入失败(守护进程侧报错,看日志)'
|
|
1113
|
-
|
|
1112
|
+
const why = { 'no-daemon': '守护进程没在跑(纯配置模式)—— 插话需要它在线', paused: '这个群项目已暂停,先恢复再插话', unbound: '没有这个群项目', 'inject-failed': '注入失败(守护进程侧报错,看日志)',
|
|
1113
|
+
// 持久处理流程(第 1 版插话)的拒绝原因。shown-failed 之前的几条在发群消息之前就拒,群里没有任何字;
|
|
1114
|
+
// 从 shown-failed 起,群里可能已经有那行「【管理员】…」
|
|
1115
|
+
busy: '该群有任务进行中,当前版本的插话不排队也不加入当前任务,等它结束再插',
|
|
1116
|
+
held: '该群上一个任务结果不明,已暂停处理、等待人工确认;插话未执行',
|
|
1117
|
+
recovering: '该群上一个任务出错,正在收尾;插话未执行,请稍后再试',
|
|
1118
|
+
upgrading: '正在升级,插话未执行,请一分钟后再试',
|
|
1119
|
+
'not-serving': '服务正在启动或停止,或该群当前未开放;插话未执行',
|
|
1120
|
+
'silent-unsupported': '当前版本的插话必须把这句话发进群(勾选「插话」);静默插话暂不支持',
|
|
1121
|
+
'quiet-unsupported': '当前版本的插话必须让回复进群(勾选「回复」);群里无痕的插话暂不支持',
|
|
1122
|
+
command: '插话不执行群命令;停止任务、开新会话请用概览里的按钮',
|
|
1123
|
+
'request-id': '这次提交缺少有效的提交编号,请刷新控制台后再试',
|
|
1124
|
+
empty: '内容不能为空',
|
|
1125
|
+
'shown-failed': '群里那行「【管理员】…」没能确认发出(失败或超时),插话未执行;即使群里随后出现这行,它也不会被处理',
|
|
1126
|
+
'busy-after-post': '这行字发进群时该群刚好开始了别的任务,这句没有处理;等它结束后原样再点一次插入,会沿用群里已有的那行',
|
|
1127
|
+
refused: '该群拒收了这句(原因已回复在群里那行下面);插话未执行',
|
|
1128
|
+
quarantined: '这句的来源已被隔离,未执行',
|
|
1129
|
+
unconfirmed: '这行字已发进群,但没在 5 秒内确认登记,结果以群里和「实时活动」为准;原样再点一次插入不会重复处理' }[r.reason] || '插话失败'
|
|
1130
|
+
return sendJSON(res, 409, { error: why, reason: r.reason || null })
|
|
1114
1131
|
}
|
|
1115
1132
|
return sendJSON(res, 200, Object.assign(await buildState(), { sayInjected: { chatId, show: visible, reply: wantReply, where } }))
|
|
1116
1133
|
}
|