@dcrays/dcgchat 0.2.34 → 0.3.19

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/cron.ts ADDED
@@ -0,0 +1,174 @@
1
+ import path from 'node:path'
2
+ import fs from 'node:fs'
3
+ import type { IMsgParams } from './types.js'
4
+ import { mergeDefaultParams, sendEventMessage, sendFinal } from './transport.js'
5
+ import { getCronMessageId, getWorkspaceDir, removeCronMessageId, setCronMessageId, setMsgStatus } 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 { getCurrentSessionKey, getEffectiveMsgParams } 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 }>
20
+ }
21
+
22
+ /**
23
+ * 在 `jobPath` 指向的 jobs.json(通常为 getCronJobsPath())中按 id 查找任务并返回其 sessionKey。
24
+ */
25
+ export function readCronJobSessionKey(jobPath: string, jobId: string): string | 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
+ const sk = job?.sessionKey?.trim()
37
+ return sk || null
38
+ } catch (e) {
39
+ dcgLogger(`readCronJobSessionKey: failed to read ${jobPath}: ${String(e)}`, 'error')
40
+ return null
41
+ }
42
+ }
43
+
44
+ function msgParamsToCtx(p: IMsgParams): IMsgParams | null {
45
+ if (!p?.botToken) return null
46
+ return p
47
+ }
48
+
49
+ const CRON_UPLOAD_DEBOUNCE_MS = 6600
50
+
51
+ /** 待合并的上传上下文(短时间内多次调用只保留最后一次) */
52
+ let pendingCronUploadCtx: IMsgParams | null = null
53
+ let cronUploadFlushTimer: ReturnType<typeof setTimeout> | null = null
54
+
55
+ async function runCronJobsUpload(msgCtx: IMsgParams): Promise<void> {
56
+ const jobPath = getCronJobsPath()
57
+ if (fs.existsSync(jobPath)) {
58
+ try {
59
+ const url = await ossUpload(jobPath, msgCtx.botToken ?? '', 0)
60
+ dcgLogger(`定时任务创建成功: ${url}`)
61
+ if (!msgCtx.sessionKey) {
62
+ dcgLogger(`runCronJobsUpload: missing sessionKey ${JSON.stringify(msgCtx)} on msgCtx`, 'error')
63
+ return
64
+ }
65
+ sendEventMessage(url, msgCtx.sessionKey)
66
+ } catch (error) {
67
+ dcgLogger(`${jobPath} upload failed: ${error}`, 'error')
68
+ }
69
+ } else {
70
+ dcgLogger(`${jobPath} not found`, 'error')
71
+ }
72
+ }
73
+
74
+ function flushCronUploadQueue(): void {
75
+ cronUploadFlushTimer = null
76
+ const ctx = pendingCronUploadCtx
77
+ pendingCronUploadCtx = null
78
+ if (!ctx) return
79
+ void runCronJobsUpload(ctx)
80
+ }
81
+
82
+ /**
83
+ * 将 jobs.json 同步到 OSS 并推送事件。30s 内多次调用合并为一次上传;定时触发后清空待处理项,避免重复执行。
84
+ * @param msgCtx 可选;省略时使用当前会话 getEffectiveMsgParams(sessionKey) 快照
85
+ */
86
+ export function sendDcgchatCron(): void {
87
+ const ctx = msgParamsToCtx(getEffectiveMsgParams(getCurrentSessionKey() ?? ''))
88
+ if (!ctx) {
89
+ dcgLogger('sendDcgchatCron: no message context (missing token / params)', 'error')
90
+ return
91
+ }
92
+ pendingCronUploadCtx = ctx
93
+ if (cronUploadFlushTimer !== null) {
94
+ clearTimeout(cronUploadFlushTimer)
95
+ }
96
+ cronUploadFlushTimer = setTimeout(flushCronUploadQueue, CRON_UPLOAD_DEBOUNCE_MS)
97
+ }
98
+
99
+ /**
100
+ * 通过 OpenClaw CLI 删除定时任务(走 Gateway,与内存状态一致)。
101
+ * 文档:运行中请勿手改 jobs.json,应使用 `openclaw cron rm` 或工具 API。
102
+ */
103
+ export const onRemoveCronJob = async (jobId: string) => {
104
+ const id = jobId?.trim()
105
+ if (!id) {
106
+ dcgLogger('onRemoveCronJob: empty jobId', 'error')
107
+ return
108
+ }
109
+ sendMessageToGateway(JSON.stringify({ method: 'cron.remove', params: { id: jobId } }))
110
+ }
111
+ export const onDisabledCronJob = async (jobId: string) => {
112
+ const id = jobId?.trim()
113
+ if (!id) {
114
+ dcgLogger('onRemoveCronJob: empty jobId', 'error')
115
+ return
116
+ }
117
+ sendMessageToGateway(JSON.stringify({ method: 'cron.update', params: { id: jobId, patch: { enabled: false } } }))
118
+ }
119
+ export const onEnabledCronJob = async (jobId: string) => {
120
+ const id = jobId?.trim()
121
+ if (!id) {
122
+ dcgLogger('onRemoveCronJob: empty jobId', 'error')
123
+ return
124
+ }
125
+ sendMessageToGateway(JSON.stringify({ method: 'cron.update', params: { id: jobId, patch: { enabled: true } } }))
126
+ }
127
+ export const onRunCronJob = async (jobId: string, messageId: string) => {
128
+ const id = jobId?.trim()
129
+ if (!id) {
130
+ dcgLogger('onRemoveCronJob: empty jobId', 'error')
131
+ return
132
+ }
133
+ const jobPath = getCronJobsPath()
134
+ const sessionKey = readCronJobSessionKey(jobPath, jobId) || ''
135
+ if (!sessionKey) {
136
+ dcgLogger(`finishedDcgchatCron: no sessionKey for job id=${id}`, 'error')
137
+ return
138
+ }
139
+ setCronMessageId(sessionKey, messageId)
140
+ sendMessageToGateway(JSON.stringify({ method: 'cron.run', params: { id: jobId } }))
141
+ }
142
+ export const finishedDcgchatCron = async (jobId: string) => {
143
+ const id = jobId?.trim()
144
+ if (!id) {
145
+ dcgLogger('finishedDcgchatCron: empty jobId', 'error')
146
+ return
147
+ }
148
+ const jobPath = getCronJobsPath()
149
+ const sessionKey = readCronJobSessionKey(jobPath, id)
150
+ if (!sessionKey) {
151
+ dcgLogger(`finishedDcgchatCron: no sessionKey for job id=${id}`, 'error')
152
+ return
153
+ }
154
+ const outboundCtx = getEffectiveMsgParams(sessionKey)
155
+ const messageId = getCronMessageId(sessionKey)
156
+ if (outboundCtx?.sessionId) {
157
+ const newCtx = messageId ? { ...outboundCtx, messageId } : outboundCtx
158
+ sendFinal(newCtx, 'cron send')
159
+ } else {
160
+ const sessionInfo = sessionKey.split(':')
161
+ const sessionId = sessionInfo.at(-1) ?? ''
162
+ const agentId = sessionInfo.at(-2) ?? ''
163
+ const merged = mergeDefaultParams({
164
+ agentId: agentId,
165
+ sessionId: `${sessionId}`,
166
+ messageId: messageId,
167
+ is_finish: -1,
168
+ real_mobook: !sessionId ? 1 : ''
169
+ })
170
+ sendFinal(merged, 'cron send')
171
+ }
172
+ removeCronMessageId(sessionKey)
173
+ dcgLogger(`finishedDcgchatCron: job=${id} sessionKey=${sessionKey}`)
174
+ }
@@ -0,0 +1,187 @@
1
+ import { channelInfo, ENV } from './utils/constant.js'
2
+ /**
3
+ * cron-delivery-guard — 定时任务投递守护插件
4
+ *
5
+ * 核心机制:通过 before_tool_call 钩子拦截 cron 工具调用,
6
+ * 当 delivery.mode 为 "announce" 且未指定 channel 时,
7
+ * 自动注入 bestEffort: true,使投递失败时静默降级,
8
+ * 不影响 cron 执行结果的保存。
9
+ *
10
+ * 背景:
11
+ * - 定时任务的 delivery 设为 announce 模式,如果没有指定 channel,
12
+ * 投递可能因找不到有效渠道而失败
13
+ * - bestEffort: true 让框架在投递失败时不报错,避免丢失执行结果
14
+ */
15
+
16
+ import { dcgLogger } from './utils/log.js'
17
+
18
+ const LOG_TAG = 'cron-delivery-guard'
19
+
20
+ // ---- 类型定义 ----
21
+
22
+ interface ToolCallEvent {
23
+ toolName: string
24
+ toolCallId: string
25
+ params: Record<string, unknown>
26
+ result?: { content: string }
27
+ }
28
+
29
+ interface HookContext {
30
+ agentId: string
31
+ sessionKey: string
32
+ }
33
+
34
+ interface BeforeToolCallResult {
35
+ block?: boolean
36
+ blockReason?: string
37
+ params?: Record<string, unknown>
38
+ }
39
+
40
+ // ---- delivery 类型 ----
41
+
42
+ interface CronDelivery {
43
+ mode?: string
44
+ channel?: string
45
+ to?: string
46
+ bestEffort?: boolean
47
+ [key: string]: unknown
48
+ }
49
+ /**
50
+ * 解析 OpenClaw mobook direct 会话 key。
51
+ * 形如 `agent:main:mobook:direct:14:5466`(大小写不敏感,与路由 toLowerCase 一致)
52
+ * - 倒数第二段:account / peer(delivery.accountId)
53
+ * - 最后一段:会话 id(delivery.to)
54
+ */
55
+ export function formatterSessionKey(sessionKey: string): { agentId: string; sessionId: string } {
56
+ const parts = sessionKey.split(':').filter((s) => s.length > 0)
57
+ const norm = parts.map((s) => s.toLowerCase())
58
+ if (parts.length >= 6 && norm[0] === 'agent' && norm[2] === 'mobook' && norm[3] === 'direct') {
59
+ return {
60
+ agentId: parts[4] ?? '',
61
+ sessionId: parts[5] ?? ''
62
+ }
63
+ }
64
+ if (parts.length >= 2) {
65
+ return {
66
+ agentId: parts[parts.length - 2] ?? '',
67
+ sessionId: parts[parts.length - 1] ?? ''
68
+ }
69
+ }
70
+ return { agentId: '', sessionId: '' }
71
+ }
72
+
73
+ // ---- 辅助函数 ----
74
+
75
+ /**
76
+ * 判断是否为 cron 工具调用
77
+ */
78
+ function isCronTool(toolName: string): boolean {
79
+ return toolName === 'cron'
80
+ }
81
+
82
+ /**
83
+ * 从 cron 参数中提取 delivery 配置
84
+ * cron 工具的参数结构可能是:
85
+ * - params.delivery (顶层)
86
+ * - params.job.delivery (嵌套在 job 中)
87
+ */
88
+ function extractDelivery(params: Record<string, unknown>): CronDelivery | null {
89
+ // 尝试顶层 delivery
90
+ if (params.delivery && typeof params.delivery === 'object') {
91
+ return params.delivery as CronDelivery
92
+ }
93
+
94
+ // 尝试 job.delivery
95
+ const job = params.job as Record<string, unknown> | undefined
96
+ if (job?.delivery && typeof job.delivery === 'object') {
97
+ return job.delivery as CronDelivery
98
+ }
99
+
100
+ // 尝试 payload 中的 deliver 相关字段 (兼容 qqbot-cron 风格)
101
+ const payload = job?.payload as Record<string, unknown> | undefined
102
+ if (payload?.deliver === true && payload.channel === undefined) {
103
+ // payload 风格: { deliver: true, channel?: string }
104
+ // 这种情况不是 delivery 对象,跳过
105
+ return null
106
+ }
107
+
108
+ return null
109
+ }
110
+
111
+ /**
112
+ * 判断 delivery 是否需要注入 bestEffort
113
+ * 条件: mode 为 "announce" 且没有 channel
114
+ */
115
+ function needsBestEffort(delivery: CronDelivery): boolean {
116
+ return delivery.mode === 'announce' && !delivery.channel && !delivery.bestEffort
117
+ }
118
+
119
+ /**
120
+ * 深拷贝 params 并注入 bestEffort: true
121
+ */
122
+ function injectBestEffort(params: Record<string, unknown>, sk: string): Record<string, unknown> {
123
+ const newParams = JSON.parse(JSON.stringify(params)) as Record<string, unknown>
124
+ const { agentId, sessionId } = formatterSessionKey(sk)
125
+ // 顶层 delivery
126
+ if (newParams.delivery && typeof newParams.delivery === 'object') {
127
+ ;(newParams.delivery as CronDelivery).bestEffort = true
128
+ ;(newParams.delivery as CronDelivery).to = `dcg-cron:${sk}`
129
+ ;(newParams.delivery as CronDelivery).accountId = agentId
130
+ ;(newParams.delivery as CronDelivery).channel = "dcgchat"
131
+ newParams.sessionKey = sk
132
+ return newParams
133
+ }
134
+
135
+ // job.delivery
136
+ const job = newParams.job as Record<string, unknown> | undefined
137
+ if (job?.delivery && typeof job.delivery === 'object') {
138
+ ;(job.delivery as CronDelivery).bestEffort = true
139
+ ;(newParams.delivery as CronDelivery).to = `dcg-cron:${sk}`
140
+ ;(newParams.delivery as CronDelivery).accountId = agentId
141
+ ;(newParams.delivery as CronDelivery).channel = "dcgchat"
142
+ newParams.sessionKey = sk
143
+ return newParams
144
+ }
145
+
146
+ return newParams
147
+ }
148
+
149
+ export function cronToolCall(event: { toolName: any; params: any; toolCallId: any }, sk: string) {
150
+ const { toolName, params, toolCallId } = event
151
+
152
+ // 仅处理 cron 工具
153
+ if (isCronTool(toolName)) {
154
+ const delivery = extractDelivery(params)
155
+ if (!delivery) {
156
+ dcgLogger(`[${LOG_TAG}] cron call (${toolCallId}) has no delivery config, skip.`)
157
+ return params
158
+ }
159
+ if (!needsBestEffort(delivery)) {
160
+ dcgLogger(
161
+ `[${LOG_TAG}] cron call (${toolCallId}) delivery does not need bestEffort ` +
162
+ `(mode=${String(delivery.mode)}, channel=${String(delivery.channel)}, bestEffort=${String(delivery.bestEffort)}), skip.`
163
+ )
164
+ return params
165
+ }
166
+
167
+ // ★ 核心:注入 bestEffort: true
168
+ const newParams = injectBestEffort(params, sk)
169
+ dcgLogger(
170
+ `[${LOG_TAG}] cron call (${toolCallId}) injected bestEffort=true ` +
171
+ `(mode=announce, no channel). delivery=${JSON.stringify(newParams.delivery || (newParams.job as Record<string, unknown>)?.delivery)}`
172
+ )
173
+
174
+ return { params: newParams }
175
+ } else if (toolName === 'exec') {
176
+ if (params.command.indexOf('cron create') > -1 || params.command.indexOf('cron add') > -1) {
177
+ const newParams = JSON.parse(JSON.stringify(params)) as Record<string, unknown>
178
+ newParams.command =
179
+ params.command.replace('--json', '') + ` --session-key ${sk} --channel ${"dcgchat"} --to dcg-cron:${sk} --json`
180
+ return { params: newParams }
181
+ } else {
182
+ return params
183
+ }
184
+ }
185
+
186
+ return params
187
+ }