@qingtui/openclaw-plugin 0.0.0-stage → 1.1.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.
@@ -0,0 +1,301 @@
1
+ /**
2
+ * nudge 入站消息处理。
3
+ *
4
+ * 入站通道不是 webhook:插件启动时由 src/service.ts 注册的后台 service
5
+ * (qingtui-inbound)通过轻推 Socket SDK 建立长连接,nudge 服务器把消息
6
+ * 推送过来;src/event.ts 负责归一化与去重,本模块负责准入、路由与派发。
7
+ *
8
+ * 【新手须知】术语:
9
+ * ingress OpenClaw 的入站访问控制判定(allowlist 策略)
10
+ * route 决定由哪个 agent、哪个会话 sessionKey 处理本条消息
11
+ *
12
+ * 完整入站流水线见 handleNudgeInboundMessage 的 doc。
13
+ * 调用方:src/service.ts 的后台 service(每条 socket 推送调一次)。
14
+ */
15
+ import { defineStableChannelIngressIdentity, resolveChannelMessageIngress, } from "openclaw/plugin-sdk/channel-ingress-runtime";
16
+ import { isControlCommandMessage, shouldComputeCommandAuthorized, } from "openclaw/plugin-sdk/command-detection";
17
+ import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-local-roots";
18
+ import { resolveOutboundMediaUrls, } from "openclaw/plugin-sdk/reply-payload";
19
+ import { encodeNudgeTarget, getDynamicAllowFrom, nudgeChatApi, nudgeStreamApi, } from "./client.js";
20
+ import { isNudgeStreamingEnabled, NUDGE_CHANNEL_ID, resolveAccount, } from "./config.js";
21
+ import { createEventDeduper } from "./event.js";
22
+ import { NudgeReplyStream } from "./stream.js";
23
+ export { normalizeNudgeEvent } from "./event.js";
24
+ /** 重推/重连可能重复投递同一 event_id;按账号维度去重 */
25
+ const deduper = createEventDeduper();
26
+ /**
27
+ * 轻推入站的身份描述:以 open_id / channel_id 为稳定主键。
28
+ * 不支持通配:白名单只来自平台同步的可见范围,任何条目都不能放开全部发送者。
29
+ */
30
+ const nudgeIngressIdentity = defineStableChannelIngressIdentity({
31
+ kind: "stable-id",
32
+ isWildcardEntry: () => false,
33
+ });
34
+ /**
35
+ * 处理一条已归一化的 nudge 入站消息。
36
+ * 由 src/service.ts 的 WebSocket service 在收到推送时调用。
37
+ *
38
+ * 完整入站链路(对齐内置渠道 nextcloud-talk / telegram):
39
+ * 1. 访问控制:resolveChannelMessageIngress(allowlist 策略 + 动态白名单)
40
+ * - 拒绝:静默丢弃
41
+ * 2. 路由:resolveAgentRoute 决定 agentId + sessionKey
42
+ * 3. 上下文:buildChannelInboundEventContext 组装 prompt 上下文
43
+ * 4. 投递:dispatchReply 跑 ingest -> resolve -> record -> dispatch,
44
+ * 回复经 delivery 适配器 -> nudgeChatApi.sendMessage 发回 nudge
45
+ */
46
+ export async function handleNudgeInboundMessage(params) {
47
+ const { event, accountId, runtime, logger } = params;
48
+ const tag = accountId ? `[${accountId}]` : "";
49
+ if (deduper.isDuplicate(accountId ?? "default", event.id)) {
50
+ logger?.info(`openclaw-nudge${tag}: 忽略重复事件 ${event.id}`);
51
+ return;
52
+ }
53
+ if (!runtime) {
54
+ // service 启动晚于 register,正常不会发生;保守处理
55
+ logger?.warn(`openclaw-nudge${tag}: runtime 未就绪,丢弃入站消息`);
56
+ return;
57
+ }
58
+ // runtime.config.current() 返回 DeepReadonly<OpenClawConfig>,
59
+ // 下游 API 签名要求 OpenClawConfig,只读保护在插件层面无影响,断言去除。
60
+ const cfg = runtime.config.current();
61
+ let account;
62
+ try {
63
+ account = resolveAccount(cfg, accountId);
64
+ }
65
+ catch (err) {
66
+ logger?.error(`openclaw-nudge${tag}: 账号解析失败: ${String(err)}`);
67
+ return;
68
+ }
69
+ // -----------------------------------------------------------------------
70
+ // 1. 访问控制:resolveChannelMessageIngress
71
+ // -----------------------------------------------------------------------
72
+ // /new /reset 等文本控制命令由 openclaw 回复管线执行,但只在入站上下文
73
+ // CommandAuthorized=true 时生效:疑似命令消息要求 ingress 附带命令门控,
74
+ // 命令 owner 白名单缺省取 effective DM 白名单(即动态白名单,与发送者
75
+ // 准入同源),白名单内的用户即可执行命令(对齐 clickclack 官方接线)。
76
+ const commandCheckRequested = shouldComputeCommandAuthorized(event.text, cfg);
77
+ // biome-ignore lint/suspicious/noImplicitAnyLet: openclaw 没导出ResolvedChannelMessageIngress
78
+ let ingress;
79
+ try {
80
+ // 群聊白名单存的是 channel_id(平台同步的"应用可见范围"里群聊与用户
81
+ // open_id 合并在同一数组),因此群聊的准入主体用 channel_id 匹配;
82
+ // 单聊仍用发送者 id。
83
+ const ingressSubject = event.chatType === "group" ? event.chatId : event.from;
84
+ const dynamicAllowList = [...getDynamicAllowFrom(account.accountId)];
85
+ ingress = await resolveChannelMessageIngress({
86
+ channelId: NUDGE_CHANNEL_ID,
87
+ accountId: account.accountId,
88
+ identity: nudgeIngressIdentity,
89
+ subject: { stableId: ingressSubject },
90
+ conversation: { kind: event.chatType, id: event.chatId },
91
+ event: {
92
+ kind: "message",
93
+ authMode: "inbound",
94
+ mayPair: false, // allowlist 策略固定不触发 pairing
95
+ },
96
+ policy: {
97
+ dmPolicy: "allowlist",
98
+ groupPolicy: "allowlist",
99
+ },
100
+ allowFrom: dynamicAllowList,
101
+ groupAllowFrom: dynamicAllowList,
102
+ command: commandCheckRequested
103
+ ? {
104
+ // 只算授权不拦截:未授权的控制命令按普通文本继续进 agent
105
+ allowTextCommands: false,
106
+ hasControlCommand: isControlCommandMessage(event.text, cfg),
107
+ }
108
+ : undefined,
109
+ });
110
+ }
111
+ catch (err) {
112
+ logger?.error(`openclaw-nudge${tag}: 访问控制解析失败: ${String(err)}`);
113
+ return;
114
+ }
115
+ // 1a. 拒绝:静默丢弃
116
+ if (!ingress.senderAccess.allowed) {
117
+ logger?.info(`openclaw-nudge${tag}: 入站消息被拒绝` +
118
+ ` (chat=${event.chatType}, subject=${event.chatType === "group" ? event.chatId : event.from},` +
119
+ ` reason=${ingress.senderAccess.reasonCode})`);
120
+ return;
121
+ }
122
+ // -----------------------------------------------------------------------
123
+ // 2. 路由:决定 agentId + sessionKey
124
+ // -----------------------------------------------------------------------
125
+ const peerKind = event.chatType === "group" ? "group" : "direct";
126
+ // 路由 peer ID:群聊把 channel_id 与发送者编在一起,确保同一群里不同
127
+ // 用户各自独立会话(session key = agent:main:qingtui:group:<channel>:<sender>);
128
+ // 单聊直接用发送者 id。
129
+ const routePeerId = event.chatType === "group" ? `${event.chatId}:${event.from}` : event.from;
130
+ const route = runtime.channel.routing.resolveAgentRoute({
131
+ cfg,
132
+ channel: NUDGE_CHANNEL_ID,
133
+ accountId: account.accountId,
134
+ peer: { kind: peerKind, id: routePeerId },
135
+ // 宿主缺省 dmScope 为 "main",会把所有用户的单聊并入同一会话;
136
+ // 未显式配置 session.dmScope 时按账号+发送者隔离。
137
+ dmScope: cfg.session?.dmScope ?? "per-account-channel-peer",
138
+ });
139
+ // -----------------------------------------------------------------------
140
+ // 3. 组装入站上下文(prompt 字段)
141
+ // -----------------------------------------------------------------------
142
+ // 出站目标始终用群 id(encodeNudgeTarget 编码 channel_id),不受路由
143
+ // peer ID 影响:回复发回群里,不按用户拆分。
144
+ const toTarget = encodeNudgeTarget(event.chatType, event.chatId);
145
+ const ctxPayload = runtime.channel.inbound.buildContext({
146
+ channel: NUDGE_CHANNEL_ID,
147
+ accountId: account.accountId,
148
+ messageId: event.id,
149
+ timestamp: event.timestamp,
150
+ from: event.from,
151
+ sender: { id: event.from },
152
+ conversation: { kind: event.chatType, id: event.chatId },
153
+ route: {
154
+ agentId: route.agentId,
155
+ accountId: account.accountId,
156
+ routeSessionKey: route.sessionKey,
157
+ },
158
+ reply: { to: toTarget },
159
+ message: { rawBody: event.text },
160
+ access: {
161
+ // DM/群聊准入决定已由上方 resolveChannelMessageIngress 消化(被拒
162
+ // 消息提前丢弃),2026.8.1 起 access 只收命令授权等剩余字段
163
+ // CommandAuthorized 只对命令型消息生效;未请求命令门控时回退发送者
164
+ // 准入结果(走到这里必为 true),与 clickclack 语义一致
165
+ commands: {
166
+ authorized: ingress.commandAccess.requested
167
+ ? ingress.commandAccess.authorized
168
+ : ingress.senderAccess.allowed,
169
+ },
170
+ },
171
+ });
172
+ // storePath:会话持久化目录(按 agent + 配置 store 路径定位)
173
+ const storePath = runtime.channel.session.resolveStorePath(cfg.session?.store, {
174
+ agentId: route.agentId,
175
+ });
176
+ // 回复附件的本地读取根目录:入站 deliver 不经渠道 outbound.sendMedia,
177
+ // 宿主不会注入 mediaAccess,按路由 agent 取宿主授权根目录(对齐内置
178
+ // telegram 入站接线);远程 URL 仍受宿主加载器的 SSRF 策略约束。
179
+ const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, route.agentId);
180
+ // -----------------------------------------------------------------------
181
+ // 4. 投递:record + dispatch(agent 回复在这里发回 nudge)
182
+ // -----------------------------------------------------------------------
183
+ logger?.info(`openclaw-nudge${tag}: 派发入站消息` +
184
+ ` (from=${event.from}, agent=${route.agentId}, session=${route.sessionKey})`);
185
+ // 流式回复:仅单聊启用(群聊消息密度高、多用户并发,流式占位协议
186
+ // 容易冲突,直接走普通文本更稳);渠道未关闭 streaming 且事件携带
187
+ // account_id(轻推流式占位接口的 launchAccountId;launchMsgId 归一化
188
+ // 时已保证存在)时启用。任一条件不满足时 stream 为 null,走普通文本。
189
+ const stream = isNudgeStreamingEnabled(cfg) &&
190
+ event.chatType === "direct" &&
191
+ event.senderAccountId
192
+ ? new NudgeReplyStream({
193
+ ctx: {
194
+ accountId: account.accountId,
195
+ to: toTarget,
196
+ launchMsgId: event.msgId,
197
+ launchAccountId: event.senderAccountId,
198
+ },
199
+ api: nudgeStreamApi,
200
+ logger,
201
+ })
202
+ : null;
203
+ try {
204
+ await runtime.channel.inbound.dispatchReply({
205
+ cfg,
206
+ channel: NUDGE_CHANNEL_ID,
207
+ accountId: account.accountId,
208
+ agentId: route.agentId,
209
+ routeSessionKey: route.sessionKey,
210
+ storePath,
211
+ ctxPayload,
212
+ recordInboundSession: runtime.channel.session.recordInboundSession,
213
+ dispatchReplyWithBufferedBlockDispatcher: runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
214
+ delivery: {
215
+ deliver: async (payload, info) => {
216
+ // deliver 被 dispatchReply 以 fire-and-forget 方式调用,
217
+ // 内部抛出的错误会被静默吞掉;try/catch + logger.error
218
+ // 保证错误能进入 gateway 日志。
219
+ try {
220
+ const text = payload.text ?? "";
221
+ const mediaUrls = resolveOutboundMediaUrls(payload);
222
+ if (!text && mediaUrls.length === 0)
223
+ return;
224
+ // final 且已开流:补发剩余增量并 status=2 收尾,不再另发
225
+ // 普通文本;未开流/流式失败时回退普通文本。其他 kind
226
+ // (tool 等)仍按独立消息发送。
227
+ let textHandled = !text;
228
+ if (text && stream && (!info?.kind || info.kind === "final")) {
229
+ textHandled = await stream.finish(text);
230
+ }
231
+ // 附件逐个上传发送(不带文字,文字统一在后面单独发)。
232
+ // 单个附件失败不影响其余附件和文字,全部发完后再抛错记日志。
233
+ let mediaError;
234
+ for (const mediaUrl of mediaUrls) {
235
+ try {
236
+ await nudgeChatApi.sendFile({
237
+ accountId: account.accountId,
238
+ to: toTarget,
239
+ mediaUrl,
240
+ mediaAccess: { localRoots: mediaLocalRoots },
241
+ });
242
+ }
243
+ catch (err) {
244
+ mediaError ??= err;
245
+ logger?.error(`openclaw-nudge${tag}: 附件发送失败: ${String(err?.message ?? err)}`);
246
+ }
247
+ }
248
+ if (!textHandled) {
249
+ // 群聊回复时引用入站提问消息(event.msgId),
250
+ // 单聊无引用(sendMessage 内部已按 sendType 判断)。
251
+ await nudgeChatApi.sendMessage({
252
+ accountId: account.accountId,
253
+ to: toTarget,
254
+ text,
255
+ replyToId: event.chatType === "group" ? event.msgId : undefined,
256
+ });
257
+ }
258
+ if (mediaError)
259
+ throw mediaError;
260
+ }
261
+ catch (deliverErr) {
262
+ logger?.error(`openclaw-nudge${tag}: delivery.deliver 失败` +
263
+ ` (kind=${info?.kind ?? "?"}): ${String(deliverErr?.message ?? deliverErr)}`);
264
+ throw deliverErr;
265
+ }
266
+ },
267
+ // biome-ignore lint/suspicious/noExplicitAny: onError 签名来自 openclaw 内部
268
+ onError: (err, info) => {
269
+ logger?.error(`openclaw-nudge${tag}: delivery.onError` +
270
+ ` (kind=${info?.kind ?? "?"}): ${String(err?.message ?? err)}`);
271
+ },
272
+ },
273
+ replyPipeline: {},
274
+ replyOptions: {
275
+ // 块流式会把回复拆成多条独立 block 消息:单聊与流式占位冲突,
276
+ // 群聊要求一次发完整内容,因此始终关闭(对齐 qqbot 官方流式接线)
277
+ disableBlockStreaming: true,
278
+ ...(stream
279
+ ? {
280
+ onPartialReply: async (payload) => {
281
+ await stream.update(payload.text ?? "");
282
+ },
283
+ // 推理模型的正文要等回合结束才下发(宿主限制),先流式展示
284
+ // 思考过程;非 /reasoning stream 会话也需显式开启推理流
285
+ streamReasoningInNonStreamModes: true,
286
+ onReasoningStream: async (payload) => {
287
+ await stream.updateReasoning(payload.text ?? "");
288
+ },
289
+ }
290
+ : {}),
291
+ },
292
+ });
293
+ // 无 final 投递(如 NO_REPLY)时占位不能悬空:把节流残留的增量
294
+ // 补发完并静默收尾(未开流时是空操作)
295
+ await stream?.finish("");
296
+ }
297
+ catch (err) {
298
+ logger?.error(`openclaw-nudge${tag}: 入站回复派发失败: ${String(err)}`);
299
+ await stream?.abort();
300
+ }
301
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,115 @@
1
+ import { connectNudgeSocket, disconnectNudgeSocket, refreshDynamicAllowFrom, resetDynamicAllowFrom, } from "./client.js";
2
+ import { isNudgeChannelEnabled, listConfiguredNudgeAccounts, resolveNudgeOpenUrl, resolveNudgeServerUrl, } from "./config.js";
3
+ import { handleNudgeInboundMessage, normalizeNudgeEvent } from "./inbound.js";
4
+ const ALLOW_FROM_REFRESH_INTERVAL_MS = 5 * 60 * 1000;
5
+ /**
6
+ * 组装入站 service。getRuntime 取入口保存的 PluginRuntime 句柄
7
+ * (入站派发要用;setRuntime 先于 service start 执行)。
8
+ * 调用方:index.ts registerFull(仅 full 模式)。
9
+ */
10
+ export function createNudgeInboundService(getRuntime) {
11
+ // 白名单定时刷新句柄归属本 service 实例;stop 时清理
12
+ let allowFromTimer = null;
13
+ return {
14
+ id: "qingtui-inbound",
15
+ start: async (ctx) => {
16
+ if (!isNudgeChannelEnabled(ctx.config)) {
17
+ ctx.logger.info("qingtui: 渠道已禁用(enabled:false),跳过连接");
18
+ return;
19
+ }
20
+ const accounts = listConfiguredNudgeAccounts(ctx.config);
21
+ if (accounts.length === 0) {
22
+ ctx.logger.info("qingtui: 未配置 appid/appsecret,跳过连接");
23
+ return;
24
+ }
25
+ const serverUrl = resolveNudgeServerUrl(ctx.config);
26
+ const openUrl = resolveNudgeOpenUrl(ctx.config);
27
+ ctx.logger.info(`qingtui: 轻推服务地址 ${serverUrl}(REST ${openUrl}),账号 ${accounts.length} 个`);
28
+ // 白名单同步函数与定时器必须先于 connect 定义 —— onAuthenticated/
29
+ // onReconnected 闭包引用它们,且 SDK 在首次 connect 失败后的自动
30
+ // 重连成功时也会触发这两个回调(后置声明会落入 TDZ)。
31
+ // 首次同步成功前白名单为空 —— fail-closed,全部拒绝。
32
+ const syncAllowFrom = (accountId) => refreshDynamicAllowFrom(accountId)
33
+ .then((count) => {
34
+ if (count !== null)
35
+ ctx.logger.info(`qingtui[${accountId}]: 动态白名单已同步(${count} 条)`);
36
+ })
37
+ .catch((error) => {
38
+ ctx.logger.warn(`qingtui[${accountId}]: 动态白名单同步失败(保留旧缓存): ${String(error)}`);
39
+ });
40
+ const syncAndScheduleAllowFrom = (accountId) => {
41
+ void syncAllowFrom(accountId);
42
+ if (!allowFromTimer)
43
+ allowFromTimer = setInterval(() => {
44
+ for (const account of accounts)
45
+ void syncAllowFrom(account.accountId);
46
+ }, ALLOW_FROM_REFRESH_INTERVAL_MS);
47
+ };
48
+ // 多机器人:为每个已配置账号建一条独立连接(一个失败不影响其余)
49
+ for (const account of accounts) {
50
+ const { accountId } = account;
51
+ try {
52
+ await connectNudgeSocket(accountId, {
53
+ appid: account.appid,
54
+ appsecret: account.appsecret,
55
+ serverUrl,
56
+ openUrl,
57
+ }, {
58
+ onMessage: (message) => {
59
+ // 解析失败或非消息事件:忽略(SDK 已 autoAck)
60
+ const event = normalizeNudgeEvent(message);
61
+ if (!event) {
62
+ ctx.logger.debug?.(`qingtui[${accountId}]: 忽略非消息事件或残缺负载`);
63
+ return;
64
+ }
65
+ // 只记录元数据,不落消息正文
66
+ ctx.logger.debug?.(`qingtui[${accountId}]: 收到消息 id=${event.id} chat=${event.chatType} len=${event.text.length}`);
67
+ void handleNudgeInboundMessage({
68
+ event,
69
+ accountId,
70
+ runtime: getRuntime(),
71
+ logger: ctx.logger,
72
+ }).catch((error) => {
73
+ ctx.logger.error(`qingtui[${accountId}]: 入站消息处理失败: ${String(error)}`);
74
+ });
75
+ },
76
+ onAuthenticated: () => {
77
+ ctx.logger.info(`qingtui[${accountId}]: socket 认证成功`);
78
+ syncAndScheduleAllowFrom(accountId);
79
+ },
80
+ onDisconnected: (reason) => {
81
+ ctx.logger.warn(`qingtui[${accountId}]: socket 断开(${reason}),SDK 将自动重连`);
82
+ },
83
+ onReconnected: () => {
84
+ ctx.logger.info(`qingtui[${accountId}]: socket 重连成功`);
85
+ // 重连后白名单缓存可能已过期,立即刷新并确保定时器在
86
+ syncAndScheduleAllowFrom(accountId);
87
+ },
88
+ onError: (error) => {
89
+ ctx.logger.error(`qingtui[${accountId}]: socket 错误: ${error.message}`);
90
+ },
91
+ onHeartbeatTimeout: () => {
92
+ ctx.logger.warn(`qingtui[${accountId}]: socket 心跳超时`);
93
+ },
94
+ });
95
+ ctx.logger.info(`qingtui[${accountId}]: 已连接轻推消息代理`);
96
+ }
97
+ catch (error) {
98
+ // 首次连接失败:SDK 内部已安排自动重连(指数退避),
99
+ // 这里只记录日志,不让 gateway service 启动失败
100
+ ctx.logger.error(`qingtui[${accountId}]: 首次连接失败(SDK 将自动重试): ${String(error)}`);
101
+ }
102
+ }
103
+ },
104
+ stop: async () => {
105
+ // 主动断开全部账号连接并停止重连
106
+ disconnectNudgeSocket();
107
+ // 停止白名单刷新并清缓存,回到 fail-closed 初始态
108
+ if (allowFromTimer) {
109
+ clearInterval(allowFromTimer);
110
+ allowFromTimer = null;
111
+ }
112
+ resetDynamicAllowFrom();
113
+ },
114
+ };
115
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * 交互式配置向导:用户跑 `openclaw channels add` 时终端里的提问流程。
3
+ *
4
+ * 【新手须知】声明式向导 = 用数据描述"问什么、怎么校验、怎么写盘",
5
+ * 提问渲染由 OpenClaw CLI 完成。本渠道流程固定为:选账号(可 Add a new
6
+ * account 新建机器人)-> 录入 appid -> 录入 appsecret(掩码)-> 录入轻推
7
+ * 服务地址(渠道级,所有账号共用)-> 回渠道列表(可继续添加)->
8
+ * Finished 后 CLI 才统一写盘(中途取消全部丢弃)。
9
+ * 没有 DM 策略提问:本渠道私聊策略固定为 allowlist(见 src/channel.ts
10
+ * security 段),白名单只能来自轻推服务器同步,无配置键。
11
+ */
12
+ import { createStandardChannelSetupStatus, setSetupChannelEnabled, } from "openclaw/plugin-sdk/setup";
13
+ import { DEFAULT_NUDGE_SERVER_URL, isNudgeAccountConfigured, isNudgeChannelConfigured, NUDGE_CHANNEL_ID, normalizeNudgeUrl, patchNudgeAccountConfig, patchNudgeServerUrl, readConfiguredNudgeServerUrl, readNudgeAccountConfig, } from "./config.js";
14
+ // 多账号:resolveShouldPromptAccountIds 恒 true,向导在凭证录入前弹账号
15
+ // 选择(含 "Add a new account"),每轮配置可继续添加新机器人。
16
+ export const nudgeSetupWizard = {
17
+ channel: NUDGE_CHANNEL_ID,
18
+ // 支持同时接入多个机器人:弹账号选择(含 Add a new account)
19
+ resolveShouldPromptAccountIds: () => true,
20
+ status: createStandardChannelSetupStatus({
21
+ channelLabel: "Qingtui(轻推)",
22
+ configuredLabel: "已配置",
23
+ unconfiguredLabel: "未配置(需要机器人ID和Secret)",
24
+ configuredHint: "appid/appsecret 已配置",
25
+ unconfiguredHint: "需要 appid + appsecret",
26
+ configuredScore: 1,
27
+ unconfiguredScore: 0,
28
+ resolveConfigured: ({ cfg, accountId }) => accountId
29
+ ? isNudgeAccountConfigured(cfg, accountId)
30
+ : isNudgeChannelConfigured(cfg),
31
+ }),
32
+ // appsecret 走 textInputs 而非 credentials:credential 步骤会先弹
33
+ // plaintext / 外部 secret provider 模式选择,声明式向导不支持按渠道关闭。
34
+ // textInput 的 sensitive:true 提供掩码录入,且不回显已存值。
35
+ credentials: [],
36
+ textInputs: [
37
+ {
38
+ inputKey: "userId",
39
+ message: "轻推机器人 ID (appid)",
40
+ placeholder: "轻推平台分配的机器人ID",
41
+ currentValue: ({ cfg, accountId }) => {
42
+ const appid = readNudgeAccountConfig(cfg, accountId)?.appid;
43
+ return typeof appid === "string" && appid ? appid : undefined;
44
+ },
45
+ validate: ({ value }) => (value ? undefined : "必填"),
46
+ applySet: async ({ cfg, accountId, value }) => patchNudgeAccountConfig({ cfg, accountId, patch: { appid: value } }),
47
+ },
48
+ {
49
+ inputKey: "secret",
50
+ message: "输入轻推机器人密钥 (appsecret)",
51
+ placeholder: "轻推平台分配的机器人密钥",
52
+ // 掩码录入;重跑时只问是否保留,不展示已存密钥
53
+ sensitive: true,
54
+ keepPrompt: "已配置机器人密钥,是否保留?",
55
+ currentValue: ({ cfg, accountId }) => {
56
+ const appsecret = readNudgeAccountConfig(cfg, accountId)?.appsecret;
57
+ return typeof appsecret === "string" && appsecret
58
+ ? appsecret
59
+ : undefined;
60
+ },
61
+ validate: ({ value }) => (value ? undefined : "必填"),
62
+ applySet: async ({ cfg, accountId, value }) => patchNudgeAccountConfig({
63
+ cfg,
64
+ accountId,
65
+ patch: { appsecret: value },
66
+ }),
67
+ },
68
+ {
69
+ inputKey: "serverUrl",
70
+ message: "轻推服务地址",
71
+ placeholder: DEFAULT_NUDGE_SERVER_URL,
72
+ helpTitle: "轻推服务地址",
73
+ helpLines: [
74
+ `生产环境:${DEFAULT_NUDGE_SERVER_URL}`,
75
+ "QA 环境:https://qaqtopen.qingtui.com",
76
+ "渠道级配置,所有机器人账号共用;REST 地址未单独配置 openUrl 时同此地址",
77
+ ],
78
+ keepPrompt: (value) => `轻推服务地址为 ${value},是否保留?`,
79
+ currentValue: ({ cfg }) => readConfiguredNudgeServerUrl(cfg),
80
+ initialValue: () => DEFAULT_NUDGE_SERVER_URL,
81
+ validate: ({ value }) => normalizeNudgeUrl(value) ? undefined : "请输入 http(s) 开头的完整地址",
82
+ normalizeValue: ({ value }) => normalizeNudgeUrl(value) ?? value,
83
+ applySet: async ({ cfg, value }) => patchNudgeServerUrl(cfg, value),
84
+ },
85
+ ],
86
+ completionNote: {
87
+ title: "轻推机器人凭证已录入",
88
+ lines: [
89
+ "配置尚未保存:回到渠道列表选择 Finished,看到 Channels updated. 才算保存",
90
+ "重启 gateway 生效:openclaw gateway restart",
91
+ ],
92
+ },
93
+ disable: (cfg) => setSetupChannelEnabled(cfg, NUDGE_CHANNEL_ID, false),
94
+ };