@dcrays/dcgchat 0.4.29 → 0.5.1

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/src/channel.ts DELETED
@@ -1,470 +0,0 @@
1
- import fs from 'node:fs'
2
- import type { ChannelPlugin, OpenClawConfig, PluginRuntime } from 'openclaw/plugin-sdk'
3
- import { createPluginRuntimeStore, DEFAULT_ACCOUNT_ID } from 'openclaw/plugin-sdk'
4
- import type { ResolvedDcgchatAccount, DcgchatConfig } from './types.js'
5
- import { ossUpload } from './request/oss.js'
6
- import {
7
- addSentMediaKey,
8
- getCronMessageId,
9
- getDcgchatRuntime,
10
- getInfoBySessionKey,
11
- getOpenClawConfig,
12
- hasSentMediaKey,
13
- setCronMessageId
14
- } from './utils/global.js'
15
- import { isWsOpen, mergeDefaultParams, mergeSessionParams, sendFinal, wsSendRaw } from './transport.js'
16
- import { dcgLogger, setLogger } from './utils/log.js'
17
- import { getOutboundMsgParams, getParamsMessage } from './utils/params.js'
18
- import { isSessionActiveForTool } from './tool.js'
19
- import { startDcgchatGatewaySocket } from './gateway/socket.js'
20
- import { getCronJobsPath, readCronJob } from './cron.js'
21
-
22
- function dcgchatChannelCfg(): DcgchatConfig {
23
- return (getOpenClawConfig()?.channels?.["dcgchat"] as DcgchatConfig | undefined) ?? {}
24
- }
25
-
26
- /** `agent:<code>:mobook:direct:<agentId>:<sessionId>`(与 getSessionKey 非 real_mobook 分支一致) */
27
- function isMobookDirectSessionKey(s: string): boolean {
28
- const parts = s.split(':').filter((p) => p.length > 0)
29
- const low = parts.map((p) => p.toLowerCase())
30
- return parts.length >= 6 && low[0] === 'agent' && low[2] === 'mobook' && low[3] === 'direct'
31
- }
32
-
33
- /** real_mobook 等线路下 Core 分配的 `agent:<agentId>:…` sessionKey */
34
- function isAgentPrefixedSessionKey(s: string): boolean {
35
- const parts = s.split(':').filter((p) => p.length > 0)
36
- return parts.length >= 3 && parts[0].toLowerCase() === 'agent'
37
- }
38
-
39
- /**
40
- * 供 `messaging.targetResolver.looksLikeId` 使用:与 OpenClaw `resolveMessagingTarget` 对齐,
41
- * 仅当 target「像合法会话路由键」时才走 id 类解析;纯数字不会命中,从而在系统层拒绝误填 userId。
42
- */
43
- function looksLikeDcgchatMessageToolTarget(raw: string): boolean {
44
- let s = raw.trim()
45
- if (!s) return false
46
- const prefix = 'dcg-cron:'
47
- if (s.startsWith(prefix)) {
48
- s = s.slice(prefix.length).trim()
49
- if (!s) return false
50
- }
51
- if (isMobookDirectSessionKey(s)) return true
52
- if (isAgentPrefixedSessionKey(s)) return true
53
- return false
54
- }
55
-
56
- function dcgchatMessageTargetLooksLikeId(raw: string, _normalized?: string): boolean {
57
- if (dcgchatChannelCfg().strictMessageToolTarget === false) {
58
- return Boolean(raw?.trim())
59
- }
60
- return looksLikeDcgchatMessageToolTarget(raw)
61
- }
62
-
63
- export type DcgchatMediaSendOptions = {
64
- /** 与 setParamsMessage / map 一致,用于 getOutboundMsgParams */
65
- sessionKey: string
66
- mediaUrl?: string
67
- text?: string
68
- /** 定时任务等场景须与 `getCronMessageId` 一致,避免沿用 map 里上一条用户消息的 messageId */
69
- messageId?: string
70
- }
71
-
72
- function normalizeSessionTarget(rawTo: string): string {
73
- const cleaned = rawTo.replace('dcg-cron:', '').trim()
74
- if (!cleaned) return ''
75
- return getParamsMessage(cleaned)?.sessionKey?.trim() || cleaned
76
- }
77
-
78
- /**
79
- * OpenClaw 定时任务 `sessionTarget: isolated` 时,出站 `to` 常为 `agent:<code>:cron:<jobId>[:run:…]`,
80
- * 与 `paramsMessageMap` / `getCronMessageId` 使用的 jobs.json `sessionKey`(mobook 用户会话)不一致,导致发文件时 sessionId、messageId 错位或缺省。
81
- */
82
- function extractCronJobIdFromIsolatedSessionKey(sessionKey: string): string | null {
83
- const parts = sessionKey.split(':').filter((p) => p.length > 0)
84
- const i = parts.findIndex((p) => p.toLowerCase() === 'cron')
85
- if (i < 0 || i + 1 >= parts.length) return null
86
- return parts[i + 1] ?? null
87
- }
88
-
89
- function resolveIsolatedCronSessionToJobSessionKey(sessionKey: string): string {
90
- const jobId = extractCronJobIdFromIsolatedSessionKey(sessionKey)
91
- if (!jobId) return sessionKey
92
- const job = readCronJob(getCronJobsPath(), jobId)
93
- const sk = job && typeof job.sessionKey === 'string' ? job.sessionKey.trim() : ''
94
- if (!sk) {
95
- dcgLogger(`dcgchat: cron job ${jobId} has no sessionKey in jobs.json, keep outbound key=${sessionKey}`, 'error')
96
- return sessionKey
97
- }
98
- return sk
99
- }
100
-
101
- /** 出站返回的 chatId:含 `dcg-cron:` 时保留原始 `to`,便于下游识别定时投递 */
102
- function outboundChatId(rawTo: string | undefined, normalizedTo: string): string {
103
- const raw = rawTo?.trim() ?? ''
104
- return raw.indexOf('dcg-cron:') >= 0 ? raw : normalizedTo
105
- }
106
-
107
- /**
108
- * 仅从 JSON / `{ file | path | url }` 等结构里取出路径字符串,不做改写(不拼 workspace、不 normalize)。
109
- */
110
- function collectOutboundMediaPaths(item: unknown, out: string[]): void {
111
- if (item == null) return
112
- if (typeof item === 'string') {
113
- const t = item.trim()
114
- if (!t) return
115
- if (t.startsWith('[')) {
116
- try {
117
- const parsed = JSON.parse(t) as unknown
118
- collectOutboundMediaPaths(parsed, out)
119
- return
120
- } catch {
121
- /* 非 JSON,按普通路径处理 */
122
- }
123
- }
124
- out.push(t)
125
- return
126
- }
127
- if (Array.isArray(item)) {
128
- for (const el of item) collectOutboundMediaPaths(el, out)
129
- return
130
- }
131
- if (typeof item === 'object') {
132
- const o = item as Record<string, unknown>
133
- const raw = o.file ?? o.path ?? o.url
134
- if (typeof raw === 'string' && raw.trim()) {
135
- out.push(raw.trim())
136
- }
137
- }
138
- }
139
-
140
- /** 将出站 media 展平为路径字符串列表(去重保序;路径保持 Core 原样) */
141
- export function normalizeOutboundMediaPaths(raw: unknown): string[] {
142
- const acc: string[] = []
143
- collectOutboundMediaPaths(raw, acc)
144
- const seen = new Set<string>()
145
- const deduped: string[] = []
146
- for (const p of acc) {
147
- if (!p || seen.has(p)) continue
148
- seen.add(p)
149
- deduped.push(p)
150
- }
151
- return deduped
152
- }
153
-
154
- export async function sendDcgchatMedia(opts: DcgchatMediaSendOptions): Promise<void> {
155
- const rawOpt = (opts.sessionKey ?? '').trim()
156
- const strippedForCron = rawOpt.replace(/^dcg-cron:/i, '').trim()
157
- const fromIsolatedCron = extractCronJobIdFromIsolatedSessionKey(strippedForCron) !== null
158
- const fromDcgCronWrapper = rawOpt.toLowerCase().startsWith('dcg-cron:')
159
-
160
- let sessionKey = normalizeSessionTarget(opts.sessionKey ?? '')
161
- sessionKey = resolveIsolatedCronSessionToJobSessionKey(sessionKey)
162
-
163
- /** 定时自动执行未走 onRunCronJob,须与 finishedDcgchatCron 共用同一 messageId,否则附件与气泡错位 */
164
- if (!opts.messageId?.trim() && (fromIsolatedCron || fromDcgCronWrapper) && !getCronMessageId(sessionKey)) {
165
- setCronMessageId(sessionKey, `${Date.now()}`)
166
- }
167
-
168
- const cronMid = getCronMessageId(sessionKey)
169
- const baseCtx = getOutboundMsgParams(sessionKey)
170
- const msgCtx = opts.messageId?.trim()
171
- ? { ...baseCtx, messageId: opts.messageId.trim() }
172
- : cronMid
173
- ? { ...baseCtx, messageId: cronMid }
174
- : baseCtx
175
- if (!isWsOpen()) {
176
- dcgLogger(`outbound media skipped -> ws not isWsOpen failed open: ${opts.mediaUrl ?? ''}`)
177
- return
178
- }
179
-
180
- const expanded = normalizeOutboundMediaPaths(opts.mediaUrl)
181
- if (expanded.length === 0) {
182
- dcgLogger(
183
- `dcgchat: sendMedia skipped (no resolvable path): ${typeof opts.mediaUrl === 'string' ? opts.mediaUrl : JSON.stringify(opts.mediaUrl)} sessionKey=${sessionKey}`,
184
- 'error'
185
- )
186
- return
187
- }
188
- if (expanded.length > 1) {
189
- for (const single of expanded) {
190
- await sendDcgchatMedia({ ...opts, mediaUrl: single })
191
- }
192
- return
193
- }
194
- const mediaUrl = expanded[0]
195
-
196
- const { sessionId, agentId } = getInfoBySessionKey(sessionKey)
197
- if (!msgCtx.sessionId) {
198
- msgCtx.sessionId = sessionId
199
- }
200
- if (!msgCtx.agentId) {
201
- msgCtx.agentId = agentId
202
- }
203
- if (!mediaUrl || !msgCtx.sessionId) {
204
- dcgLogger(`dcgchat: sendMedia skipped (duplicate in session): ${mediaUrl} sessionId=${msgCtx.sessionId} sessionKey=${sessionKey}`)
205
- return
206
- }
207
- // 判断文件存在
208
- try {
209
- if (!fs.existsSync(mediaUrl)) {
210
- dcgLogger(`dcgchat: sendMedia skipped (file not found): ${mediaUrl} sessionKey=${sessionKey}`, 'error')
211
- return
212
- }
213
- } catch (err) {
214
- dcgLogger(`dcgchat: sendMedia skipped (cannot stat path): ${mediaUrl} ${String(err)} sessionKey=${sessionKey}`, 'error')
215
- }
216
-
217
- if (mediaUrl && msgCtx.sessionId) {
218
- if (hasSentMediaKey(msgCtx.sessionId, mediaUrl)) {
219
- dcgLogger(`dcgchat: sendMedia skipped (hasSentMediaKey): ${mediaUrl} sessionId=${msgCtx.sessionId} sessionKey=${sessionKey}`)
220
- return
221
- }
222
- addSentMediaKey(msgCtx.sessionId, mediaUrl)
223
- }
224
- const fileName = mediaUrl?.split(/[\\/]/).pop() || ''
225
- const notMessageId = `${msgCtx?.messageId}`?.length === 13 || !msgCtx?.messageId
226
- try {
227
- const botToken = msgCtx.botToken ?? getOpenClawConfig()?.channels?.["dcgchat"]?.botToken ?? ''
228
- const url = mediaUrl ? await ossUpload(mediaUrl, botToken, 1) : ''
229
- wsSendRaw(msgCtx, {
230
- response: opts.text ?? '',
231
- is_finish: notMessageId ? -1 : 0,
232
- message_tags: notMessageId ? { mark: 'empty_text_file' } : {},
233
- files: [{ url, name: fileName }]
234
- })
235
- dcgLogger(`dcgchat: sendMedia session=${sessionKey}, file=${fileName}`)
236
- } catch (error) {
237
- wsSendRaw(msgCtx, {
238
- response: opts.text ?? '',
239
- message_tags: notMessageId ? { mark: 'empty_text_file' } : {},
240
- is_finish: notMessageId ? -1 : 0,
241
- files: [{ url: opts.mediaUrl ?? '', name: fileName }]
242
- })
243
- dcgLogger(`dcgchat: error sendMedia session=${sessionKey}: ${String(error)}`, 'error')
244
- }
245
- }
246
-
247
- export function resolveAccount(cfg: OpenClawConfig, accountId?: string | null): ResolvedDcgchatAccount {
248
- const id = accountId ?? DEFAULT_ACCOUNT_ID
249
- const raw = (cfg.channels?.["dcgchat"] as DcgchatConfig | undefined) ?? {}
250
- return {
251
- accountId: id,
252
- enabled: raw.enabled !== false,
253
- configured: Boolean(raw.wsUrl),
254
- wsUrl: raw.wsUrl ?? '',
255
- botToken: raw.botToken ?? '',
256
- userId: raw.userId ?? '',
257
- domainId: raw.domainId ?? '',
258
- appId: raw.appId ?? ''
259
- }
260
- }
261
-
262
- export const dcgchatPlugin: ChannelPlugin<ResolvedDcgchatAccount> = {
263
- id: "dcgchat",
264
- meta: {
265
- id: "dcgchat",
266
- label: '书灵墨宝',
267
- selectionLabel: '书灵墨宝',
268
- docsPath: '/channels/dcgchat',
269
- docsLabel: "dcgchat",
270
- blurb: '连接 OpenClaw 与 书灵墨宝 产品',
271
- order: 80
272
- },
273
- capabilities: {
274
- chatTypes: ['direct'],
275
- polls: false,
276
- threads: true,
277
- media: true,
278
- nativeCommands: true,
279
- reactions: true,
280
- edit: false,
281
- reply: true,
282
- effects: true
283
- // blockStreaming: true,
284
- },
285
- /** 当前构建的 channel id + 兼容旧配置键 `channels.dcgchat` */
286
- reload: { configPrefixes: [`channels.${"dcgchat"}`, 'channels.dcgchat'] },
287
- configSchema: {
288
- schema: {
289
- type: 'object',
290
- additionalProperties: false,
291
- properties: {
292
- enabled: { type: 'boolean' },
293
- wsUrl: { type: 'string' },
294
- botToken: { type: 'string' },
295
- userId: { type: 'string', description: 'WebSocket 连接参数 _userId,与 dcgchat_message 工具的 target(dcgSessionKey)无关' },
296
- appId: { type: 'string' },
297
- domainId: { type: 'string' },
298
- capabilities: { type: 'array', items: { type: 'string' } },
299
- strictMessageToolTarget: {
300
- type: 'boolean',
301
- description:
302
- '默认 true:内置 message 工具的 target 须为 sessionKey 形态(如 agent:…:mobook:direct:… 或 agent: 前缀多段),禁止纯数字 WS userId。设为 false 关闭此校验。'
303
- }
304
- }
305
- },
306
- uiHints: {
307
- userId: {
308
- label: 'WS 连接 _userId',
309
- help: '仅用于拼接网关 WebSocket URL 的查询参数,不是 Agent 发消息时的 target。发消息请使用 dcgSessionKey(与入站上下文 SessionKey 相同)。'
310
- },
311
- strictMessageToolTarget: {
312
- label: '严格 message.target',
313
- help: '开启后由通道目标解析层拒绝纯数字等非 sessionKey 的 target(推荐开启);关闭则与旧版行为一致。'
314
- }
315
- }
316
- },
317
- config: {
318
- listAccountIds: () => [DEFAULT_ACCOUNT_ID],
319
- resolveAccount: (cfg, accountId) => resolveAccount(cfg, accountId),
320
- defaultAccountId: () => DEFAULT_ACCOUNT_ID,
321
- setAccountEnabled: ({ cfg, enabled }) => {
322
- const channelKey = "dcgchat"
323
- const prev = (cfg.channels?.[channelKey as keyof NonNullable<typeof cfg.channels>] as Record<string, unknown> | undefined) ?? {}
324
- return {
325
- ...cfg,
326
- channels: {
327
- ...cfg.channels,
328
- [channelKey]: { ...prev, enabled }
329
- }
330
- }
331
- },
332
- isConfigured: (account) => account.configured,
333
- describeAccount: (account) => ({
334
- accountId: account.accountId,
335
- enabled: account.enabled,
336
- configured: account.configured,
337
- wsUrl: account.wsUrl,
338
- botToken: account.botToken ? '***' : '',
339
- userId: account.userId,
340
- domainId: account.domainId,
341
- appId: account.appId
342
- })
343
- },
344
- messaging: {
345
- normalizeTarget: (raw) => raw || undefined,
346
- targetResolver: {
347
- looksLikeId: dcgchatMessageTargetLooksLikeId,
348
- hint: '须为完整 dcgSessionKey(与 SessionKey 一致,形如 agent:…:mobook:direct:… 或 agent: 前缀路由键);禁止填 WS userId 等纯数字。可在通道配置 strictMessageToolTarget=false 关闭校验。'
349
- }
350
- },
351
- /**
352
- * 与 Telegram 等通道一致:用入站路由键 `To`(即 SessionKey / OriginatingTo)作为 message 工具默认 `currentChannelId`。
353
- * 显式 target 由 `messaging.targetResolver.looksLikeId` + OpenClaw `resolveMessagingTarget` 校验(见 strictMessageToolTarget)。
354
- */
355
- threading: {
356
- buildToolContext: ({ context, hasRepliedRef }) => ({
357
- currentChannelId: context.To?.trim() || undefined,
358
- hasRepliedRef
359
- })
360
- },
361
- agentPrompt: {
362
- messageToolHints: () => [
363
- '书灵墨宝 / 内置 `message`:回复当前会话时 **不要传 `target`**,由 OpenClaw 使用工具上下文里的 `currentChannelId`(来自入站 `To`,即当前 SessionKey)。',
364
- '必须指定会话时:`target` 须为上下文中出现的 **整段 SessionKey,逐字一致**(如 `agent:…:mobook:direct:…` 或以 `agent:` 开头的路由键);**禁止**使用 `userId`、`From`、纯数字会话号等代替。',
365
- '生成文件后,**尽可能不要**把文件路径、地址直接告诉用户;把文件名告诉用户;须通过工具发文件,勿在正文里直接输出可访问路径。',
366
- '使用 `dcgchat_message` 时同样遵守上述 SessionKey 规则(该工具通常由插件注入当前会话,一般无需自造 target)。'
367
- ]
368
- },
369
- outbound: {
370
- deliveryMode: 'direct',
371
- resolveTarget: ({ to }) => {
372
- if (!to) {
373
- return { ok: false, error: new Error('target is empty') }
374
- }
375
- return { ok: true, to: to }
376
- },
377
- chunker: (text, limit) => getDcgchatRuntime().channel.text.chunkMarkdownText(text, limit),
378
- textChunkLimit: 4000,
379
- sendText: async (ctx) => {
380
- dcgLogger(`channel sendText to ${ctx.to} `)
381
- let messageId = ''
382
- const to = normalizeSessionTarget(ctx.to)
383
- if (isWsOpen()) {
384
- const isCron = ctx.to.indexOf('dcg-cron:') >= 0
385
- const outboundCtx = getOutboundMsgParams(to)
386
- const content: Record<string, unknown> = { response: ctx.text }
387
- if (!isCron) {
388
- if (outboundCtx?.sessionId) {
389
- // 入站 handler 已将本轮标为 running;若已 sendFinal(end) 则应为 finished。
390
- // 否则网关/Core 晚到的正文会仍用「当前 params map」里的 messageId,错挂到后一条用户消息上。
391
- if (!isSessionActiveForTool(to)) {
392
- dcgLogger(`channel sendText dropped (session not active): to=${to}`)
393
- return {
394
- channel: "dcgchat",
395
- messageId: '',
396
- chatId: outboundChatId(ctx.to, to)
397
- }
398
- }
399
- messageId = outboundCtx?.messageId || `${Date.now()}`
400
- const newCtx = { ...outboundCtx, messageId }
401
- wsSendRaw(newCtx, content)
402
- }
403
- }
404
- }
405
- return {
406
- channel: "dcgchat",
407
- messageId: `${messageId}`,
408
- chatId: outboundChatId(ctx.to, to)
409
- }
410
- },
411
- sendMedia: async (ctx) => {
412
- const normalizedFromTo = normalizeSessionTarget(ctx.to)
413
- const isCron = ctx.to.indexOf('dcg-cron:') >= 0 || extractCronJobIdFromIsolatedSessionKey(normalizedFromTo) !== null
414
- const to = resolveIsolatedCronSessionToJobSessionKey(normalizedFromTo)
415
- const outboundCtx = getOutboundMsgParams(to)
416
- const msgCtx = getParamsMessage(to) ?? outboundCtx
417
- if (isCron && !getCronMessageId(to)) {
418
- setCronMessageId(to, `${Date.now()}`)
419
- }
420
- const cronMsgId = getCronMessageId(to)
421
- const fallbackMessageId = `${Date.now()}`
422
- const messageId = cronMsgId || (isCron ? fallbackMessageId : msgCtx?.messageId || fallbackMessageId)
423
- const { sessionId } = getInfoBySessionKey(to)
424
- if (!sessionId) {
425
- dcgLogger(`channel sendMedia to ${ctx.to} -> sessionId not found`, 'error')
426
- return {
427
- channel: "dcgchat",
428
- messageId,
429
- chatId: outboundChatId(ctx.to, to || '')
430
- }
431
- }
432
-
433
- dcgLogger(`channel sendMedia to ${ctx.to}`)
434
-
435
- const ctxExt = ctx as { mediaUrls?: unknown; mediaUrl?: string }
436
- const rawMedia = ctxExt.mediaUrls ?? ctxExt.mediaUrl
437
- const paths = normalizeOutboundMediaPaths(rawMedia)
438
- for (const mediaUrl of paths) {
439
- await sendDcgchatMedia({
440
- sessionKey: to || '',
441
- mediaUrl,
442
- ...(isCron ? { messageId } : {})
443
- })
444
- }
445
- return {
446
- channel: "dcgchat",
447
- messageId,
448
- chatId: outboundChatId(ctx.to, to || '')
449
- }
450
- }
451
- },
452
- gateway: {
453
- startAccount: async (ctx) => {
454
- const { monitorDcgchatProvider } = await import('./monitor.js')
455
- const account = resolveAccount(ctx.cfg, ctx.accountId)
456
- setLogger(ctx.runtime)
457
- if (!account.wsUrl) {
458
- dcgLogger(`dcgchat[${account.accountId}]: wsUrl not configured, skipping`, 'error')
459
- return
460
- }
461
- startDcgchatGatewaySocket()
462
- return monitorDcgchatProvider({
463
- config: ctx.cfg,
464
- runtime: ctx.runtime,
465
- abortSignal: ctx.abortSignal,
466
- accountId: ctx.accountId
467
- })
468
- }
469
- }
470
- }
package/src/cron.ts DELETED
@@ -1,194 +0,0 @@
1
- import path from 'node:path'
2
- import fs from 'node:fs'
3
- import type { IMsgParams } from './types.js'
4
- import { isWsOpen, mergeDefaultParams, sendEventMessage, sendFinal, wsSendRaw } from './transport.js'
5
- import { getCronMessageId, getWorkspaceDir, getWsConnection, removeCronMessageId, setCronMessageId } from './utils/global.js'
6
- import { ossUpload } from './request/oss.js'
7
- import { dcgLogger } from './utils/log.js'
8
- import { sendMessageToGateway } from './gateway/socket.js'
9
- import { getEffectiveMsgParams, getParamsDefaults } from './utils/params.js'
10
-
11
- export function getCronJobsPath(): string {
12
- const workspaceDir = getWorkspaceDir()
13
- const cronDir = workspaceDir.replace('workspace', 'cron')
14
- return path.join(cronDir, 'jobs.json')
15
- }
16
-
17
- type CronJobsFile = {
18
- version?: number
19
- jobs?: Array<{ id?: string; sessionKey?: string; name?: string }>
20
- }
21
-
22
- /**
23
- * 在 `jobPath` 指向的 jobs.json(通常为 getCronJobsPath())中按 id 查找任务并返回其 sessionKey。
24
- */
25
- export function readCronJob(jobPath: string, jobId: string): Record<string, any> | null {
26
- const id = jobId?.trim()
27
- if (!id) return null
28
- if (!fs.existsSync(jobPath)) {
29
- dcgLogger(`readCronJobSessionKey: file not found ${jobPath}`, 'error')
30
- return null
31
- }
32
- try {
33
- const raw = fs.readFileSync(jobPath, 'utf8')
34
- const data = JSON.parse(raw) as CronJobsFile
35
- const job = (data.jobs ?? []).find((j) => j.id === id)
36
- return job || null
37
- } catch (e) {
38
- dcgLogger(`readCronJobSessionKey: failed to read ${jobPath}: ${String(e)}`, 'error')
39
- return null
40
- }
41
- }
42
-
43
- const CRON_UPLOAD_DEBOUNCE_MS = 2400
44
-
45
- let cronUploadFlushTimer: ReturnType<typeof setTimeout> | null = null
46
-
47
- async function runCronJobsUpload(sessionKey: string): Promise<void> {
48
- const jobPath = getCronJobsPath()
49
- const botToken = getParamsDefaults().botToken
50
- if (fs.existsSync(jobPath)) {
51
- try {
52
- const url = await ossUpload(jobPath, botToken ?? '', 0)
53
- dcgLogger(`定时任务更新成功: ${url}`)
54
- const sessionInfo = sessionKey?.split(':') || []
55
- const sessionId = sessionInfo.at(-1) ?? ''
56
- const agentId = sessionInfo.at(-2) ?? ''
57
- const params = {
58
- event_type: 'cron',
59
- operation_type: 'install',
60
- session_id: sessionId,
61
- agent_id: agentId,
62
- oss_url: url
63
- }
64
- sendEventMessage(params)
65
- } catch (error) {
66
- dcgLogger(`${jobPath} upload failed: ${error}`, 'error')
67
- }
68
- } else {
69
- dcgLogger(`${jobPath} not found`, 'error')
70
- }
71
- }
72
-
73
- /**
74
- * 将 jobs.json 同步到 OSS 并推送事件。30s 内多次调用合并为一次上传;定时触发后清空待处理项,避免重复执行。
75
- * @param msgCtx 可选;省略时使用当前会话 getEffectiveMsgParams(sessionKey) 快照
76
- */
77
- export function sendDcgchatCron(jobId: string): void {
78
- const jobPath = getCronJobsPath()
79
- const { sessionKey } = readCronJob(jobPath, jobId) || {}
80
- dcgLogger(`sessionKey: ${sessionKey}, jobId: ${jobId}`)
81
- if (cronUploadFlushTimer !== null) {
82
- clearTimeout(cronUploadFlushTimer)
83
- }
84
- cronUploadFlushTimer = setTimeout(() => {
85
- runCronJobsUpload(sessionKey)
86
- }, CRON_UPLOAD_DEBOUNCE_MS)
87
- }
88
-
89
- /**
90
- * 通过 OpenClaw CLI 删除定时任务(走 Gateway,与内存状态一致)。
91
- * 文档:运行中请勿手改 jobs.json,应使用 `openclaw cron rm` 或工具 API。
92
- */
93
- export const onRemoveCronJob = async (jobId: string) => {
94
- const id = jobId?.trim()
95
- if (!id) {
96
- dcgLogger('onRemoveCronJob: empty jobId', 'error')
97
- return
98
- }
99
- sendMessageToGateway(JSON.stringify({ method: 'cron.remove', params: { id: jobId } }))
100
- }
101
- export const onDisabledCronJob = async (jobId: string) => {
102
- const id = jobId?.trim()
103
- if (!id) {
104
- dcgLogger('onRemoveCronJob: empty jobId', 'error')
105
- return
106
- }
107
- sendMessageToGateway(JSON.stringify({ method: 'cron.update', params: { id: jobId, patch: { enabled: false } } }))
108
- }
109
- export const onEnabledCronJob = async (jobId: string) => {
110
- const id = jobId?.trim()
111
- if (!id) {
112
- dcgLogger('onRemoveCronJob: empty jobId', 'error')
113
- return
114
- }
115
- sendMessageToGateway(JSON.stringify({ method: 'cron.update', params: { id: jobId, patch: { enabled: true } } }))
116
- }
117
- export const onRunCronJob = async (jobId: string, messageId: string) => {
118
- const id = jobId?.trim()
119
- if (!id) {
120
- dcgLogger('onRemoveCronJob: empty jobId', 'error')
121
- return
122
- }
123
- const jobPath = getCronJobsPath()
124
- const { sessionKey } = readCronJob(jobPath, jobId) || {}
125
- if (!sessionKey) {
126
- dcgLogger(`finishedDcgchatCron: no sessionKey for job id=${id}`, 'error')
127
- return
128
- }
129
- setCronMessageId(sessionKey, messageId)
130
- sendMessageToGateway(
131
- JSON.stringify({
132
- method: 'cron.runs',
133
- params: { scope: 'job', id: jobId, limit: 50, offset: 0, status: 'all', sortDir: 'desc' }
134
- })
135
- )
136
- sendMessageToGateway(JSON.stringify({ method: 'cron.run', params: { id: jobId, mode: 'force' } }))
137
- }
138
- export const finishedDcgchatCron = async (jobId: string, summary: string, hasFileOutput?: boolean) => {
139
- const id = jobId?.trim()
140
- if (!id) {
141
- dcgLogger('finishedDcgchatCron: empty jobId', 'error')
142
- return
143
- }
144
- const jobPath = getCronJobsPath()
145
- const { sessionKey, name } = readCronJob(jobPath, id) || {}
146
- if (!sessionKey) {
147
- dcgLogger(`finishedDcgchatCron: no sessionKey for job id=${id}`, 'error')
148
- return
149
- }
150
- const messageId = getCronMessageId(sessionKey) || `${Date.now()}`
151
- const sessionInfo = sessionKey.split(':')
152
- const sessionId = sessionInfo.at(-1) ?? ''
153
- const agentId = sessionInfo.at(-2) ?? ''
154
- // if (outboundCtx?.sessionId) {
155
-
156
- const merged = mergeDefaultParams({
157
- agentId: agentId,
158
- sessionId: `${sessionId}`,
159
- messageId: messageId || `${Date.now()}`,
160
- real_mobook: !sessionId ? 1 : ''
161
- })
162
- const message_tags = { source: 'cron' } as Record<string, string | boolean>
163
- if (hasFileOutput) {
164
- message_tags.hasFile = true
165
- }
166
- wsSendRaw(merged, { response: summary, message_tags, is_finish: -1 })
167
- setTimeout(() => {
168
- sendFinal(merged, 'cron send')
169
- }, 200)
170
- // }
171
- const ws = getWsConnection()
172
- const baseContent = getParamsDefaults()
173
- if (isWsOpen()) {
174
- ws?.send(
175
- JSON.stringify({
176
- messageType: 'openclaw_bot_event',
177
- source: 'client',
178
- content: {
179
- event_type: 'notify',
180
- operation_type: 'cron',
181
- bot_token: baseContent.botToken,
182
- app_id: baseContent.appId,
183
- session_id: sessionId,
184
- agent_id: agentId,
185
- real_mobook: !sessionId ? 1 : '',
186
- title: name
187
- }
188
- })
189
- )
190
- }
191
- dcgLogger(`定时任务执行成功: ${id}`)
192
- removeCronMessageId(sessionKey)
193
- dcgLogger(`finishedDcgchatCron: job=${id} sessionKey=${sessionKey}`)
194
- }