diff --git a/FORK.md b/FORK.md
new file mode 100644
index 0000000..fac6db4
--- /dev/null
+++ b/FORK.md
@@ -0,0 +1,33 @@
+# dsh-ops-cron(运维 fork)
+
+本仓库是 [Whale-Zhang/dsh-cron-tasks](https://github.com/Whale-Zhang/dsh-cron-tasks) 的 **运维 fork**,用于:
+
+- Agent 自建定时任务(`cron_create` / `cron_list` / …)
+- 按创建来源默认投递:**WhatsApp 会话 → IM**;**Web/DSH 会话 → 侧栏新会话**
+- 与 [dsh-im-ops](https://github.com/hansjone/dsh-im-ops) 的 `ctx.dshIm.send(botId, targetId, text)` 打通
+
+不要与上游 `@dsh-external/dsh-cron-tasks` 装进同一 profile(工具名冲突)。
+
+## 安装
+
+```powershell
+# 建议顺序:先 im,再 cron
+dsh plugin --profile web add -w "github:hansjone/dsh-im-ops"
+dsh plugin --profile web add -w "github:hansjone/dsh-ops-cron"
+# 重启 dsh web
+```
+
+本地开发:
+
+```powershell
+dsh plugin --profile web add -w "D:\project\chatgpt\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` 投回
+
+关键告警推送仍在 **netxops**,不经过本插件。
diff --git a/README.md b/README.md
index b9f04f8..f3797e4 100644
--- a/README.md
+++ b/README.md
@@ -1,116 +1,31 @@
-# dsh-cron-tasks
+# dsh-ops-cron
-[English](#english) · [中文](#zhongwen)
+Ops fork of [dsh-cron-tasks](https://github.com/Whale-Zhang/dsh-cron-tasks) for DeepSeek Harness.
-A DeepSeek Harness (`dsh`) plugin for **scheduled tasks**. Sidebar entry under **New Session**, plugin settings, isolated run history, and model-facing `cron_*` tools.
+- 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`)
-
-
+See [FORK.md](FORK.md) for install notes. Do **not** install upstream `@dsh-external/dsh-cron-tasks` in the same profile.
-
-
-## 中文
-
-到点后 **新开一条会话** 跑事先写好的提示词,记进侧栏「定时任务」历史。这类会话不会淹没日常工作区列表。
-
-这不是官方同会话 reminder(`@deepseek-ai/dsh-schedule`),也不是系统 `crontab`。调度器住在 DSH Host 里:Host 没在跑就不会触发。
-
-### 安装
-
-需要已安装 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)。一条命令装进网页端和桌面端:
+## Install
```sh
-dsh plugin --profile web add github:Whale-Zhang/dsh-cron-tasks
-dsh plugin --profile desktop add github:Whale-Zhang/dsh-cron-tasks
+dsh plugin --profile web add -w "github:hansjone/dsh-im-ops"
+dsh plugin --profile web add -w "github:hansjone/dsh-ops-cron"
+# restart dsh web
```
-装完后**重启对应 profile**(`dsh web` 或 Desktop)。用下面命令应能看到 `dsh-cron-tasks`:
+## Delivery defaults
-```sh
-dsh --profile web --dump-config
-```
+| Created from | Default delivery |
+|--------------|------------------|
+| WhatsApp / IM session (peer resolved + matching 投递目标) | `im` → that `botId`+`targetId` |
+| 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.
-```sh
-dsh plugin --profile web remove @dsh-external/dsh-cron-tasks
-dsh plugin --profile desktop remove @dsh-external/dsh-cron-tasks
-```
+## License
-### 做什么
-
-- 左侧 **「新会话」正下方** 的 **「定时任务」** 入口:创建、暂停/恢复、删除、立即运行、搜索
-- **设置 → 插件 → 插件配置** 里的调度策略(启用、默认时区、历史保留、重叠/漏跑)
-- 每次触发新开 root Session;点历史记录打开原生对话,可以继续聊
-- 每个任务可指定 **模型**(钉死后不跟着聊天模型变,额度可预期)
-- 工作目录可下拉已有工作区,或自定义路径
-- 也可在对话里对 AI 说「帮我建一个定时任务」(skill `scheduled-tasks` + `cron_create`)
-
-### 日程
-
-- 一次性 `at`,或循环 5 字段 cron,IANA 时区(默认 `Asia/Shanghai`)
-- 「一分钟后」用 `after_minutes`,不要叠 `at` / `hour` / `expr`
-- 重叠:同一任务已有进行中的运行则跳过
-- 漏跑:Host 宕机期间错过的节拍 **不补跑**
-
-### 开发
-
-```sh
-node --test test/*.test.js
-```
-
-本地 `file:` 安装(开发用,不要用 `link:`):
-
-```sh
-dsh plugin --profile web add file:$HOME/.dsh/plugins/dsh-cron-tasks
-```
-
-产品契约见 [docs/PRD.md](docs/PRD.md)。实现经验见 [docs/NOTES.md](docs/NOTES.md)。
-
----
-
-
-
-## English
-
-At the scheduled time, Harness **opens a fresh root session**, runs a self-contained prompt, and records the run in the **Scheduled tasks** sidebar. Those sessions stay out of the ordinary workspace list.
-
-This is not the official same-chat reminder (`@deepseek-ai/dsh-schedule`) and not OS crontab. The scheduler lives in the DSH Host process; nothing fires while Host is down.
-
-### Install
-
-Requires [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness).
-
-```sh
-dsh plugin --profile web add github:Whale-Zhang/dsh-cron-tasks
-dsh plugin --profile desktop add github:Whale-Zhang/dsh-cron-tasks
-```
-
-Restart the profile after installing. `dsh --profile web --dump-config` should list `dsh-cron-tasks`.
-
-### Uninstall
-
-```sh
-dsh plugin --profile web remove @dsh-external/dsh-cron-tasks
-dsh plugin --profile desktop remove @dsh-external/dsh-cron-tasks
-```
-
-### What it does
-
-- Sidebar entry **under New Session**: create, pause/resume, delete, run now, search
-- Scheduler policy in **Settings → Plugins → Plugin configuration**
-- Each fire is a new root session; open a history row to continue in the native chat
-- Each job can pin a **model** so quota does not follow the chat selector
-- Working directory: existing workspace or a custom path
-- Ask the agent to create a job (`scheduled-tasks` skill + `cron_create`)
-
-### Schedule
-
-- One-shot `at` or 5-field cron, IANA time zone (default `Asia/Shanghai`)
-- “In N minutes” → `after_minutes`
-- Overlap: skip if the same job is already running
-- Misfire: missed beats while Host was down are **not** replayed
-
-### License
-
-MIT
+MIT (upstream MIT retained).
diff --git a/cordis.patch.yml b/cordis.patch.yml
index db093ae..6a4e8c1 100644
--- a/cordis.patch.yml
+++ b/cordis.patch.yml
@@ -1,4 +1,5 @@
-# Insert this plugin into a profile's layer stack.
+# Insert this ops cron plugin into a profile's layer stack.
+# Do not install upstream @dsh-external/dsh-cron-tasks alongside this package.
- insert:
- - id: dsh-cron-tasks
- name: '@dsh-external/dsh-cron-tasks'
+ - id: dsh-ops-cron
+ name: dsh-ops-cron
diff --git a/lib/client.js b/lib/client.js
index a8c28bd..9133612 100644
--- a/lib/client.js
+++ b/lib/client.js
@@ -1,5 +1,5 @@
window.__ModuleLoader__.load({
- id: '@dsh-external/dsh-cron-tasks',
+ id: 'dsh-ops-cron',
factory: (require) => {
const module = { exports: {} }
const exports = module.exports
@@ -156,16 +156,16 @@ window.__ModuleLoader__.load({
return undefined
}
- const name = 'dsh-cron-tasks'
- const NS = 'dsh-cron-tasks'
+ const name = 'dsh-ops-cron'
+ const NS = 'dsh-ops-cron'
const inject = ['slots', 'locale', 'settingsScope']
- const API = '/dsh-cron-tasks'
+ const API = '/dsh-ops-cron'
const LOCALE_NS = 'settings.dshCronTasks'
- const TITLE_PREFIX = '定时任务 · '
+ const TITLE_PREFIX = '运维定时 · '
const listSnapshot = { jobs: [], runs: [], workspaces: [], catalog: { groups: [], current: null } }
function jobStamp(rows) {
- return (rows || []).map((row) => `${row.id}:${row.updatedAt}:${row.nextRunAt}:${row.enabled}:${row.lastStatus}:${row.model}:${row.provider}`).join('|')
+ 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('|')
}
function runStamp(rows) {
@@ -291,19 +291,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,19 +313,25 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
cwdHint: '运行会在这个目录对应的工作区里开新会话。留空则用最近工作区。',
model: '模型',
modelDefault: '每次运行用当时的新会话默认',
- modelHint: '定时任务会消耗这个模型的额度。指定后不会跟着聊天模型变。',
- editorLead: '保存后按日程在新会话里执行。点击下方某次运行,右侧会打开那次对话,可以继续。',
+ modelHint: '运维定时会消耗这个模型的额度。指定后不会跟着聊天模型变。',
+ editorLead: '保存后按日程在新会话里执行;若投递为 IM,结束后还会发摘要到 WhatsApp/IM。点击下方某次运行,右侧会打开那次对话,可以继续。',
lastOutput: '上次输出', noOutput: '还没有输出。先立即运行一次。',
+ delivery: '投递',
+ deliveryDsh: 'DSH 侧栏会话',
+ deliveryIm: 'WhatsApp / IM',
+ imBotId: 'IM Bot ID',
+ imTargetId: 'IM Target ID',
+ deliveryHint: 'Web 默认 DSH;WhatsApp 会话里由 Agent 创建时可自动匹配投递目标。也可粘贴 IM「投递设置」里的 botId/targetId。',
}
const en = {
- title: 'Scheduled tasks',
+ title: 'Ops schedule',
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: 'Scheduled tasks', entryLabel: 'Open scheduled tasks',
+ entry: 'Ops schedule', entryLabel: 'Open ops schedule',
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',
@@ -341,8 +347,14 @@ 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.',
- editorLead: 'Runs start a new session. Click a run below to open that conversation and continue it.',
+ 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',
+ deliveryDsh: 'DSH sidebar session',
+ 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.',
}
function normalizePrefs(value) {
@@ -585,6 +597,9 @@ 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 || '',
+ deliveryKind: 'dsh',
+ imBotId: '',
+ imTargetId: '',
}
}
@@ -601,9 +616,19 @@ 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 || '',
+ deliveryKind: job.delivery?.kind === 'im' ? 'im' : 'dsh',
+ imBotId: job.delivery?.botId || '',
+ imTargetId: job.delivery?.targetId || '',
}
}
+ function jobDeliveryLabel(job, t) {
+ if (job?.delivery?.kind === 'im') {
+ return `${t('deliveryIm')}:${job.delivery.targetId || '?'}`
+ }
+ return t('deliveryDsh')
+ }
+
function modelKey(provider, model) {
if (!provider || !model) return ''
return `${provider}::${model}`
@@ -723,7 +748,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
h('span', { className: skin.projectText },
h('span', { className: skin.title }, paused
? `${job.name} · ${t('paused')}`
- : (jobModelLabel(job) ? `${job.name} · ${jobModelLabel(job)}` : job.name))),
+ : `${job.name} · ${jobDeliveryLabel(job, t)}${jobModelLabel(job) ? ` · ${jobModelLabel(job)}` : ''}`)),
h('span', { className: skin.rowActs, onClick: (e) => e.stopPropagation() },
h('button', { type: 'button', className: skin.rowIcon, title: t('runNow'), onClick: () => onRun(job.id) },
playIcon() || '▶'),
@@ -865,6 +890,28 @@ 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('label', null, t('delivery'),
+ h('select', {
+ value: form.deliveryKind || 'dsh',
+ onChange: (e) => setForm({ ...form, deliveryKind: e.target.value }),
+ },
+ h('option', { value: 'dsh' }, t('deliveryDsh')),
+ h('option', { value: 'im' }, t('deliveryIm')),
+ ),
+ ),
+ 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 }),
+ })),
+ )
+ : null,
+ h('span', { className: 'dsh-ct-cwdHint' }, t('deliveryHint')),
h('div', { className: 'dsh-ct-editorActs' },
h('button', { type: 'button', className: 'dsh-ct-primary', onClick: onSave }, t('save')),
!isNew ? h('button', { type: 'button', className: 'dsh-ct-secondary', onClick: onRun }, t('runNow')) : null,
@@ -1037,6 +1084,9 @@ 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,
+ delivery: form.deliveryKind === 'im'
+ ? { kind: 'im', botId: form.imBotId, targetId: form.imTargetId }
+ : { kind: 'dsh' },
}
if (selection.type === 'job' && selection.jobId) {
await api(`/jobs/${selection.jobId}`, { method: 'PATCH', body: JSON.stringify(payload) })
@@ -1147,14 +1197,14 @@ 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(TITLE_PREFIX.trim()) || value.includes(TITLE_PREFIX)
}
function workspaceTitleFromCronFork(title) {
const value = String(title || '').trim()
if (value.startsWith(TITLE_PREFIX)) return value.slice(TITLE_PREFIX.length).trim() || value
- if (value.startsWith('定时任务')) {
- const stripped = value.replace(/^定时任务(?:\s*·\s*|\s+)/, '').trim()
+ if (value.startsWith('运维定时')) {
+ const stripped = value.replace(/^运维定时(?:\s*·\s*|\s+)/, '').trim()
return stripped || value
}
return value
@@ -1169,7 +1219,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('运维定时')
if (previousId && ids.has(String(previousId)) && looksCron) return true
return false
}
@@ -1411,7 +1461,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
tag.textContent = CSS
document.head.appendChild(tag)
return () => tag.remove()
- }, 'dsh-cron-tasks: styles')
+ }, 'dsh-ops-cron: styles')
ctx.effect(() => {
try {
@@ -1421,7 +1471,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
const offEn = ctx.locale.register(LOCALE_NS, 'en', en)
return () => { offZh && offZh(); offEn && offEn() }
}
- }, 'dsh-cron-tasks: locale')
+ }, 'dsh-ops-cron: locale')
const scope = ctx.settingsScope.bind({ namespace: NS })
const form = createForm(scope)
@@ -1473,7 +1523,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
sessions.open(sessionId)
} catch (error) {
lastError = error
- console.warn('[dsh-cron-tasks] open session failed', sessionId, error)
+ console.warn('[dsh-ops-cron] open session failed', sessionId, error)
try { await sessions.refresh?.() } catch { /* ignore */ }
await sleep(80)
continue
@@ -1484,8 +1534,8 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
if (sessions.list?.getSnapshot?.()?.current === sessionId) return true
}
}
- if (lastError) console.warn('[dsh-cron-tasks] giving up open', sessionId, lastError)
- else console.warn('[dsh-cron-tasks] open session timed out', sessionId)
+ if (lastError) console.warn('[dsh-ops-cron] giving up open', sessionId, lastError)
+ else console.warn('[dsh-ops-cron] open session timed out', sessionId)
return false
},
clearSession() {
@@ -1519,7 +1569,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
if (typeof off === 'function') offs.push(off)
} catch { /* optional */ }
return () => { for (const off of offs) off() }
- }, 'dsh-cron-tasks: session faces')
+ }, 'dsh-ops-cron: session faces')
ctx.effect(() => {
const promoted = new Set()
@@ -1543,12 +1593,12 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
if (session && typeof session.rename === 'function') await session.rename(nextTitle)
}
} catch (error) {
- console.warn('[dsh-cron-tasks] fork rename failed', row.id, error)
+ console.warn('[dsh-ops-cron] fork rename failed', row.id, error)
}
try {
await api(`/sessions/${encodeURIComponent(row.id)}/adopt`, { method: 'POST' })
} catch (error) {
- console.warn('[dsh-cron-tasks] fork adopt failed', row.id, error)
+ console.warn('[dsh-ops-cron] fork adopt failed', row.id, error)
}
try { faces.leaveCronMode?.() } catch { /* ignore */ }
}
@@ -1573,14 +1623,14 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
clearInterval(timer)
if (typeof unsub === 'function') unsub()
}
- }, 'dsh-cron-tasks: fork promote')
+ }, 'dsh-ops-cron: fork promote')
ctx.effect(() => {
const lang = ctx.locale?.locale || ctx.locale?.current || 'zh'
const dict = String(lang).toLowerCase().startsWith('zh') ? zh : en
const t = (key) => dict[key] || zh[key] || key
return installChrome(t, faces)
- }, 'dsh-cron-tasks: chrome')
+ }, 'dsh-ops-cron: chrome')
}
exports.name = name
diff --git a/lib/delivery.js b/lib/delivery.js
new file mode 100644
index 0000000..25cbf28
--- /dev/null
+++ b/lib/delivery.js
@@ -0,0 +1,118 @@
+/**
+ * Job delivery: DSH sidebar session vs proactive IM (botId + targetId).
+ */
+
+export function normalizeDelivery(input = {}) {
+ const src = input && typeof input === 'object' ? input : {}
+ const kind = String(src.kind || 'dsh').trim().toLowerCase()
+ if (kind === 'im') {
+ const botId = typeof src.botId === 'string' ? src.botId.trim() : ''
+ const targetId = typeof src.targetId === 'string' ? src.targetId.trim() : ''
+ if (!botId || !targetId) {
+ const error = new Error('im delivery requires botId and targetId')
+ error.code = 'INVALID_DELIVERY'
+ throw error
+ }
+ return { kind: 'im', botId, targetId }
+ }
+ return { kind: 'dsh' }
+}
+
+export function deliveryLine(delivery) {
+ if (!delivery || delivery.kind !== 'im') return 'delivery=dsh'
+ return `delivery=im botId=${delivery.botId} targetId=${delivery.targetId}`
+}
+
+/**
+ * Match a delivery target whose route overlaps the channel peer conversation.
+ * @param {Array