mirror of
https://github.com/hansjone/dsh-ops-cron.git
synced 2026-10-09 04:40:48 +08:00
638 lines
22 KiB
JavaScript
638 lines
22 KiB
JavaScript
/**
|
|
* Job delivery: DSH sidebar session vs proactive IM (botId + targetId).
|
|
* Group jobs may also pin mentionJid so delivery @s the creator.
|
|
*/
|
|
|
|
import { DEFAULT_LOCALE, t } from './i18n.js'
|
|
import { canViewAllJobs, isMultiUserIdentity } from './ownership.js'
|
|
|
|
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
|
|
}
|
|
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' }
|
|
}
|
|
|
|
/**
|
|
* Web/sidebar callers who are not super_admin must not aim IM delivery at
|
|
* arbitrary bot/target ids (confused deputy). Channel jobs bind delivery via peer.
|
|
* @param {object|null|undefined} delivery
|
|
* @param {object|null|undefined} identity
|
|
*/
|
|
export function assertDeliveryAllowedForIdentity(delivery, identity) {
|
|
if (!delivery || delivery.kind !== 'im') return delivery
|
|
if (!identity?.empNo) return delivery
|
|
// Standalone local identity may still configure IM for single-user hosts.
|
|
if (!isMultiUserIdentity(identity)) return delivery
|
|
if (canViewAllJobs(identity)) return delivery
|
|
const error = new Error(
|
|
'only super_admin can set WhatsApp/IM delivery from the web sidebar; create channel jobs from the IM chat',
|
|
)
|
|
error.code = 'IM_DELIVERY_FORBIDDEN'
|
|
throw error
|
|
}
|
|
|
|
/**
|
|
* 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'
|
|
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)
|
|
}
|
|
|
|
/**
|
|
* Match a delivery target whose route overlaps the channel peer conversation.
|
|
* @param {Array<object>} targets
|
|
* @param {object|null} peer - from dshIm.resolveSessionPeer
|
|
*/
|
|
export function matchTargetForPeer(targets, peer) {
|
|
if (!peer || !Array.isArray(targets) || targets.length === 0) 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) continue
|
|
if (wantGroup && !jid.endsWith('@g.us')) continue
|
|
if (candidates.some((peerJid) => jid === 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 ? t('delivery.wa_group') : t('delivery.wa_dm'))).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(
|
|
session?.id
|
|
|| session?.sessionId
|
|
|| exec?.agent?.sessionId
|
|
|| exec?.sessionId
|
|
|| '',
|
|
).trim()
|
|
}
|
|
|
|
/**
|
|
* Normalize a durable origin binding for result mirroring.
|
|
* @param {object|null|undefined} input
|
|
* @returns {{ kind: 'web' | 'im', sessionId?: string, peer?: object } | null}
|
|
*/
|
|
export function normalizeOrigin(input) {
|
|
if (!input || typeof input !== 'object') return null
|
|
const sessionId = typeof input.sessionId === 'string' ? input.sessionId.trim() : ''
|
|
const peerSrc = input.peer && typeof input.peer === 'object' ? input.peer : null
|
|
const peerBotId = typeof peerSrc?.botId === 'string' ? peerSrc.botId.trim() : ''
|
|
const conversationKey = typeof peerSrc?.conversationKey === 'string'
|
|
? peerSrc.conversationKey.trim()
|
|
: ''
|
|
const kindRaw = String(input.kind || '').trim().toLowerCase()
|
|
const kind = kindRaw === 'im' || peerBotId
|
|
? 'im'
|
|
: (kindRaw === 'web' || sessionId ? 'web' : '')
|
|
if (!kind) return null
|
|
if (!sessionId && !(kind === 'im' && peerBotId && conversationKey)) return null
|
|
const origin = { kind }
|
|
if (sessionId) origin.sessionId = sessionId
|
|
if (kind === 'im' && peerBotId && conversationKey) {
|
|
origin.peer = {
|
|
botId: peerBotId,
|
|
conversationKey,
|
|
...typeof peerSrc.conversationId === 'string' && peerSrc.conversationId.trim()
|
|
? { conversationId: peerSrc.conversationId.trim() }
|
|
: {},
|
|
...peerSrc.kind === 'group' || peerSrc.kind === 'direct'
|
|
? { kind: peerSrc.kind }
|
|
: {},
|
|
}
|
|
}
|
|
return origin
|
|
}
|
|
|
|
function slimPeer(peer) {
|
|
if (!peer || typeof peer !== 'object') return null
|
|
const botId = typeof peer.botId === 'string' ? peer.botId.trim() : ''
|
|
const conversationKey = typeof peer.conversationKey === 'string'
|
|
? peer.conversationKey.trim()
|
|
: ''
|
|
if (!botId || !conversationKey) return null
|
|
return {
|
|
botId,
|
|
conversationKey,
|
|
...typeof peer.conversationId === 'string' && peer.conversationId.trim()
|
|
? { conversationId: peer.conversationId.trim() }
|
|
: {},
|
|
...peer.kind === 'group' || peer.kind === 'direct' ? { kind: peer.kind } : {},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve origin for cron_create / sidebar create.
|
|
* Prefer an explicit origin; else capture the caller session (+ IM peer when present).
|
|
* @param {object} args
|
|
* @param {object} exec
|
|
* @param {{ dshIm?: object }} deps
|
|
*/
|
|
export async function resolveCreateOrigin(args = {}, exec = {}, deps = {}) {
|
|
const explicit = normalizeOrigin(args.origin)
|
|
if (explicit) return explicit
|
|
|
|
const sessionId = typeof args.origin_session_id === 'string'
|
|
? args.origin_session_id.trim()
|
|
: (typeof args.originSessionId === 'string' ? args.originSessionId.trim() : '')
|
|
const callerId = sessionId || callerSessionId(exec)
|
|
if (!callerId) return null
|
|
|
|
const dshIm = deps.dshIm
|
|
if (dshIm && typeof dshIm.resolveSessionPeer === 'function') {
|
|
try {
|
|
const peer = await dshIm.resolveSessionPeer(callerId)
|
|
const slim = slimPeer(peer)
|
|
if (slim) {
|
|
return normalizeOrigin({ kind: 'im', sessionId: callerId, peer: slim })
|
|
}
|
|
} catch {
|
|
// fall through to web origin
|
|
}
|
|
}
|
|
return normalizeOrigin({ kind: 'web', sessionId: callerId })
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
export async function resolveCreateDelivery(args = {}, exec = {}, deps = {}) {
|
|
const explicit = typeof args.delivery === 'string' ? args.delivery.trim().toLowerCase() : ''
|
|
const botId = typeof args.im_bot_id === 'string' ? args.im_bot_id.trim()
|
|
: (typeof args.imBotId === 'string' ? args.imBotId.trim() : '')
|
|
const targetId = typeof args.im_target_id === 'string' ? args.im_target_id.trim()
|
|
: (typeof args.imTargetId === 'string' ? args.imTargetId.trim() : '')
|
|
|
|
const dshIm = deps.dshIm
|
|
const peer = await resolveCallerPeer(exec, dshIm)
|
|
|
|
// IM peers cannot retarget to an arbitrary catalog entry (confused deputy).
|
|
if (peer?.botId) {
|
|
if (explicit === 'dsh') return normalizeDelivery({ kind: 'dsh' })
|
|
const ensured = await ensureImDeliveryForPeer(peer, dshIm)
|
|
if (ensured) return ensured
|
|
if (explicit === 'im' || (botId && targetId)) {
|
|
const error = new Error('IM sessions cannot pick a free im_bot_id/im_target_id; delivery is bound to this chat')
|
|
error.code = 'IM_TARGET_FORBIDDEN'
|
|
throw error
|
|
}
|
|
return normalizeDelivery({ kind: 'dsh' })
|
|
}
|
|
|
|
if (explicit === 'dsh') return normalizeDelivery({ kind: 'dsh' })
|
|
if (explicit === 'im' || (botId && targetId)) {
|
|
return normalizeDelivery({ kind: 'im', botId, targetId })
|
|
}
|
|
|
|
return normalizeDelivery({ kind: 'dsh' })
|
|
}
|
|
|
|
/**
|
|
* Resolve the IM peer for the tool-calling session, if any.
|
|
* @param {object} exec
|
|
* @param {object|undefined} dshIm
|
|
*/
|
|
export async function resolveCallerPeer(exec, dshIm) {
|
|
const sessionId = callerSessionId(exec)
|
|
if (!sessionId || !dshIm || typeof dshIm.resolveSessionPeer !== 'function') return null
|
|
try {
|
|
const peer = await dshIm.resolveSessionPeer(sessionId)
|
|
return peer?.botId ? peer : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Whether a scheduled job was created from this IM conversation.
|
|
* Web/sidebar callers are unrestricted; IM peers only manage their own jobs.
|
|
* @param {object|null|undefined} job
|
|
* @param {object} peer
|
|
*/
|
|
export function jobVisibleToPeer(job, peer) {
|
|
if (!job || !peer?.botId) return false
|
|
const botId = String(peer.botId).trim()
|
|
const conversationKey = String(peer.conversationKey || '').trim()
|
|
const conversationId = String(peer.conversationId || '').trim()
|
|
const origin = normalizeOrigin(job.origin)
|
|
if (origin?.kind !== 'im' || origin.peer?.botId !== botId) return false
|
|
if (conversationKey && origin.peer.conversationKey === conversationKey) return true
|
|
if (conversationId && origin.peer.conversationId === conversationId) return true
|
|
return false
|
|
}
|
|
|
|
const IM_MAX_CHARS = 3500
|
|
|
|
/**
|
|
* Shared body for IM delivery and session mirror (without @mention prefix).
|
|
* @param {object} job
|
|
* @param {string} [summary]
|
|
* @param {'zh' | 'en' | string} [locale]
|
|
*/
|
|
export function formatRunResultBody(job, summary, locale = DEFAULT_LOCALE) {
|
|
const name = job?.name || ''
|
|
const text = String(summary || '').trim() || t('delivery.empty_summary', locale, { name })
|
|
const clipped = text.length > IM_MAX_CHARS ? `${text.slice(0, IM_MAX_CHARS)}…` : text
|
|
return `${t('delivery.header', locale, { name })}\n${clipped}`
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
if (!delivery || delivery.kind !== 'im') return { sent: false, skipped: true }
|
|
const dshIm = deps.dshIm
|
|
if (!dshIm || typeof dshIm.send !== 'function') {
|
|
const error = new Error('ctx.dshIm unavailable — install dsh-im-ops for IM delivery')
|
|
error.code = 'IM_UNAVAILABLE'
|
|
throw error
|
|
}
|
|
const headerAndBody = formatRunResultBody(job, summary)
|
|
const mentionJid = typeof delivery.mentionJid === 'string' ? delivery.mentionJid.trim() : ''
|
|
let body = headerAndBody
|
|
const options = {}
|
|
if (mentionJid && /@(s\.whatsapp\.net|lid)$/i.test(mentionJid)) {
|
|
const token = mentionJid.slice(0, mentionJid.indexOf('@'))
|
|
body = `@${token}\n${headerAndBody}`
|
|
options.mentions = [mentionJid]
|
|
}
|
|
await dshIm.send(delivery.botId, delivery.targetId, body, options)
|
|
return { sent: true, mentioned: Boolean(options.mentions) }
|
|
}
|
|
|
|
/**
|
|
* Resolve which live/effective session should receive a mirrored run summary.
|
|
* Prefer the current IM conversation binding over a stale origin.sessionId.
|
|
* @param {object} job
|
|
* @param {{ dshIm?: object }} deps
|
|
* @returns {Promise<{ sessionId: string, via: string } | null>}
|
|
*/
|
|
export async function resolveMirrorSession(job, deps = {}) {
|
|
const dshIm = deps.dshIm
|
|
const origin = normalizeOrigin(job?.origin)
|
|
const delivery = job?.delivery
|
|
|
|
if (origin?.peer?.botId && origin.peer.conversationKey
|
|
&& dshIm && typeof dshIm.resolveConversationSession === 'function') {
|
|
try {
|
|
const hit = await dshIm.resolveConversationSession(
|
|
origin.peer.botId,
|
|
origin.peer.conversationKey,
|
|
)
|
|
const sessionId = typeof hit?.sessionId === 'string' ? hit.sessionId.trim() : ''
|
|
if (sessionId) return { sessionId, via: 'conversation' }
|
|
} catch {
|
|
// try target / origin fallbacks
|
|
}
|
|
}
|
|
|
|
if (delivery?.kind === 'im' && delivery.botId && delivery.targetId
|
|
&& dshIm && typeof dshIm.resolveTargetSession === 'function') {
|
|
try {
|
|
const hit = await dshIm.resolveTargetSession(delivery.botId, delivery.targetId)
|
|
const sessionId = typeof hit?.sessionId === 'string' ? hit.sessionId.trim() : ''
|
|
if (sessionId) return { sessionId, via: 'target' }
|
|
} catch {
|
|
// fall through
|
|
}
|
|
}
|
|
|
|
if (origin?.sessionId) return { sessionId: origin.sessionId, via: 'origin' }
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Append the run summary into the effective origin/IM session without waking a turn.
|
|
* Prefer a live agent; otherwise resume the persisted session (never create — that
|
|
* id already belongs to the origin chat).
|
|
* @param {object} job
|
|
* @param {string} [summary]
|
|
* @param {{
|
|
* dshIm?: object,
|
|
* getAgents?: () => object | undefined,
|
|
* pluginName?: string,
|
|
* newId?: () => string,
|
|
* }} deps
|
|
*/
|
|
export async function mirrorRunToSession(job, summary, deps = {}) {
|
|
const target = await resolveMirrorSession(job, deps)
|
|
if (!target?.sessionId) {
|
|
return { mirrored: false, skipped: true, reason: 'no_target' }
|
|
}
|
|
const agents = typeof deps.getAgents === 'function' ? deps.getAgents() : undefined
|
|
if (!agents) {
|
|
return {
|
|
mirrored: false,
|
|
skipped: true,
|
|
reason: 'agents_unavailable',
|
|
sessionId: target.sessionId,
|
|
via: target.via,
|
|
}
|
|
}
|
|
|
|
let agent = typeof agents.get === 'function' ? agents.get(target.sessionId) : null
|
|
let resumed = false
|
|
if (!agent && typeof agents.resume === 'function') {
|
|
try {
|
|
const handle = await agents.resume({ resumeSessionId: target.sessionId })
|
|
agent = handle?.agent || null
|
|
resumed = Boolean(agent)
|
|
} catch (error) {
|
|
return {
|
|
mirrored: false,
|
|
skipped: true,
|
|
reason: 'session_unavailable',
|
|
sessionId: target.sessionId,
|
|
via: target.via,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
}
|
|
}
|
|
}
|
|
if (!agent) {
|
|
return {
|
|
mirrored: false,
|
|
skipped: true,
|
|
reason: 'session_unavailable',
|
|
sessionId: target.sessionId,
|
|
via: target.via,
|
|
}
|
|
}
|
|
|
|
const text = formatRunResultBody(job, summary)
|
|
const safe = text.replaceAll('</system-reminder>', '<\\/system-reminder>')
|
|
const plugin = typeof deps.pluginName === 'string' && deps.pluginName.trim()
|
|
? deps.pluginName.trim()
|
|
: 'dsh-ops-cron'
|
|
const id = typeof deps.newId === 'function' ? deps.newId() : `cron-mirror-${Date.now()}`
|
|
// Keep role=user + user/message (assistant/message requires model source + open turn).
|
|
// Wrap as system-reminder so the next model turn does not treat cron output as user intent.
|
|
const message = {
|
|
id,
|
|
role: 'user',
|
|
content: [{ type: 'text', text: `<system-reminder>\n${safe}\n</system-reminder>` }],
|
|
source: { kind: 'plugin', plugin },
|
|
}
|
|
|
|
try {
|
|
if (typeof agent.session?.append === 'function') {
|
|
agent.session.append('user/message', message, { surfaceOp: 'append' })
|
|
return {
|
|
mirrored: true,
|
|
sessionId: target.sessionId,
|
|
via: target.via,
|
|
method: 'append',
|
|
resumed,
|
|
}
|
|
}
|
|
if (typeof agent.inject === 'function') {
|
|
agent.inject(message)
|
|
return {
|
|
mirrored: true,
|
|
sessionId: target.sessionId,
|
|
via: target.via,
|
|
method: 'inject',
|
|
resumed,
|
|
}
|
|
}
|
|
} catch (error) {
|
|
return {
|
|
mirrored: false,
|
|
skipped: true,
|
|
reason: 'mirror_failed',
|
|
sessionId: target.sessionId,
|
|
via: target.via,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
}
|
|
}
|
|
return {
|
|
mirrored: false,
|
|
skipped: true,
|
|
reason: 'no_append',
|
|
sessionId: target.sessionId,
|
|
via: target.via,
|
|
}
|
|
}
|