mirror of
https://github.com/hansjone/dsh-ops-cron.git
synced 2026-10-18 16:40:50 +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
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) }
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue