mirror of
https://github.com/hansjone/dsh-ops-cron.git
synced 2026-10-08 22:00:46 +08:00
Inherit agent presets and auto-bind IM delivery targets (0.1.7).
Cron jobs created from WhatsApp can omit bot/target ids and reuse chat overrides; sidebar/agent preset pins persist on fire. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
2f08136adf
commit
2e690c30c8
15 changed files with 830 additions and 82 deletions
2
FORK.md
2
FORK.md
|
|
@ -25,7 +25,7 @@ dsh plugin --profile web add -w "D:\project\chatgpt\dsh-ops-cron"
|
|||
|
||||
## 相对上游的改动
|
||||
|
||||
1. 包名 / cordis id / HTTP 前缀 / 侧栏文案 → `dsh-ops-cron` /「运维定时」
|
||||
1. 包名 / cordis id / HTTP 前缀仍为 `dsh-ops-cron`;侧栏产品文案为「定时任务」
|
||||
2. Job 增加 `delivery: { kind: 'dsh'|'im', botId?, targetId? }`
|
||||
3. `cron_create` 支持 `delivery` / `im_bot_id` / `im_target_id`;未显式指定时,若当前会话能 `resolveSessionPeer` 且已有匹配投递目标 → 默认 IM
|
||||
4. 开火后若 `delivery.kind === 'im'`,把 assistant 摘要经 `ctx.dshIm.send` 投回
|
||||
|
|
|
|||
18
README.md
18
README.md
|
|
@ -1,8 +1,8 @@
|
|||
# dsh-ops-cron
|
||||
|
||||
Ops fork of [dsh-cron-tasks](https://github.com/Whale-Zhang/dsh-cron-tasks) for DeepSeek Harness.
|
||||
Scheduled-task plugin for DeepSeek Harness (fork of [dsh-cron-tasks](https://github.com/Whale-Zhang/dsh-cron-tasks)).
|
||||
|
||||
- Sidebar **运维定时** under New Session
|
||||
- Sidebar **定时任务** under New Session
|
||||
- Agent tools: `cron_create` / `cron_list` / `cron_pause` / `cron_resume` / `cron_delete`
|
||||
- Per-job delivery: **DSH** (new root session) or **IM** (`ctx.dshIm.send`)
|
||||
|
||||
|
|
@ -20,11 +20,21 @@ dsh plugin --profile web add -w "github:hansjone/dsh-ops-cron"
|
|||
|
||||
| Created from | Default delivery |
|
||||
|--------------|------------------|
|
||||
| WhatsApp / IM session (peer resolved + matching 投递目标) | `im` → that `botId`+`targetId` |
|
||||
| WhatsApp / IM session | `im` → reuse or **auto-create** a 投递目标 for that chat (group→group, DM→DM) |
|
||||
| Web / plain DSH session | `dsh` → sidebar history session |
|
||||
| Explicit `delivery` / `im_bot_id`+`im_target_id` | as specified |
|
||||
|
||||
Create IM targets first in IM → 投递设置 → 复制调用参数 when you need a stable target for WhatsApp.
|
||||
You usually do **not** need to paste botId/targetId when creating from WhatsApp — omit `delivery` and the job binds to the current chat.
|
||||
|
||||
## Agent preset
|
||||
|
||||
| Source | Behavior |
|
||||
|--------|----------|
|
||||
| Explicit `agent_preset` / sidebar field | pinned on the job |
|
||||
| WhatsApp / IM create (omit) | inherit chat/group override → bot preset → Host default |
|
||||
| Sidebar leave empty | Host default at each fire |
|
||||
|
||||
Scheduled runs mount `job.agentPreset` (or Host default when empty).
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
|||
216
lib/client.js
216
lib/client.js
|
|
@ -161,8 +161,13 @@ window.__ModuleLoader__.load({
|
|||
const inject = ['slots', 'locale', 'settingsScope']
|
||||
const API = '/dsh-ops-cron'
|
||||
const LOCALE_NS = 'settings.dshCronTasks'
|
||||
const TITLE_PREFIX = '运维定时 · '
|
||||
const listSnapshot = { jobs: [], runs: [], workspaces: [], catalog: { groups: [], current: null } }
|
||||
const TITLE_PREFIX = '定时任务 · '
|
||||
const listSnapshot = {
|
||||
jobs: [], runs: [], workspaces: [],
|
||||
catalog: { groups: [], current: null },
|
||||
presets: { items: [], current: null },
|
||||
imCatalog: { available: true, options: [], loading: false },
|
||||
}
|
||||
|
||||
function jobStamp(rows) {
|
||||
return (rows || []).map((row) => `${row.id}:${row.updatedAt}:${row.nextRunAt}:${row.enabled}:${row.lastStatus}:${row.model}:${row.provider}:${row.delivery?.kind}:${row.delivery?.targetId}`).join('|')
|
||||
|
|
@ -291,19 +296,19 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
|
|||
`
|
||||
|
||||
const zh = {
|
||||
title: '运维定时',
|
||||
title: '定时任务',
|
||||
description: '调度策略:启用、默认时区、历史保留、重叠与漏跑。',
|
||||
expand: '展开设置', collapse: '收起设置', save: '保存', saving: '保存中…', discard: '放弃修改',
|
||||
saveFailed: '本部署没有接受这些值,已保留供你修改。',
|
||||
enabled: '启用调度器', timezone: '默认时区', historyLimit: '历史保留条数',
|
||||
overlapPolicy: '重叠策略', misfirePolicy: '漏跑策略', policySkip: '跳过',
|
||||
hint: '任务在左侧「新会话」下方进入。运行结果会出现在历史行里,点击记录会打开和普通会话一样的对话页,可以继续聊。',
|
||||
entry: '运维定时', entryLabel: '打开运维定时',
|
||||
entry: '定时任务', entryLabel: '打开定时任务',
|
||||
backToWorkspace: '返回工作区', backLabel: '返回工作区',
|
||||
newJob: '新建任务', name: '名称', prompt: '提示词', kind: '日程',
|
||||
cron: 'Cron(循环)', at: '一次性时间', expr: 'Cron 表达式', atTime: '时间',
|
||||
create: '创建', pause: '暂停', resume: '恢复', runNow: '立即运行', remove: '删除',
|
||||
emptyJobs: '还没有运维定时。点右上角 + 新建。', next: '下次', last: '上次',
|
||||
emptyJobs: '还没有定时任务。点右上角 + 新建。', next: '下次', last: '上次',
|
||||
expandRuns: '展开记录', collapseRuns: '收起记录',
|
||||
search: '搜索', searchPlaceholder: '搜索任务', searchClear: '清除搜索', searchEmpty: '没有匹配的任务。',
|
||||
paused: '已暂停',
|
||||
|
|
@ -313,7 +318,10 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
|
|||
cwdHint: '运行会在这个目录对应的工作区里开新会话。留空则用最近工作区。',
|
||||
model: '模型',
|
||||
modelDefault: '每次运行用当时的新会话默认',
|
||||
modelHint: '运维定时会消耗这个模型的额度。指定后不会跟着聊天模型变。',
|
||||
modelHint: '定时任务会消耗这个模型的额度。指定后不会跟着聊天模型变。',
|
||||
agentPreset: 'Agent Preset',
|
||||
agentPresetDefault: '每次运行用当时的 Host 默认',
|
||||
agentPresetHint: '指定后每次触发都挂载该 Preset;留空则跟随 Host 默认(创建时若从 WhatsApp 继承会自动写入)。',
|
||||
editorLead: '保存后按日程在新会话里执行;若投递为 IM,结束后还会发摘要到 WhatsApp/IM。点击下方某次运行,右侧会打开那次对话,可以继续。',
|
||||
lastOutput: '上次输出', noOutput: '还没有输出。先立即运行一次。',
|
||||
delivery: '投递',
|
||||
|
|
@ -321,17 +329,22 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
|
|||
deliveryIm: 'WhatsApp / IM',
|
||||
imBotId: 'IM Bot ID',
|
||||
imTargetId: 'IM Target ID',
|
||||
deliveryHint: 'Web 默认 DSH;WhatsApp 会话里由 Agent 创建时可自动匹配投递目标。也可粘贴 IM「投递设置」里的 botId/targetId。',
|
||||
imTarget: '投递目标',
|
||||
imTargetNone: '(请先在 IM「投递设置」新建目标)',
|
||||
imTargetManual: '手动填写…',
|
||||
imCatalogLoading: '正在加载投递目标…',
|
||||
imCatalogUnavailable: '无法加载投递目标(需 dsh-im-ops ≥ops.24,且已配置投递目标)',
|
||||
deliveryHint: '选 WhatsApp/IM 后从下拉选择已保存的投递目标;也可手动填写。目标在 IM 机器人 → 投递设置里创建。',
|
||||
}
|
||||
const en = {
|
||||
title: 'Ops schedule',
|
||||
title: 'Scheduled tasks',
|
||||
description: 'Scheduler policy: enable, default time zone, history retention, overlap and misfire.',
|
||||
expand: 'Show settings', collapse: 'Hide settings', save: 'Save', saving: 'Saving…', discard: 'Discard',
|
||||
saveFailed: 'The deployment did not accept these values; they were left for you to correct.',
|
||||
enabled: 'Enable scheduler', timezone: 'Default time zone', historyLimit: 'History retention',
|
||||
overlapPolicy: 'Overlap policy', misfirePolicy: 'Misfire policy', policySkip: 'Skip',
|
||||
hint: 'Open from under New Session. Run output appears in history; click a run to continue in the normal chat view.',
|
||||
entry: 'Ops schedule', entryLabel: 'Open ops schedule',
|
||||
entry: 'Scheduled tasks', entryLabel: 'Open scheduled tasks',
|
||||
backToWorkspace: 'Back to workspace', backLabel: 'Back to workspace',
|
||||
newJob: 'New job', name: 'Name', prompt: 'Prompt', kind: 'Schedule',
|
||||
cron: 'Cron (recurring)', at: 'One-shot time', expr: 'Cron expression', atTime: 'Time',
|
||||
|
|
@ -347,6 +360,9 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
|
|||
model: 'Model',
|
||||
modelDefault: 'Use the New Session default at fire time',
|
||||
modelHint: 'Scheduled runs consume this model\'s quota. A pinned model will not follow the chat selector.',
|
||||
agentPreset: 'Agent preset',
|
||||
agentPresetDefault: 'Use the Host default at fire time',
|
||||
agentPresetHint: 'Pin a preset for every fire. Leave empty to follow the Host default (WhatsApp-created jobs inherit the chat preset automatically).',
|
||||
editorLead: 'Runs start a new session. IM delivery also sends a summary via dshIm after the turn. Click a run below to open that conversation.',
|
||||
lastOutput: 'Last output', noOutput: 'No output yet. Run it once.',
|
||||
delivery: 'Delivery',
|
||||
|
|
@ -354,7 +370,12 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
|
|||
deliveryIm: 'WhatsApp / IM',
|
||||
imBotId: 'IM Bot ID',
|
||||
imTargetId: 'IM Target ID',
|
||||
deliveryHint: 'Web defaults to DSH. WhatsApp sessions can auto-match a delivery target. Or paste botId/targetId from IM delivery settings.',
|
||||
imTarget: 'Delivery target',
|
||||
imTargetNone: '(Create a target in IM delivery settings first)',
|
||||
imTargetManual: 'Enter manually…',
|
||||
imCatalogLoading: 'Loading delivery targets…',
|
||||
imCatalogUnavailable: 'Cannot load targets (need dsh-im-ops ≥ops.24 with saved targets)',
|
||||
deliveryHint: 'Pick a saved IM delivery target from the list, or enter botId/targetId manually. Create targets under IM bot → Delivery settings.',
|
||||
}
|
||||
|
||||
function normalizePrefs(value) {
|
||||
|
|
@ -597,6 +618,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
|
|||
timezone: timezone || DEFAULTS.timezone, cwd: '', timeoutMinutes: 10, enabled: true,
|
||||
provider: current?.provider || '',
|
||||
model: current?.model || '',
|
||||
agentPreset: '',
|
||||
deliveryKind: 'dsh',
|
||||
imBotId: '',
|
||||
imTargetId: '',
|
||||
|
|
@ -616,6 +638,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
|
|||
enabled: job.enabled !== false,
|
||||
provider: job.provider || '',
|
||||
model: job.model || '',
|
||||
agentPreset: job.agentPreset || '',
|
||||
deliveryKind: job.delivery?.kind === 'im' ? 'im' : 'dsh',
|
||||
imBotId: job.delivery?.botId || '',
|
||||
imTargetId: job.delivery?.targetId || '',
|
||||
|
|
@ -858,7 +881,110 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
|
|||
)
|
||||
}
|
||||
|
||||
function JobEditor({ t, form, setForm, isNew, error, lastOutput, nextRunAt, lastRunAt, workspaces, catalog, onSave, onRun, onPause, onRemove }) {
|
||||
function PresetField({ t, form, setForm, presets }) {
|
||||
const items = Array.isArray(presets?.items) ? presets.items : []
|
||||
const current = presets?.current
|
||||
const selected = form.agentPreset || ''
|
||||
const known = items.some((row) => row.id === selected)
|
||||
const defaultLabel = current?.id
|
||||
? `${t('agentPresetDefault')}(${current.name || current.id})`
|
||||
: t('agentPresetDefault')
|
||||
return h('label', null, t('agentPreset'),
|
||||
h('select', {
|
||||
value: selected,
|
||||
onChange: (e) => setForm({ ...form, agentPreset: e.target.value }),
|
||||
},
|
||||
h('option', { value: '' }, defaultLabel),
|
||||
!known && selected
|
||||
? h('option', { value: selected }, selected)
|
||||
: null,
|
||||
...items.map((row) => h('option', {
|
||||
value: row.id,
|
||||
key: row.id,
|
||||
}, row.name || row.id)),
|
||||
),
|
||||
h('span', { className: 'dsh-ct-cwdHint' }, t('agentPresetHint')),
|
||||
)
|
||||
}
|
||||
|
||||
function imOptionKey(botId, targetId) {
|
||||
return `${botId || ''}::${targetId || ''}`
|
||||
}
|
||||
|
||||
function parseImOptionKey(value) {
|
||||
const raw = String(value || '')
|
||||
const at = raw.indexOf('::')
|
||||
if (at <= 0) return { botId: '', targetId: '' }
|
||||
return { botId: raw.slice(0, at), targetId: raw.slice(at + 2) }
|
||||
}
|
||||
|
||||
function ImDeliveryFields({ t, form, setForm, imCatalog }) {
|
||||
const options = Array.isArray(imCatalog?.options) ? imCatalog.options : []
|
||||
const loading = !!imCatalog?.loading
|
||||
const available = imCatalog?.available !== false
|
||||
const selectedKey = form.imBotId && form.imTargetId
|
||||
? imOptionKey(form.imBotId, form.imTargetId)
|
||||
: ''
|
||||
const matched = options.some((row) => imOptionKey(row.botId, row.targetId) === selectedKey)
|
||||
const [manual, setManual] = useState(() => !!(selectedKey && !matched))
|
||||
useEffect(() => {
|
||||
if (selectedKey && !matched && !loading) setManual(true)
|
||||
if (selectedKey && matched) setManual(false)
|
||||
}, [selectedKey, matched, loading])
|
||||
const selectValue = manual
|
||||
? '__manual__'
|
||||
: (matched ? selectedKey : '')
|
||||
const showManual = manual || (selectedKey && !matched && !loading)
|
||||
|
||||
return h(React.Fragment, null,
|
||||
h('label', null, t('imTarget'),
|
||||
h('select', {
|
||||
value: selectValue,
|
||||
disabled: loading,
|
||||
onChange: (e) => {
|
||||
const value = e.target.value
|
||||
if (value === '__manual__') {
|
||||
setManual(true)
|
||||
return
|
||||
}
|
||||
setManual(false)
|
||||
if (!value) {
|
||||
setForm({ ...form, imBotId: '', imTargetId: '' })
|
||||
return
|
||||
}
|
||||
const next = parseImOptionKey(value)
|
||||
setForm({ ...form, imBotId: next.botId, imTargetId: next.targetId })
|
||||
},
|
||||
},
|
||||
h('option', { value: '' }, loading ? t('imCatalogLoading') : (
|
||||
options.length === 0 ? t('imTargetNone') : `— ${t('imTarget')} —`
|
||||
)),
|
||||
...options.map((row) => h('option', {
|
||||
key: imOptionKey(row.botId, row.targetId),
|
||||
value: imOptionKey(row.botId, row.targetId),
|
||||
}, `${row.name || row.targetId} · ${row.channel || 'im'} · ${row.targetId}`)),
|
||||
h('option', { value: '__manual__' }, t('imTargetManual')),
|
||||
),
|
||||
),
|
||||
!available || (options.length === 0 && !loading)
|
||||
? h('span', { className: 'dsh-ct-cwdHint' }, imCatalog?.hint || t('imCatalogUnavailable'))
|
||||
: null,
|
||||
showManual
|
||||
? h('div', { className: 'dsh-ct-editorRow' },
|
||||
h('label', null, t('imBotId'), h('input', {
|
||||
value: form.imBotId || '',
|
||||
onChange: (e) => setForm({ ...form, imBotId: e.target.value }),
|
||||
})),
|
||||
h('label', null, t('imTargetId'), h('input', {
|
||||
value: form.imTargetId || '',
|
||||
onChange: (e) => setForm({ ...form, imTargetId: e.target.value }),
|
||||
})),
|
||||
)
|
||||
: null,
|
||||
)
|
||||
}
|
||||
|
||||
function JobEditor({ t, form, setForm, isNew, error, lastOutput, nextRunAt, lastRunAt, workspaces, catalog, presets, imCatalog, onSave, onRun, onPause, onRemove }) {
|
||||
return h('div', { className: 'dsh-ct-editor' },
|
||||
h('h1', null, isNew ? t('newJob') : (form.name || t('title'))),
|
||||
h('p', { className: 'dsh-ct-editorLead' }, t('editorLead')),
|
||||
|
|
@ -890,6 +1016,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
|
|||
),
|
||||
h(CwdField, { t, form, setForm, workspaces }),
|
||||
h(ModelField, { t, form, setForm, catalog }),
|
||||
h(PresetField, { t, form, setForm, presets }),
|
||||
h('label', null, t('delivery'),
|
||||
h('select', {
|
||||
value: form.deliveryKind || 'dsh',
|
||||
|
|
@ -900,16 +1027,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
|
|||
),
|
||||
),
|
||||
form.deliveryKind === 'im'
|
||||
? h('div', { className: 'dsh-ct-editorRow' },
|
||||
h('label', null, t('imBotId'), h('input', {
|
||||
value: form.imBotId || '',
|
||||
onChange: (e) => setForm({ ...form, imBotId: e.target.value }),
|
||||
})),
|
||||
h('label', null, t('imTargetId'), h('input', {
|
||||
value: form.imTargetId || '',
|
||||
onChange: (e) => setForm({ ...form, imTargetId: e.target.value }),
|
||||
})),
|
||||
)
|
||||
? h(ImDeliveryFields, { t, form, setForm, imCatalog })
|
||||
: null,
|
||||
h('span', { className: 'dsh-ct-cwdHint' }, t('deliveryHint')),
|
||||
h('div', { className: 'dsh-ct-editorActs' },
|
||||
|
|
@ -935,6 +1053,8 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
|
|||
const [cronMode, setCronMode] = useState(false)
|
||||
const [workspaces, setWorkspaces] = useState(() => listSnapshot.workspaces)
|
||||
const [catalog, setCatalog] = useState(() => listSnapshot.catalog)
|
||||
const [presets, setPresets] = useState(() => listSnapshot.presets || { items: [], current: null })
|
||||
const [imCatalog, setImCatalog] = useState(() => listSnapshot.imCatalog || { available: true, options: [], loading: false })
|
||||
const [paneHost, setPaneHost] = useState(() => (typeof document === 'undefined' ? null : document.body))
|
||||
const skipAutoSelect = useRef(false)
|
||||
|
||||
|
|
@ -957,18 +1077,44 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
|
|||
}
|
||||
}, [])
|
||||
|
||||
const loadImCatalog = useCallback(async () => {
|
||||
setImCatalog((prev) => ({ ...prev, loading: true }))
|
||||
try {
|
||||
const body = await api('/im-catalog')
|
||||
const next = {
|
||||
available: body.available !== false,
|
||||
options: Array.isArray(body.options) ? body.options : [],
|
||||
hint: body.hint || '',
|
||||
loading: false,
|
||||
}
|
||||
listSnapshot.imCatalog = next
|
||||
setImCatalog(next)
|
||||
} catch (err) {
|
||||
const next = {
|
||||
available: false,
|
||||
options: [],
|
||||
hint: err instanceof Error ? err.message : String(err),
|
||||
loading: false,
|
||||
}
|
||||
listSnapshot.imCatalog = next
|
||||
setImCatalog(next)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [jobBody, histBody, wsBody, modelBody] = await Promise.all([
|
||||
const [jobBody, histBody, wsBody, modelBody, presetBody] = await Promise.all([
|
||||
api('/jobs'),
|
||||
api('/history'),
|
||||
api('/workspaces').catch(() => ({ workspaces: [] })),
|
||||
api('/models').catch(() => ({ groups: [], current: null })),
|
||||
api('/presets').catch(() => ({ items: [], current: null })),
|
||||
])
|
||||
const nextJobs = jobBody.jobs || []
|
||||
const nextRuns = histBody.runs || []
|
||||
const nextWorkspaces = wsBody.workspaces || []
|
||||
const nextCatalog = { groups: modelBody.groups || [], current: modelBody.current || null }
|
||||
const nextPresets = { items: presetBody.items || [], current: presetBody.current || null }
|
||||
if (jobStamp(listSnapshot.jobs) !== jobStamp(nextJobs)) {
|
||||
listSnapshot.jobs = nextJobs
|
||||
setJobs(nextJobs)
|
||||
|
|
@ -979,8 +1125,10 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
|
|||
}
|
||||
listSnapshot.workspaces = nextWorkspaces
|
||||
listSnapshot.catalog = nextCatalog
|
||||
listSnapshot.presets = nextPresets
|
||||
setWorkspaces(nextWorkspaces)
|
||||
setCatalog(nextCatalog)
|
||||
setPresets(nextPresets)
|
||||
faces.cronSessionIds = new Set(nextRuns.map((run) => run.sessionId).filter(Boolean))
|
||||
setError('')
|
||||
} catch (err) {
|
||||
|
|
@ -1002,6 +1150,11 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
|
|||
}
|
||||
}, [load, cronMode])
|
||||
|
||||
useEffect(() => {
|
||||
if (!cronMode || form.deliveryKind !== 'im') return
|
||||
loadImCatalog()
|
||||
}, [cronMode, form.deliveryKind, loadImCatalog])
|
||||
|
||||
useEffect(() => {
|
||||
if (!cronMode) {
|
||||
skipAutoSelect.current = false
|
||||
|
|
@ -1038,9 +1191,12 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
|
|||
if ((!next.provider || !next.model) && catalog.current?.provider && catalog.current?.model) {
|
||||
next = { ...next, provider: catalog.current.provider, model: catalog.current.model }
|
||||
}
|
||||
if (!next.agentPreset && presets.current?.id) {
|
||||
next = { ...next, agentPreset: presets.current.id }
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [cronMode, selection.type, catalog.current])
|
||||
}, [cronMode, selection.type, catalog.current, presets.current])
|
||||
|
||||
function selectJob(jobId) {
|
||||
const job = jobs.find((row) => row.id === jobId)
|
||||
|
|
@ -1084,6 +1240,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
|
|||
name: form.name, prompt: form.prompt, schedule,
|
||||
cwd: form.cwd, timeoutMinutes: form.timeoutMinutes, enabled: form.enabled,
|
||||
provider: form.provider, model: form.model,
|
||||
agentPreset: form.agentPreset || '',
|
||||
delivery: form.deliveryKind === 'im'
|
||||
? { kind: 'im', botId: form.imBotId, targetId: form.imTargetId }
|
||||
: { kind: 'dsh' },
|
||||
|
|
@ -1132,6 +1289,8 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
|
|||
lastRunAt: selectedJob?.lastRunAt,
|
||||
workspaces,
|
||||
catalog,
|
||||
presets,
|
||||
imCatalog,
|
||||
onSave: save,
|
||||
onRun: () => selection.jobId && run(selection.jobId),
|
||||
onPause: () => {
|
||||
|
|
@ -1197,7 +1356,10 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
|
|||
|
||||
function isCronTitle(text) {
|
||||
const value = String(text || '').trim()
|
||||
return value.startsWith('运维定时') || value.startsWith(TITLE_PREFIX.trim()) || value.includes(TITLE_PREFIX)
|
||||
return value.startsWith('定时任务')
|
||||
|| value.startsWith('运维定时')
|
||||
|| value.startsWith(TITLE_PREFIX.trim())
|
||||
|| value.includes(TITLE_PREFIX)
|
||||
}
|
||||
|
||||
function workspaceTitleFromCronFork(title) {
|
||||
|
|
@ -1207,6 +1369,10 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
|
|||
const stripped = value.replace(/^运维定时(?:\s*·\s*|\s+)/, '').trim()
|
||||
return stripped || value
|
||||
}
|
||||
if (value.startsWith('定时任务')) {
|
||||
const stripped = value.replace(/^定时任务(?:\s*·\s*|\s+)/, '').trim()
|
||||
return stripped || value
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
|
|
@ -1219,7 +1385,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
|
|||
const parent = String(row.parentId || row.parentSessionId || '').trim()
|
||||
if (parent && ids.has(parent)) return true
|
||||
const title = String(row.title || row.displayTitle || '')
|
||||
const looksCron = title.startsWith(TITLE_PREFIX) || title.startsWith('运维定时')
|
||||
const looksCron = title.startsWith(TITLE_PREFIX) || title.startsWith('定时任务') || title.startsWith('运维定时')
|
||||
if (previousId && ids.has(String(previousId)) && looksCron) return true
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
256
lib/delivery.js
256
lib/delivery.js
|
|
@ -1,5 +1,6 @@
|
|||
/**
|
||||
* Job delivery: DSH sidebar session vs proactive IM (botId + targetId).
|
||||
* Group jobs may also pin mentionJid so delivery @s the creator.
|
||||
*/
|
||||
|
||||
export function normalizeDelivery(input = {}) {
|
||||
|
|
@ -13,14 +14,121 @@ export function normalizeDelivery(input = {}) {
|
|||
error.code = 'INVALID_DELIVERY'
|
||||
throw error
|
||||
}
|
||||
return { kind: 'im', botId, targetId }
|
||||
const mentionJid = typeof src.mentionJid === 'string' ? src.mentionJid.trim() : ''
|
||||
const mentionName = typeof src.mentionName === 'string' ? src.mentionName.trim().slice(0, 80) : ''
|
||||
const delivery = { kind: 'im', botId, targetId }
|
||||
if (mentionJid && /@(s\.whatsapp\.net|lid)$/i.test(mentionJid)) {
|
||||
delivery.mentionJid = mentionJid
|
||||
if (mentionName) delivery.mentionName = mentionName
|
||||
}
|
||||
return delivery
|
||||
}
|
||||
return { kind: 'dsh' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep creator @mention when the UI re-saves the same IM target without mention fields.
|
||||
* @param {object|null|undefined} previous
|
||||
* @param {object} next
|
||||
*/
|
||||
export function mergeDeliveryMention(previous, next) {
|
||||
const normalized = normalizeDelivery(next)
|
||||
if (normalized.kind !== 'im' || normalized.mentionJid) return normalized
|
||||
if (previous?.kind !== 'im') return normalized
|
||||
if (previous.botId !== normalized.botId || previous.targetId !== normalized.targetId) {
|
||||
return normalized
|
||||
}
|
||||
if (!previous.mentionJid) return normalized
|
||||
return normalizeDelivery({
|
||||
...normalized,
|
||||
mentionJid: previous.mentionJid,
|
||||
mentionName: previous.mentionName,
|
||||
})
|
||||
}
|
||||
|
||||
export function deliveryLine(delivery) {
|
||||
if (!delivery || delivery.kind !== 'im') return 'delivery=dsh'
|
||||
return `delivery=im botId=${delivery.botId} targetId=${delivery.targetId}`
|
||||
const mention = delivery.mentionJid ? ` mention=@${delivery.mentionName || delivery.mentionJid}` : ''
|
||||
return `delivery=im botId=${delivery.botId} targetId=${delivery.targetId}${mention}`
|
||||
}
|
||||
|
||||
function peerIsGroup(peer) {
|
||||
if (!peer || typeof peer !== 'object') return false
|
||||
if (peer.kind === 'group') return true
|
||||
const conversationId = String(peer.conversationId || '').trim()
|
||||
if (conversationId.endsWith('@g.us')) return true
|
||||
const key = String(peer.conversationKey || '').trim()
|
||||
return key.startsWith('group:')
|
||||
}
|
||||
|
||||
/**
|
||||
* Group creator identity for WhatsApp @mention on delivery.
|
||||
* Prefer phone JID so WhatsApp shows the contact nickname and notifies;
|
||||
* LID-only identities are a last resort (often render as opaque digits).
|
||||
* @param {object|null} peer
|
||||
* @returns {{ mentionJid: string, mentionName?: string } | null}
|
||||
*/
|
||||
export function mentionFromPeer(peer) {
|
||||
if (!peerIsGroup(peer)) return null
|
||||
const phone = typeof peer.phone === 'string' ? peer.phone.trim() : ''
|
||||
const senderId = typeof peer.senderId === 'string' ? peer.senderId.trim() : ''
|
||||
const phoneJid = phone ? `${phone}@s.whatsapp.net` : ''
|
||||
const senderIsPhone = /@s\.whatsapp\.net$/i.test(senderId)
|
||||
const senderIsLid = /@lid$/i.test(senderId)
|
||||
const mentionJid = phoneJid
|
||||
|| (senderIsPhone ? senderId : '')
|
||||
|| (senderIsLid ? senderId : '')
|
||||
if (!mentionJid) return null
|
||||
const pushName = typeof peer.pushName === 'string' ? peer.pushName.trim() : ''
|
||||
const mentionName = (pushName || phone || mentionJid.split('@')[0] || '').slice(0, 80)
|
||||
return mentionName
|
||||
? { mentionJid, mentionName }
|
||||
: { mentionJid }
|
||||
}
|
||||
|
||||
function withPeerMention(delivery, peer) {
|
||||
if (!delivery || delivery.kind !== 'im') return delivery
|
||||
const mention = mentionFromPeer(peer)
|
||||
if (!mention) return delivery
|
||||
return normalizeDelivery({ ...delivery, ...mention })
|
||||
}
|
||||
|
||||
/**
|
||||
* JID candidates for matching saved 投递目标.
|
||||
* Groups must NEVER include the sender phone — that would bind cron delivery to a DM.
|
||||
* @param {object|null} peer
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function peerJidCandidates(peer) {
|
||||
if (!peer || typeof peer !== 'object') return []
|
||||
const conversationId = String(peer.conversationId || '').trim()
|
||||
const key = String(peer.conversationKey || '').trim()
|
||||
if (peerIsGroup(peer)) {
|
||||
const groupFromKey = key.startsWith('group:')
|
||||
? key.slice('group:'.length).split(':user:')[0].trim()
|
||||
: ''
|
||||
return [
|
||||
peer.jid,
|
||||
peer.route?.jid,
|
||||
conversationId,
|
||||
groupFromKey,
|
||||
]
|
||||
.map((value) => String(value || '').trim().toLowerCase())
|
||||
.filter((value) => value.endsWith('@g.us'))
|
||||
}
|
||||
const jidFromKey = key.startsWith('direct:')
|
||||
? key.slice('direct:'.length).trim()
|
||||
: (key.includes(':') ? key.slice(key.indexOf(':') + 1).split(':user:')[0] : key)
|
||||
const phoneJid = peer.phone ? `${String(peer.phone).trim()}@s.whatsapp.net` : ''
|
||||
return [
|
||||
peer.jid,
|
||||
peer.route?.jid,
|
||||
conversationId,
|
||||
phoneJid,
|
||||
jidFromKey,
|
||||
]
|
||||
.map((value) => String(value || '').trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -30,20 +138,123 @@ export function deliveryLine(delivery) {
|
|||
*/
|
||||
export function matchTargetForPeer(targets, peer) {
|
||||
if (!peer || !Array.isArray(targets) || targets.length === 0) return null
|
||||
const key = String(peer.conversationKey || '').trim()
|
||||
const jidFromKey = key.includes(':') ? key.slice(key.indexOf(':') + 1) : key
|
||||
const peerJid = String(peer.jid || peer.route?.jid || jidFromKey || '').trim().toLowerCase()
|
||||
if (!peerJid) return null
|
||||
const candidates = peerJidCandidates(peer)
|
||||
if (candidates.length === 0) return null
|
||||
const wantGroup = peerIsGroup(peer)
|
||||
for (const target of targets) {
|
||||
const targetKind = typeof target?.kind === 'string' ? target.kind.trim() : ''
|
||||
if (wantGroup && targetKind === 'user') continue
|
||||
if (!wantGroup && targetKind === 'group') continue
|
||||
const route = target?.route && typeof target.route === 'object' ? target.route : {}
|
||||
const jid = String(route.jid || route.chatId || route.openId || route.userId || '').trim().toLowerCase()
|
||||
if (jid && (jid === peerJid || peerJid.endsWith(jid) || jid.endsWith(peerJid))) {
|
||||
if (!jid) continue
|
||||
if (wantGroup && !jid.endsWith('@g.us')) continue
|
||||
if (candidates.some((peerJid) => (
|
||||
jid === peerJid || peerJid.endsWith(jid) || jid.endsWith(peerJid)
|
||||
))) {
|
||||
return target
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function slugId(value, fallback) {
|
||||
const raw = String(value || '').trim().toLowerCase()
|
||||
const slug = raw.replace(/@.*/, '').replace(/[^a-z0-9_-]+/g, '').slice(0, 40)
|
||||
return slug || fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a WhatsApp (or JID-shaped) delivery target draft for the current peer.
|
||||
* Group chats always target the group JID; DMs prefer phone JID when known.
|
||||
* @param {object|null} peer
|
||||
* @returns {{ botId: string, target: object } | null}
|
||||
*/
|
||||
export function deliveryDraftFromPeer(peer) {
|
||||
if (!peer?.botId || !peer.conversationId) return null
|
||||
const conversationId = String(peer.conversationId).trim()
|
||||
const isGroup = peerIsGroup(peer)
|
||||
const routeJid = (!isGroup && peer.phone)
|
||||
? `${String(peer.phone).trim()}@s.whatsapp.net`
|
||||
: conversationId
|
||||
if (!routeJid) return null
|
||||
if (isGroup && !routeJid.endsWith('@g.us')) return null
|
||||
if (!isGroup && !/@(s\.whatsapp\.net|lid)$/i.test(routeJid)) {
|
||||
// Non-WhatsApp peers are not auto-materialized yet.
|
||||
return null
|
||||
}
|
||||
const name = String(peer.label || (isGroup ? 'WhatsApp 群聊' : 'WhatsApp 私聊')).trim().slice(0, 80)
|
||||
const targetId = isGroup
|
||||
? `auto-group-${slugId(routeJid, 'chat')}`
|
||||
: `auto-dm-${slugId(peer.phone || routeJid, 'user')}`
|
||||
return {
|
||||
botId: String(peer.botId).trim(),
|
||||
target: {
|
||||
targetId,
|
||||
name: name || targetId,
|
||||
kind: isGroup ? 'group' : 'user',
|
||||
route: { jid: routeJid },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reuse or create a delivery target for the WhatsApp/IM peer that owns this session.
|
||||
* @param {object|null} peer
|
||||
* @param {object} dshIm
|
||||
* @returns {Promise<{ kind: 'im', botId: string, targetId: string } | null>}
|
||||
*/
|
||||
export async function ensureImDeliveryForPeer(peer, dshIm) {
|
||||
if (!peer?.botId || !dshIm || typeof dshIm.listTargets !== 'function') return null
|
||||
let targets = []
|
||||
try {
|
||||
const listed = await dshIm.listTargets(peer.botId)
|
||||
targets = Array.isArray(listed) ? listed : []
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const match = matchTargetForPeer(targets, peer)
|
||||
if (match?.targetId) {
|
||||
return withPeerMention(
|
||||
normalizeDelivery({ kind: 'im', botId: peer.botId, targetId: String(match.targetId) }),
|
||||
peer,
|
||||
)
|
||||
}
|
||||
const draft = deliveryDraftFromPeer(peer)
|
||||
if (!draft || typeof dshIm.createTarget !== 'function') return null
|
||||
try {
|
||||
const created = await dshIm.createTarget(draft.botId, draft.target)
|
||||
const targetId = created?.targetId || draft.target.targetId
|
||||
return withPeerMention(
|
||||
normalizeDelivery({ kind: 'im', botId: draft.botId, targetId: String(targetId) }),
|
||||
peer,
|
||||
)
|
||||
} catch (error) {
|
||||
if (error?.code !== 'target-conflict') return null
|
||||
try {
|
||||
const again = await dshIm.listTargets(peer.botId)
|
||||
const rows = Array.isArray(again) ? again : []
|
||||
const byId = rows.find((row) => row?.targetId === draft.target.targetId)
|
||||
if (byId?.targetId) {
|
||||
return withPeerMention(
|
||||
normalizeDelivery({ kind: 'im', botId: peer.botId, targetId: String(byId.targetId) }),
|
||||
peer,
|
||||
)
|
||||
}
|
||||
const rematch = matchTargetForPeer(rows, peer)
|
||||
if (rematch?.targetId) {
|
||||
return withPeerMention(
|
||||
normalizeDelivery({ kind: 'im', botId: peer.botId, targetId: String(rematch.targetId) }),
|
||||
peer,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function callerSessionId(exec) {
|
||||
const session = exec?.agent?.session
|
||||
return String(
|
||||
|
|
@ -56,7 +267,8 @@ export function callerSessionId(exec) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Resolve delivery for cron_create: explicit args win; else IM peer → matching target; else dsh.
|
||||
* Resolve delivery for cron_create: explicit args win; else IM peer → matching
|
||||
* or auto-created 投递目标; else dsh.
|
||||
* @param {object} args
|
||||
* @param {object} exec
|
||||
* @param {{ dshIm?: object }} deps
|
||||
|
|
@ -78,17 +290,8 @@ export async function resolveCreateDelivery(args = {}, exec = {}, deps = {}) {
|
|||
if (sessionId && dshIm && typeof dshIm.resolveSessionPeer === 'function') {
|
||||
try {
|
||||
const peer = await dshIm.resolveSessionPeer(sessionId)
|
||||
if (peer?.botId && typeof dshIm.listTargets === 'function') {
|
||||
const targets = await dshIm.listTargets(peer.botId)
|
||||
const match = matchTargetForPeer(targets, peer)
|
||||
if (match?.targetId) {
|
||||
return normalizeDelivery({
|
||||
kind: 'im',
|
||||
botId: peer.botId,
|
||||
targetId: String(match.targetId),
|
||||
})
|
||||
}
|
||||
}
|
||||
const ensured = await ensureImDeliveryForPeer(peer, dshIm)
|
||||
if (ensured) return ensured
|
||||
} catch {
|
||||
// fall through to dsh
|
||||
}
|
||||
|
|
@ -100,6 +303,7 @@ const IM_MAX_CHARS = 3500
|
|||
|
||||
/**
|
||||
* Send run summary to IM when job.delivery.kind === 'im'.
|
||||
* Group deliveries @mention the creator when mentionJid was captured at create time.
|
||||
*/
|
||||
export async function deliverRunToIm(job, summary, deps = {}) {
|
||||
const delivery = job?.delivery
|
||||
|
|
@ -112,7 +316,15 @@ export async function deliverRunToIm(job, summary, deps = {}) {
|
|||
}
|
||||
const text = String(summary || '').trim() || `(定时任务「${job.name}」已完成,无文本摘要)`
|
||||
const clipped = text.length > IM_MAX_CHARS ? `${text.slice(0, IM_MAX_CHARS)}…` : text
|
||||
const header = `【运维定时 · ${job.name}】\n`
|
||||
await dshIm.send(delivery.botId, delivery.targetId, `${header}${clipped}`)
|
||||
return { sent: true }
|
||||
const header = `【定时任务 · ${job.name}】\n`
|
||||
const mentionJid = typeof delivery.mentionJid === 'string' ? delivery.mentionJid.trim() : ''
|
||||
let body = `${header}${clipped}`
|
||||
const options = {}
|
||||
if (mentionJid && /@(s\.whatsapp\.net|lid)$/i.test(mentionJid)) {
|
||||
const token = mentionJid.slice(0, mentionJid.indexOf('@'))
|
||||
body = `@${token}\n${header}${clipped}`
|
||||
options.mentions = [mentionJid]
|
||||
}
|
||||
await dshIm.send(delivery.botId, delivery.targetId, body, options)
|
||||
return { sent: true, mentioned: Boolean(options.mentions) }
|
||||
}
|
||||
|
|
|
|||
11
lib/fire.js
11
lib/fire.js
|
|
@ -23,7 +23,7 @@ export function createRunRecord(job, decision, now, trigger) {
|
|||
}
|
||||
}
|
||||
|
||||
export const TITLE_PREFIX = '运维定时 · '
|
||||
export const TITLE_PREFIX = '定时任务 · '
|
||||
|
||||
export function extractAssistantText(messages, maxChars = 4000) {
|
||||
if (!Array.isArray(messages)) return ''
|
||||
|
|
@ -55,8 +55,15 @@ export function publicJob(job) {
|
|||
provider: job.provider || '',
|
||||
model: job.model || '',
|
||||
reasoningEffort: job.reasoningEffort || '',
|
||||
agentPreset: job.agentPreset || '',
|
||||
delivery: job.delivery && job.delivery.kind === 'im'
|
||||
? { kind: 'im', botId: job.delivery.botId, targetId: job.delivery.targetId }
|
||||
? {
|
||||
kind: 'im',
|
||||
botId: job.delivery.botId,
|
||||
targetId: job.delivery.targetId,
|
||||
...job.delivery.mentionJid ? { mentionJid: job.delivery.mentionJid } : {},
|
||||
...job.delivery.mentionName ? { mentionName: job.delivery.mentionName } : {},
|
||||
}
|
||||
: { kind: 'dsh' },
|
||||
schedule: job.schedule,
|
||||
createdAt: job.createdAt,
|
||||
|
|
|
|||
85
lib/host.js
85
lib/host.js
|
|
@ -8,7 +8,7 @@ import { mkdir } from 'node:fs/promises'
|
|||
import { homedir } from 'node:os'
|
||||
import { basename, join } from 'node:path'
|
||||
import { claimOccurrence, executeClaimedRun, extractAssistantText, interruptActiveRuns, publicJob, settleRun, TITLE_PREFIX } from './fire.js'
|
||||
import { deliverRunToIm } from './delivery.js'
|
||||
import { deliverRunToIm, mergeDeliveryMention } from './delivery.js'
|
||||
import { workspaceVisibleIds } from './isolation.js'
|
||||
import { decideDispatch, nextFire, validateSchedule } from './scheduler.js'
|
||||
import {
|
||||
|
|
@ -174,7 +174,10 @@ export function createHostService(options = {}) {
|
|||
provider: patch.provider !== undefined ? patch.provider : job.provider,
|
||||
model: patch.model !== undefined ? patch.model : job.model,
|
||||
reasoningEffort: patch.reasoningEffort !== undefined ? patch.reasoningEffort : job.reasoningEffort,
|
||||
delivery: patch.delivery !== undefined ? patch.delivery : job.delivery,
|
||||
agentPreset: patch.agentPreset !== undefined ? patch.agentPreset : job.agentPreset,
|
||||
delivery: patch.delivery !== undefined
|
||||
? mergeDeliveryMention(job.delivery, patch.delivery)
|
||||
: job.delivery,
|
||||
}
|
||||
const record = createJobRecord(nextInput, current, t)
|
||||
record.createdAt = job.createdAt
|
||||
|
|
@ -375,6 +378,14 @@ export function createHostService(options = {}) {
|
|||
return
|
||||
}
|
||||
|
||||
if (path === `${API_PREFIX}/presets` && method === 'GET') {
|
||||
const catalog = typeof sessionPort?.listPresets === 'function'
|
||||
? await sessionPort.listPresets()
|
||||
: { items: [], current: null }
|
||||
write(200, { ok: true, ...catalog })
|
||||
return
|
||||
}
|
||||
|
||||
if (path === `${API_PREFIX}/workspaces` && method === 'GET') {
|
||||
const workspaces = typeof sessionPort?.listWorkspaces === 'function'
|
||||
? await sessionPort.listWorkspaces()
|
||||
|
|
@ -383,6 +394,35 @@ export function createHostService(options = {}) {
|
|||
return
|
||||
}
|
||||
|
||||
if (path === `${API_PREFIX}/im-catalog` && method === 'GET') {
|
||||
const dshIm = getDshIm()
|
||||
if (!dshIm || typeof dshIm.listDeliveryCatalog !== 'function') {
|
||||
write(200, {
|
||||
ok: true,
|
||||
available: false,
|
||||
options: [],
|
||||
hint: 'dsh-im-ops missing or outdated — install ≥ops.24 for delivery picker',
|
||||
})
|
||||
return
|
||||
}
|
||||
try {
|
||||
const options = await dshIm.listDeliveryCatalog()
|
||||
write(200, {
|
||||
ok: true,
|
||||
available: true,
|
||||
options: Array.isArray(options) ? options : [],
|
||||
})
|
||||
} catch (error) {
|
||||
write(200, {
|
||||
ok: true,
|
||||
available: false,
|
||||
options: [],
|
||||
hint: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (path === `${API_PREFIX}/jobs` && method === 'GET') {
|
||||
const state = await snapshot()
|
||||
write(200, { ok: true, jobs: listJobs(state).map(jobView) })
|
||||
|
|
@ -766,6 +806,35 @@ export async function listModelChoices(ctx) {
|
|||
return { groups, current: currentDefaultModel(ctx) }
|
||||
}
|
||||
|
||||
export async function listPresetChoices(ctx) {
|
||||
const presets = tryGet(ctx, 'agentPresets')
|
||||
if (!presets || typeof presets.list !== 'function') {
|
||||
return { items: [], current: null }
|
||||
}
|
||||
let items = []
|
||||
try {
|
||||
const listed = await presets.list()
|
||||
const rows = Array.isArray(listed) ? listed : (Array.isArray(listed?.items) ? listed.items : [])
|
||||
items = rows.map((row) => ({
|
||||
id: String(row?.id || '').trim(),
|
||||
name: String(row?.name || row?.displayName || row?.id || '').trim(),
|
||||
})).filter((row) => row.id)
|
||||
} catch {
|
||||
items = []
|
||||
}
|
||||
let current = null
|
||||
if (typeof presets.resolve === 'function') {
|
||||
try {
|
||||
const resolved = await presets.resolve()
|
||||
const id = typeof resolved?.id === 'string' ? resolved.id.trim() : ''
|
||||
if (id) current = { id, name: String(resolved?.name || id) }
|
||||
} catch {
|
||||
current = null
|
||||
}
|
||||
}
|
||||
return { items, current }
|
||||
}
|
||||
|
||||
export async function resolveJobModel(ctx, job) {
|
||||
const provider = typeof job?.provider === 'string' ? job.provider.trim() : ''
|
||||
const model = typeof job?.model === 'string' ? job.model.trim() : ''
|
||||
|
|
@ -838,7 +907,7 @@ export function bindModelSelection(agentCtx, selection) {
|
|||
})
|
||||
}
|
||||
|
||||
async function composeCronAgent(ctx, selection) {
|
||||
async function composeCronAgent(ctx, selection, job) {
|
||||
const presets = tryGet(ctx, 'agentPresets')
|
||||
if (!presets || typeof presets.resolve !== 'function' || typeof presets.mount !== 'function') {
|
||||
return {
|
||||
|
|
@ -847,7 +916,10 @@ async function composeCronAgent(ctx, selection) {
|
|||
},
|
||||
}
|
||||
}
|
||||
const resolved = await presets.resolve()
|
||||
const wanted = typeof job?.agentPreset === 'string' ? job.agentPreset.trim() : ''
|
||||
const resolved = wanted
|
||||
? await presets.resolve(wanted)
|
||||
: await presets.resolve()
|
||||
const presetId = resolved?.id
|
||||
return {
|
||||
agentPreset: presetId,
|
||||
|
|
@ -871,7 +943,7 @@ export function makeLiveSessionPort(ctx) {
|
|||
const cwd = placement.cwd || defaultCwd()
|
||||
await mkdir(cwd, { recursive: true })
|
||||
const selection = await resolveJobModel(ctx, job)
|
||||
const composition = await composeCronAgent(ctx, selection)
|
||||
const composition = await composeCronAgent(ctx, selection, job)
|
||||
const handle = await agents.create({
|
||||
sessionId,
|
||||
agentOptions: {
|
||||
|
|
@ -935,6 +1007,9 @@ export function makeLiveSessionPort(ctx) {
|
|||
async listModels() {
|
||||
return listModelChoices(ctx)
|
||||
},
|
||||
async listPresets() {
|
||||
return listPresetChoices(ctx)
|
||||
},
|
||||
async waitForTurn(sessionId) {
|
||||
const entry = handles.get(sessionId)
|
||||
const agents = tryGet(ctx, 'agents')
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
* dsh-ops-cron, node half.
|
||||
*
|
||||
* Durable scheduled jobs: create a fresh root Session on fire, hide that
|
||||
* session from the workspace list, and keep history under 运维定时.
|
||||
* session from the workspace list, and keep history under 定时任务.
|
||||
* Jobs may deliver summaries to IM via soft-injected ctx.dshIm.
|
||||
*
|
||||
* Do not list @deepseek-ai/* in package.json — resolve schemastery from the
|
||||
|
|
@ -131,6 +131,7 @@ function registerWebRoute(ctx, handler) {
|
|||
export function apply(ctx, config = {}) {
|
||||
const entry = resolveConfig(config)
|
||||
const getDshIm = () => tryGet(ctx, 'dshIm')
|
||||
const getAgentPresets = () => tryGet(ctx, 'agentPresets')
|
||||
const service = createHostService({
|
||||
sessionPort: makeLiveSessionPort(ctx),
|
||||
getDshIm,
|
||||
|
|
@ -143,7 +144,7 @@ export function apply(ctx, config = {}) {
|
|||
})
|
||||
|
||||
ctx.inject(['tools'], (tctx) => {
|
||||
registerCronTools(tctx, service, { getDshIm })
|
||||
registerCronTools(tctx, service, { getDshIm, getAgentPresets })
|
||||
tctx.logger?.info?.('[dsh-ops-cron] tools cron_create/list/pause/resume/delete registered')
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
*/
|
||||
|
||||
export const HIDDEN_REASON = 'cron-run'
|
||||
export const TITLE_PREFIX = '运维定时 · '
|
||||
export const TITLE_PREFIX = '定时任务 · '
|
||||
|
||||
/**
|
||||
* Strip the scheduler title prefix so a forked child can sit in the
|
||||
|
|
|
|||
79
lib/preset.js
Normal file
79
lib/preset.js
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
/**
|
||||
* Resolve which Agent Preset a cron job should mount.
|
||||
* Priority: explicit args → IM session peer → creating session meta → Host default.
|
||||
*/
|
||||
|
||||
import { callerSessionId } from './delivery.js'
|
||||
|
||||
const PRESET_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @returns {string}
|
||||
*/
|
||||
export function normalizeAgentPresetId(value) {
|
||||
if (typeof value !== 'string') return ''
|
||||
const id = value.trim()
|
||||
if (!id) return ''
|
||||
if (!PRESET_ID.test(id)) {
|
||||
const error = new Error('agentPreset must be a short alphanumeric id')
|
||||
error.code = 'INVALID_JOB'
|
||||
throw error
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
function sessionMetaPreset(exec) {
|
||||
const meta = exec?.agent?.session?.meta
|
||||
|| exec?.agent?.meta
|
||||
|| exec?.session?.meta
|
||||
|| null
|
||||
if (!meta || typeof meta !== 'object') return ''
|
||||
const raw = meta.agentPreset ?? meta.agent_preset
|
||||
return typeof raw === 'string' ? raw.trim() : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} args
|
||||
* @param {object} exec
|
||||
* @param {{ dshIm?: object, agentPresets?: object }} deps
|
||||
* @returns {Promise<string>} empty string means "Host default at fire time"
|
||||
*/
|
||||
export async function resolveCreateAgentPreset(args = {}, exec = {}, deps = {}) {
|
||||
const explicit = typeof args.agent_preset === 'string' ? args.agent_preset.trim()
|
||||
: (typeof args.agentPreset === 'string' ? args.agentPreset.trim() : '')
|
||||
if (explicit) return normalizeAgentPresetId(explicit)
|
||||
|
||||
const dshIm = deps.dshIm
|
||||
const sessionId = callerSessionId(exec)
|
||||
if (sessionId && dshIm && typeof dshIm.resolveSessionPeer === 'function') {
|
||||
try {
|
||||
const peer = await dshIm.resolveSessionPeer(sessionId)
|
||||
const fromPeer = typeof peer?.agentPreset === 'string' ? peer.agentPreset.trim() : ''
|
||||
if (fromPeer) return normalizeAgentPresetId(fromPeer)
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
const fromSession = sessionMetaPreset(exec)
|
||||
if (fromSession) {
|
||||
try {
|
||||
return normalizeAgentPresetId(fromSession)
|
||||
} catch {
|
||||
// ignore invalid session meta
|
||||
}
|
||||
}
|
||||
|
||||
const presets = deps.agentPresets
|
||||
if (presets && typeof presets.resolve === 'function') {
|
||||
try {
|
||||
const resolved = await presets.resolve()
|
||||
const id = typeof resolved?.id === 'string' ? resolved.id.trim() : ''
|
||||
return id ? normalizeAgentPresetId(id) : ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import { randomUUID } from 'node:crypto'
|
|||
import { applyRunIsolation, recordHiddenSession } from './isolation.js'
|
||||
import { nextFire, validateSchedule } from './scheduler.js'
|
||||
import { normalizeDelivery } from './delivery.js'
|
||||
import { normalizeAgentPresetId } from './preset.js'
|
||||
|
||||
export const STORE_VERSION = 1
|
||||
|
||||
|
|
@ -94,6 +95,7 @@ export function createJobRecord(input, state, now) {
|
|||
const timeoutMinutes = Number(input.timeoutMinutes)
|
||||
const model = normalizeJobModel(input)
|
||||
const delivery = normalizeDelivery(input.delivery)
|
||||
const agentPreset = normalizeAgentPresetId(input.agentPreset ?? input.agent_preset)
|
||||
const job = {
|
||||
id: String(input.id || newId()),
|
||||
name,
|
||||
|
|
@ -106,6 +108,7 @@ export function createJobRecord(input, state, now) {
|
|||
provider: model.provider,
|
||||
model: model.model,
|
||||
reasoningEffort: model.reasoningEffort,
|
||||
agentPreset,
|
||||
delivery,
|
||||
schedule: schedule.kind === 'cron'
|
||||
? { kind: 'cron', expr: schedule.expr, timezone: schedule.timezone }
|
||||
|
|
|
|||
42
lib/tools.js
42
lib/tools.js
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
import { formatInZone, resolveTodayAt } from './scheduler.js'
|
||||
import { deliveryLine, resolveCreateDelivery } from './delivery.js'
|
||||
import { resolveCreateAgentPreset } from './preset.js'
|
||||
|
||||
const JOB_SCHEMA = {
|
||||
type: 'object',
|
||||
|
|
@ -19,6 +20,7 @@ const JOB_SCHEMA = {
|
|||
provider: { type: 'string' },
|
||||
model: { type: 'string' },
|
||||
reasoningEffort: { type: 'string' },
|
||||
agentPreset: { type: 'string' },
|
||||
delivery: { type: 'object', additionalProperties: true },
|
||||
schedule: { type: 'object', additionalProperties: true },
|
||||
createdAt: { type: 'number' },
|
||||
|
|
@ -142,15 +144,16 @@ function requireId(args) {
|
|||
|
||||
export function cronToolDefinitions(service, deps = {}) {
|
||||
const getDshIm = typeof deps.getDshIm === 'function' ? deps.getDshIm : () => undefined
|
||||
const getAgentPresets = typeof deps.getAgentPresets === 'function' ? deps.getAgentPresets : () => undefined
|
||||
return [
|
||||
{
|
||||
name: 'cron_create',
|
||||
description: 'Create a DSH 运维定时 job (sidebar scheduled job). NEVER use crontab. NOT schedule_create. Default timezone Asia/Shanghai. For "in N minutes / 一分钟后" pass only after_minutes. For "today/tonight at HH:MM" pass only hour+minute (24h; hour=0 is midnight). Do not also send at or expr. Extra schedule fields are ignored (after_minutes > hour > at > expr). cwd MUST be the current workspace path when chatting in a workspace; if omitted, uses the current session working directory. Pass provider+model for the job; if omitted, snapshots the current session model. Delivery: omit for auto (WhatsApp/IM session → im if a matching 投递目标 exists; else dsh). Or pass delivery=dsh|im with im_bot_id+im_target_id from IM 投递设置.',
|
||||
description: 'Create a DSH 定时任务 job (sidebar scheduled job). NEVER use crontab. NOT schedule_create. Default timezone Asia/Shanghai. For "in N minutes / 一分钟后" pass only after_minutes. For "today/tonight at HH:MM" pass only hour+minute (24h; hour=0 is midnight). Do not also send at or expr. Extra schedule fields are ignored (after_minutes > hour > at > expr). cwd MUST be the current workspace path when chatting in a workspace; if omitted, uses the current session working directory. Pass provider+model for the job; if omitted, snapshots the current session model. Agent preset: omit to inherit (WhatsApp chat preset → session → Host default); or pass agent_preset. Delivery: omit for auto — on WhatsApp/IM creates/reuses a 投递目标 for the current chat (group→group, DM→DM) and sets delivery=im; on Web/DSH defaults to dsh. Or pass delivery=dsh|im with im_bot_id+im_target_id.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
name: { type: 'string', description: 'Short display name in the 运维定时 list.' },
|
||||
name: { type: 'string', description: 'Short display name in the 定时任务 list.' },
|
||||
prompt: { type: 'string', description: 'Self-contained instructions for the new session when the job fires.' },
|
||||
expr: { type: 'string', description: '5-field cron in the given timezone: "minute hour day-of-month month day-of-week". Recurring jobs only. 22:55 every day = "55 22 * * *". Do not send expr together with after_minutes/hour/at — extras are ignored.' },
|
||||
cron: { type: 'string', description: 'Alias of expr.' },
|
||||
|
|
@ -170,6 +173,7 @@ export function cronToolDefinitions(service, deps = {}) {
|
|||
delivery: { type: 'string', description: 'dsh (sidebar session) or im (proactive WhatsApp/IM via botId+targetId). Omit to auto-detect from the current session.' },
|
||||
im_bot_id: { type: 'string', description: 'Opaque botId from IM 投递设置 when delivery=im.' },
|
||||
im_target_id: { type: 'string', description: 'Opaque targetId from IM 投递设置 when delivery=im.' },
|
||||
agent_preset: { type: 'string', description: 'Agent preset id for scheduled runs. Omit to inherit from the current WhatsApp/IM chat or Host default.' },
|
||||
},
|
||||
required: ['name', 'prompt'],
|
||||
},
|
||||
|
|
@ -194,12 +198,16 @@ export function cronToolDefinitions(service, deps = {}) {
|
|||
return text(`Created "${job?.name}" (${job?.id}) ${sched} in ${tz}.${cwd}.${model}.${delivery}. Next run: ${when}. If that local time is wrong, delete this job and recreate with 24h hours (22=10pm).`)
|
||||
},
|
||||
},
|
||||
presentCall: (args) => ({ card: 'generic', title: '创建运维定时', content: String(args?.name || '') }),
|
||||
presentCall: (args) => ({ card: 'generic', title: '创建定时任务', content: String(args?.name || '') }),
|
||||
async execute(args, exec) {
|
||||
aborted(exec)
|
||||
const timeout = Number(args.timeout_minutes)
|
||||
try {
|
||||
const delivery = await resolveCreateDelivery(args, exec, { dshIm: getDshIm() })
|
||||
const agentPreset = await resolveCreateAgentPreset(args, exec, {
|
||||
dshIm: getDshIm(),
|
||||
agentPresets: getAgentPresets(),
|
||||
})
|
||||
const job = await service.createJob({
|
||||
name: args.name,
|
||||
prompt: args.prompt,
|
||||
|
|
@ -209,6 +217,7 @@ export function cronToolDefinitions(service, deps = {}) {
|
|||
timeoutMinutes: Number.isFinite(timeout) && timeout > 0 ? timeout : undefined,
|
||||
enabled: args.enabled !== false,
|
||||
delivery,
|
||||
agentPreset,
|
||||
})
|
||||
return { job }
|
||||
} catch (error) {
|
||||
|
|
@ -225,7 +234,7 @@ export function cronToolDefinitions(service, deps = {}) {
|
|||
},
|
||||
{
|
||||
name: 'cron_list',
|
||||
description: 'List DSH 运维定时 jobs (sidebar scheduled jobs). Use this whenever the user asks what scheduled tasks exist. NEVER use crontab -l or /etc/cron* — those are OS crontabs, not this plugin.',
|
||||
description: 'List DSH 定时任务 jobs (sidebar scheduled jobs). Use this whenever the user asks what scheduled tasks exist. NEVER use crontab -l or /etc/cron* — those are OS crontabs, not this plugin.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
|
|
@ -248,7 +257,7 @@ export function cronToolDefinitions(service, deps = {}) {
|
|||
return text(rows.length ? `Scheduled tasks (${value.count}):\n${rows.join('\n')}` : 'No scheduled tasks yet.')
|
||||
},
|
||||
},
|
||||
presentCall: () => ({ card: 'generic', title: '列出运维定时' }),
|
||||
presentCall: () => ({ card: 'generic', title: '列出定时任务' }),
|
||||
async execute(args, exec) {
|
||||
aborted(exec)
|
||||
let jobs = await service.listJobs()
|
||||
|
|
@ -258,7 +267,7 @@ export function cronToolDefinitions(service, deps = {}) {
|
|||
},
|
||||
{
|
||||
name: 'cron_pause',
|
||||
description: 'Pause a scheduled task by id from cron_list / cron_create. It stays in 运维定时 but will not fire until resumed.',
|
||||
description: 'Pause a scheduled task by id from cron_list / cron_create. It stays in 定时任务 but will not fire until resumed.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
|
|
@ -269,7 +278,7 @@ export function cronToolDefinitions(service, deps = {}) {
|
|||
schema: { type: 'object', additionalProperties: true, properties: { job: JOB_SCHEMA } },
|
||||
render: (_args, value) => text(`Paused "${value.job?.name}" (${value.job?.id}).`),
|
||||
},
|
||||
presentCall: (args) => ({ card: 'generic', title: '暂停运维定时', content: String(args?.id || '') }),
|
||||
presentCall: (args) => ({ card: 'generic', title: '暂停定时任务', content: String(args?.id || '') }),
|
||||
async execute(args, exec) {
|
||||
aborted(exec)
|
||||
const job = await service.pauseJob(requireId(args), false)
|
||||
|
|
@ -289,7 +298,7 @@ export function cronToolDefinitions(service, deps = {}) {
|
|||
schema: { type: 'object', additionalProperties: true, properties: { job: JOB_SCHEMA } },
|
||||
render: (_args, value) => text(`Resumed "${value.job?.name}" (${value.job?.id}).`),
|
||||
},
|
||||
presentCall: (args) => ({ card: 'generic', title: '恢复运维定时', content: String(args?.id || '') }),
|
||||
presentCall: (args) => ({ card: 'generic', title: '恢复定时任务', content: String(args?.id || '') }),
|
||||
async execute(args, exec) {
|
||||
aborted(exec)
|
||||
const job = await service.pauseJob(requireId(args), true)
|
||||
|
|
@ -317,7 +326,7 @@ export function cronToolDefinitions(service, deps = {}) {
|
|||
},
|
||||
render: (_args, value) => text(value.deleted ? `Deleted scheduled task ${value.id}.` : `Scheduled task ${value.id} was not found.`),
|
||||
},
|
||||
presentCall: (args) => ({ card: 'generic', title: '删除运维定时', content: String(args?.id || '') }),
|
||||
presentCall: (args) => ({ card: 'generic', title: '删除定时任务', content: String(args?.id || '') }),
|
||||
async execute(args, exec) {
|
||||
aborted(exec)
|
||||
const id = requireId(args)
|
||||
|
|
@ -336,17 +345,18 @@ export function cronToolDefinitions(service, deps = {}) {
|
|||
export function cronGuidanceText(nowMs = Date.now(), timeZone = 'Asia/Shanghai') {
|
||||
const now = formatInZone(nowMs, timeZone)
|
||||
return [
|
||||
'This deployment has a DSH sidebar feature named 运维定时 (ops scheduled tasks / dsh-ops-cron).',
|
||||
'This deployment has a DSH sidebar feature named 定时任务 (scheduled tasks / dsh-ops-cron).',
|
||||
'Jobs live in that list and fire a fresh session (and optionally deliver a summary to WhatsApp/IM). They are NOT OS crontab and NOT in-chat schedule_create reminders.',
|
||||
`Current local time for scheduling: ${now}. Use this calendar date for "today"/"tonight". Never invent another month or day.`,
|
||||
'For "in N minutes / 一分钟后", call cron_create with after_minutes=N only (do not also pass at, hour, or expr).',
|
||||
'For a clock time tonight, pass only hour and minute in 24h (晚上11点34 → hour=23, minute=34; 零点33 → hour=0, minute=33). Extra at/expr fields are ignored.',
|
||||
'Working directory: if the user is chatting in a workspace, pass cwd as that workspace filesystem path (the current session working directory). If they name another workspace, use that path. If cwd is omitted, cron_create uses the current session cwd.',
|
||||
'Model: pass provider+model for the job. If omitted, cron_create stores the current session model. Scheduled runs consume that model\'s quota.',
|
||||
'Delivery: when chatting on WhatsApp/IM, omit delivery so the job defaults to im (requires a matching 投递目标 for this peer). On Web/DSH, default is dsh. Or pass delivery=im with im_bot_id+im_target_id from IM 投递设置 → 复制调用参数.',
|
||||
'When the user asks to look at, create, pause, resume, or delete 运维定时 / 定时任务 / scheduled tasks / cron jobs:',
|
||||
'Delivery: when chatting on WhatsApp/IM, omit delivery so the job defaults to im for the current chat (group→same group, DM→same DM). A 投递目标 is reused or auto-created. On Web/DSH, default is dsh. Or pass delivery=im with im_bot_id+im_target_id.',
|
||||
'Agent preset: omit agent_preset to inherit (WhatsApp chat/group preset → creating session → Host default). Pass agent_preset to pin a preset for every scheduled run.',
|
||||
'When the user asks to look at, create, pause, resume, or delete 定时任务 / scheduled tasks / cron jobs:',
|
||||
'1. If cron_list / cron_create / cron_pause / cron_resume / cron_delete are in your tool list, call them.',
|
||||
'2. If they are not listed, search/unlock tools or skills with query "运维定时" or "cron" (skill_search, skill_load, or dev_tool_search) and then call them.',
|
||||
'2. If they are not listed, search/unlock tools or skills with query "定时任务" or "cron" (skill_search, skill_load, or dev_tool_search) and then call them.',
|
||||
'3. Never run crontab, never read /etc/cron*, and never say there are no tasks until cron_list has returned.',
|
||||
].join('\n')
|
||||
}
|
||||
|
|
@ -356,15 +366,15 @@ export const CRON_GUIDANCE = cronGuidanceText()
|
|||
export function makeCronSkill() {
|
||||
return {
|
||||
name: 'scheduled-tasks',
|
||||
description: '运维定时: 查看、创建、暂停、恢复、删除 DSH 侧栏定时任务(今天晚上几点、一次性执行、scheduled job、cron)。WhatsApp 里创建默认回投 IM;Web 里创建进侧栏会话。不要用系统 crontab,也不是会话内 reminder。',
|
||||
whenToUse: 'User asks to list or create 运维定时 / 定时任务, schedule something for tonight/today, pause a job, or mentions cron / scheduled tasks in DeepSeek Harness.',
|
||||
description: '定时任务: 查看、创建、暂停、恢复、删除 DSH 侧栏定时任务(今天晚上几点、一次性执行、scheduled job、cron)。WhatsApp 里创建默认回投 IM;Web 里创建进侧栏会话。不要用系统 crontab,也不是会话内 reminder。',
|
||||
whenToUse: 'User asks to list or create 定时任务 / scheduled tasks, schedule something for tonight/today, pause a job, or mentions cron in DeepSeek Harness.',
|
||||
source: 'runtime',
|
||||
provider: 'runtime',
|
||||
content: `${cronGuidanceText()}
|
||||
|
||||
Tools:
|
||||
- cron_list — list jobs
|
||||
- cron_create — "一分钟后" → after_minutes=1. Clock time → hour+minute only. Do not send at/expr at the same time. Pass cwd as the current workspace path when creating from a workspace chat. Pass provider+model or inherit the current session model. Delivery auto from session, or delivery=im + im_bot_id + im_target_id.
|
||||
- cron_create — "一分钟后" → after_minutes=1. Clock time → hour+minute only. Do not send at/expr at the same time. Pass cwd as the current workspace path when creating from a workspace chat. Pass provider+model or inherit the current session model. Delivery and agent preset auto from session, or pass delivery=im / agent_preset explicitly.
|
||||
- cron_pause / cron_resume / cron_delete — by id from cron_list
|
||||
`,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "dsh-ops-cron",
|
||||
"description": "Ops fork of dsh-cron-tasks: Agent cron_* tools + sidebar 运维定时, with DSH or WhatsApp/IM delivery per job.",
|
||||
"version": "0.1.0",
|
||||
"description": "Scheduled tasks for DeepSeek Harness: Agent cron_* tools + sidebar 定时任务, with DSH or WhatsApp/IM delivery per job.",
|
||||
"version": "0.1.7",
|
||||
"private": false,
|
||||
"type": "module",
|
||||
"repository": {
|
||||
|
|
|
|||
|
|
@ -42,6 +42,8 @@ test('resolveCreateDelivery auto-matches peer target', async () => {
|
|||
resolveSessionPeer: async () => ({
|
||||
botId: 'bot_1',
|
||||
conversationKey: 'direct:86138@s.whatsapp.net',
|
||||
conversationId: '86138@s.whatsapp.net',
|
||||
kind: 'direct',
|
||||
}),
|
||||
listTargets: async () => ([
|
||||
{ targetId: 'wa-dm', route: { jid: '86138@s.whatsapp.net' } },
|
||||
|
|
@ -53,6 +55,134 @@ test('resolveCreateDelivery auto-matches peer target', async () => {
|
|||
assert.deepEqual(delivery, { kind: 'im', botId: 'bot_1', targetId: 'wa-dm' })
|
||||
})
|
||||
|
||||
test('resolveCreateDelivery auto-creates group target when none exists', async () => {
|
||||
const created = []
|
||||
const dshIm = {
|
||||
resolveSessionPeer: async () => ({
|
||||
botId: 'bot_wa',
|
||||
kind: 'group',
|
||||
conversationKey: 'group:120363@g.us:user:86138@s.whatsapp.net',
|
||||
conversationId: '120363@g.us',
|
||||
label: '运维群',
|
||||
}),
|
||||
listTargets: async () => ([]),
|
||||
createTarget: async (botId, target) => {
|
||||
created.push({ botId, target })
|
||||
return target
|
||||
},
|
||||
}
|
||||
const delivery = await resolveCreateDelivery({}, {
|
||||
agent: { session: { id: 'sess-group' } },
|
||||
}, { dshIm })
|
||||
assert.equal(created.length, 1)
|
||||
assert.equal(created[0].botId, 'bot_wa')
|
||||
assert.equal(created[0].target.kind, 'group')
|
||||
assert.equal(created[0].target.route.jid, '120363@g.us')
|
||||
assert.deepEqual(delivery, {
|
||||
kind: 'im',
|
||||
botId: 'bot_wa',
|
||||
targetId: created[0].target.targetId,
|
||||
})
|
||||
})
|
||||
|
||||
test('matchTargetForPeer matches phone-mapped DM against phone JID target', () => {
|
||||
const peer = {
|
||||
botId: 'bot_1',
|
||||
conversationKey: 'direct:910xxx@lid',
|
||||
conversationId: '910xxx@lid',
|
||||
phone: '8613800000000',
|
||||
}
|
||||
const targets = [
|
||||
{ targetId: 'alerts', kind: 'user', route: { jid: '8613800000000@s.whatsapp.net' } },
|
||||
]
|
||||
assert.equal(matchTargetForPeer(targets, peer).targetId, 'alerts')
|
||||
})
|
||||
|
||||
test('matchTargetForPeer does not bind group peer to a DM via sender phone', () => {
|
||||
const peer = {
|
||||
botId: 'bot_1',
|
||||
kind: 'group',
|
||||
conversationKey: 'group:120363999@g.us:user:8613800000000@s.whatsapp.net',
|
||||
conversationId: '120363999@g.us',
|
||||
phone: '8613800000000',
|
||||
label: 'Ops Room · Alice · 8613800000000',
|
||||
}
|
||||
const targets = [
|
||||
{ targetId: 'my-dm', kind: 'user', route: { jid: '8613800000000@s.whatsapp.net' } },
|
||||
{ targetId: 'ops-group', kind: 'group', route: { jid: '120363999@g.us' } },
|
||||
]
|
||||
assert.equal(matchTargetForPeer(targets, peer).targetId, 'ops-group')
|
||||
})
|
||||
|
||||
test('matchTargetForPeer ignores DM-only catalog when peer is a group', () => {
|
||||
const peer = {
|
||||
botId: 'bot_1',
|
||||
kind: 'group',
|
||||
conversationKey: 'group:120363999@g.us:user:8613800000000@s.whatsapp.net',
|
||||
conversationId: '120363999@g.us',
|
||||
phone: '8613800000000',
|
||||
}
|
||||
const targets = [
|
||||
{ targetId: 'my-dm', kind: 'user', route: { jid: '8613800000000@s.whatsapp.net' } },
|
||||
]
|
||||
assert.equal(matchTargetForPeer(targets, peer), null)
|
||||
})
|
||||
|
||||
test('resolveCreateDelivery pins group creator mention from peer', async () => {
|
||||
const dshIm = {
|
||||
resolveSessionPeer: async () => ({
|
||||
botId: 'bot_wa',
|
||||
kind: 'group',
|
||||
conversationKey: 'group:120363@g.us:user:910xxx@lid',
|
||||
conversationId: '120363@g.us',
|
||||
senderId: '910xxx@lid',
|
||||
phone: '8613800000000',
|
||||
pushName: 'Alice',
|
||||
label: 'Ops · Alice · 8613800000000',
|
||||
}),
|
||||
listTargets: async () => ([
|
||||
{ targetId: 'ops-group', kind: 'group', route: { jid: '120363@g.us' } },
|
||||
]),
|
||||
}
|
||||
const delivery = await resolveCreateDelivery({}, {
|
||||
agent: { session: { id: 'sess-group' } },
|
||||
}, { dshIm })
|
||||
assert.deepEqual(delivery, {
|
||||
kind: 'im',
|
||||
botId: 'bot_wa',
|
||||
targetId: 'ops-group',
|
||||
// Prefer phone JID over LID so WhatsApp can show nickname + notify.
|
||||
mentionJid: '8613800000000@s.whatsapp.net',
|
||||
mentionName: 'Alice',
|
||||
})
|
||||
})
|
||||
|
||||
test('deliverRunToIm @mentions the creator in group deliveries', async () => {
|
||||
const sent = []
|
||||
await deliverRunToIm(
|
||||
{
|
||||
name: 'ping',
|
||||
delivery: {
|
||||
kind: 'im',
|
||||
botId: 'b',
|
||||
targetId: 't',
|
||||
mentionJid: '8613800000000@s.whatsapp.net',
|
||||
mentionName: 'Alice',
|
||||
},
|
||||
},
|
||||
'hello world',
|
||||
{
|
||||
dshIm: {
|
||||
send: async (...args) => { sent.push(args) },
|
||||
},
|
||||
},
|
||||
)
|
||||
assert.equal(sent.length, 1)
|
||||
assert.match(sent[0][2], /^@8613800000000\n/)
|
||||
assert.match(sent[0][2], /定时任务/)
|
||||
assert.deepEqual(sent[0][3], { mentions: ['8613800000000@s.whatsapp.net'] })
|
||||
})
|
||||
|
||||
test('deliverRunToIm sends via dshIm', async () => {
|
||||
const sent = []
|
||||
await deliverRunToIm(
|
||||
|
|
@ -63,6 +193,6 @@ test('deliverRunToIm sends via dshIm', async () => {
|
|||
assert.equal(sent.length, 1)
|
||||
assert.equal(sent[0][0], 'b')
|
||||
assert.equal(sent[0][1], 't')
|
||||
assert.match(sent[0][2], /运维定时/)
|
||||
assert.match(sent[0][2], /定时任务/)
|
||||
assert.match(sent[0][2], /hello world/)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ test('installable bundle declares host apply, client half, unique id, and no @de
|
|||
assert.match(tools, /resolveCreateCwd/)
|
||||
assert.match(tools, /after_minutes/)
|
||||
assert.match(tools, /Extra schedule fields are ignored/)
|
||||
assert.match(tools, /agent_preset/)
|
||||
assert.match(tools, /resolveCreateAgentPreset/)
|
||||
assert.doesNotMatch(tools, /pass only one of expr, at, or hour/)
|
||||
assert.match(index, /makeCronSkill/)
|
||||
assert.doesNotMatch(index, /agent\/session-start/)
|
||||
|
|
@ -46,9 +48,12 @@ test('installable bundle declares host apply, client half, unique id, and no @de
|
|||
assert.match(host, /API_PREFIX = '\/dsh-ops-cron'/)
|
||||
assert.match(host, /resolveSessionPlacement/)
|
||||
assert.match(host, /\/models/)
|
||||
assert.match(host, /\/presets/)
|
||||
assert.match(host, /composeCronAgent/)
|
||||
assert.match(host, /listPresetChoices/)
|
||||
assert.doesNotMatch(host, /from ['"]@deepseek-ai\//)
|
||||
const client = await readFile(join(root, 'lib/client.js'), 'utf8')
|
||||
assert.match(client, /运维定时/)
|
||||
assert.match(client, /定时任务/)
|
||||
assert.match(client, /dsh-ops-cron/)
|
||||
assert.match(client, /settings\.plugin\.item/)
|
||||
assert.match(client, /deliveryKind/)
|
||||
|
|
@ -56,7 +61,11 @@ test('installable bundle declares host apply, client half, unique id, and no @de
|
|||
assert.match(client, /imTargetId/)
|
||||
assert.match(client, /CwdField/)
|
||||
assert.match(client, /ModelField/)
|
||||
assert.match(client, /PresetField/)
|
||||
assert.match(client, /agentPreset/)
|
||||
assert.match(client, /inject = \['slots', 'locale', 'settingsScope'\]/)
|
||||
const preset = await readFile(join(root, 'lib/preset.js'), 'utf8')
|
||||
assert.match(preset, /resolveCreateAgentPreset/)
|
||||
const delivery = await readFile(join(root, 'lib/delivery.js'), 'utf8')
|
||||
assert.match(delivery, /resolveCreateDelivery/)
|
||||
assert.match(delivery, /deliverRunToIm/)
|
||||
|
|
|
|||
46
test/preset.test.js
Normal file
46
test/preset.test.js
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { normalizeAgentPresetId, resolveCreateAgentPreset } from '../lib/preset.js'
|
||||
import { createJobRecord, emptyState } from '../lib/store.js'
|
||||
|
||||
test('normalizeAgentPresetId accepts empty and valid ids', () => {
|
||||
assert.equal(normalizeAgentPresetId(''), '')
|
||||
assert.equal(normalizeAgentPresetId(' ops-desk '), 'ops-desk')
|
||||
assert.throws(() => normalizeAgentPresetId('bad preset'), /agentPreset/)
|
||||
})
|
||||
|
||||
test('createJobRecord persists agentPreset', () => {
|
||||
const job = createJobRecord({
|
||||
name: 'n',
|
||||
prompt: 'p',
|
||||
schedule: { kind: 'cron', expr: '0 9 * * *', timezone: 'Asia/Shanghai' },
|
||||
agentPreset: 'ops-desk',
|
||||
}, emptyState(), Date.now())
|
||||
assert.equal(job.agentPreset, 'ops-desk')
|
||||
})
|
||||
|
||||
test('resolveCreateAgentPreset prefers explicit then peer then session then host default', async () => {
|
||||
assert.equal(
|
||||
await resolveCreateAgentPreset({ agent_preset: 'explicit' }, {}, {}),
|
||||
'explicit',
|
||||
)
|
||||
|
||||
const fromPeer = await resolveCreateAgentPreset({}, { agent: { session: { id: 's1' } } }, {
|
||||
dshIm: {
|
||||
resolveSessionPeer: async () => ({ agentPreset: 'from-peer' }),
|
||||
},
|
||||
})
|
||||
assert.equal(fromPeer, 'from-peer')
|
||||
|
||||
const fromMeta = await resolveCreateAgentPreset({}, {
|
||||
agent: { session: { meta: { agentPreset: 'from-meta' } } },
|
||||
}, {})
|
||||
assert.equal(fromMeta, 'from-meta')
|
||||
|
||||
const fromHost = await resolveCreateAgentPreset({}, {}, {
|
||||
agentPresets: {
|
||||
resolve: async () => ({ id: 'host-default' }),
|
||||
},
|
||||
})
|
||||
assert.equal(fromHost, 'host-default')
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue