feat: add the dsh-superagent research prototype
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
/** Bounded thread message window loaded for admission assessments. */
|
||||
import type { Commands } from '../infra/commands.ts'
|
||||
import type { ContextMessage, TriggerContext } from './kind.ts'
|
||||
|
||||
/** Selects the first five thread messages plus five messages on either side of `sourceId`, recording omitted gap counts. */
|
||||
export function threadWindow(messages: ContextMessage[], sourceId: string) {
|
||||
const trigger = messages.findIndex(message => message.id === sourceId)
|
||||
const selected = messages.map((message, index) => ({ message, index }))
|
||||
.filter(({ index }) => index < 5 || (trigger >= 0 && Math.abs(index - trigger) <= 5))
|
||||
let previous = -1
|
||||
const entries = selected.map(({ message, index }) => {
|
||||
const omitted_before = index - previous - 1
|
||||
previous = index
|
||||
return { message, position: index + 1, omitted_before }
|
||||
})
|
||||
return { entries, source_found: trigger >= 0, loaded_messages: messages.length, omitted_after: messages.length - previous - 1 }
|
||||
}
|
||||
/**
|
||||
* Reads up to `maxPages` of a trigger's thread and builds the bounded message window around the trigger; counts describe
|
||||
* only this loaded snapshot (never unread history), and `status` reports fallback modes such as `chat_neighbours`.
|
||||
*/
|
||||
export async function admissionContext(context: TriggerContext, commands: Commands, maxPages: number) {
|
||||
const messages: ContextMessage[] = []
|
||||
const seen = new Set<string>()
|
||||
const cursors = new Set<string>()
|
||||
let next: string | undefined
|
||||
let status: string | undefined
|
||||
let more = true
|
||||
for (let page = 0; more && page < maxPages; page++) {
|
||||
const result = await context.read(commands, 'thread', next)
|
||||
for (const message of result.messages) if (!seen.has(message.id)) {
|
||||
seen.add(message.id); messages.push(message)
|
||||
}
|
||||
status ??= result.status
|
||||
next = result.next
|
||||
more = next !== undefined
|
||||
if (next !== undefined && cursors.has(next)) throw new Error('Thread pagination repeated a cursor; context is incomplete')
|
||||
if (next !== undefined) cursors.add(next)
|
||||
}
|
||||
return { messages, window: threadWindow(messages, context.messageId), complete: !more, next: more ? next ?? null : null,
|
||||
...status ? { status } : {} }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/** Feishu outbound channel: posts threaded bot replies and constructs chat deep links. */
|
||||
import type { OutboundMessage } from '../core/index.ts'
|
||||
import { CliError } from '../infra/commands.ts'
|
||||
import { checkedImText } from '../infra/text.ts'
|
||||
import type { SourceContext } from './host.ts'
|
||||
import type { ChannelLink, ChannelLinks } from './kind.ts'
|
||||
import { feishuTriggerId } from './feishu-trigger.ts'
|
||||
|
||||
/** Feishu or Lark AppLink that opens a chat; used when the message's own link was not delivered with the event. */
|
||||
export const feishuChatLink = (brand: 'feishu' | 'lark', chatId: string): string =>
|
||||
`https://applink.${brand === 'lark' ? 'larksuite.com' : 'feishu.cn'}/client/chat/open?openChatId=${encodeURIComponent(chatId)}`
|
||||
/**
|
||||
* True when Feishu permanently rejects a reply because the target message was recalled, withdrawn, or not found.
|
||||
* Only a Feishu response carrying one of these error codes means the target message is gone; timeouts or crashes do not.
|
||||
*/
|
||||
const gone = (error: unknown): boolean => error instanceof CliError && error.code !== undefined && (error.code === 230011
|
||||
|| /withdrawn/i.test(error.message) || (Math.floor(error.code / 100) === 2300 && /not found/i.test(error.message)))
|
||||
/** Replies in the topic of the target message; Feishu keeps at most one reply per idempotency key (the outgoing message's `message.id`). */
|
||||
export async function sendFeishu(host: Pick<SourceContext, 'commands'>, message: OutboundMessage): Promise<'sent' | 'gone'> {
|
||||
const messageId = message.channel.address.messageId
|
||||
if (!messageId) throw new Error('Missing Feishu reply target')
|
||||
const text = checkedImText(message.text)
|
||||
const reply = ['im', '+messages-reply', '--message-id', messageId, '--markdown', text, '--reply-in-thread', '--idempotency-key', message.id]
|
||||
try { await host.commands.lark(reply) }
|
||||
catch (error) {
|
||||
if (gone(error)) return 'gone'
|
||||
throw error
|
||||
}
|
||||
return 'sent'
|
||||
}
|
||||
/** Fallback chat link on the bot's brand; the message's own deep link on its Trigger is preferred when available. */
|
||||
export const feishuLink = (address: Record<string, string>, links: ChannelLinks): ChannelLink => ({
|
||||
url: feishuChatLink(links.feishu?.brand ?? 'feishu', address.chatId ?? ''), label: { zh: '飞书消息', en: 'Feishu message' },
|
||||
...address.messageId ? { triggerId: feishuTriggerId(address.messageId) } : {},
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
/** Downloads Feishu message images and commits them as model-visible attachments during assessment. */
|
||||
import { mkdtemp, readFile, realpath, rm, stat } from 'node:fs/promises'
|
||||
import { dirname, extname, join } from 'node:path'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment'
|
||||
import type {} from '@deepseek-ai/dsh-llm'
|
||||
import { z } from 'zod'
|
||||
import type { Commands } from '../infra/commands.ts'
|
||||
import type { FeishuRef } from './feishu-read.ts'
|
||||
|
||||
/** Extracts Feishu image keys from message text so keys remain paired with the originating message; arbitrary remote IDs are not accepted. */
|
||||
export function admissionImageKeys(text: string): string[] {
|
||||
return [...new Set(text.match(/\bimg_[a-zA-Z0-9_-]+\b/g) ?? [])]
|
||||
}
|
||||
|
||||
export interface AdmissionImage {
|
||||
source_message_id: string
|
||||
image_key: string
|
||||
image: ImageAttachmentRef
|
||||
}
|
||||
|
||||
export async function readAdmissionImage(agent: Agent, commands: Commands, ref: FeishuRef, text: string, imageKey: string, stateDir: string, signal: AbortSignal): Promise<AdmissionImage> {
|
||||
if (!admissionImageKeys(text).includes(imageKey)) throw new Error('Choose an image key from this source message')
|
||||
const messageId = z.string().regex(/^om_[a-zA-Z0-9_-]+$/).parse(ref.messageId)
|
||||
const attachments = agent.ctx.get('attachments')
|
||||
if (!attachments) throw new Error('Image reading is unavailable: no attachment service is mounted')
|
||||
const route = agent.session.requestHeader()?.config
|
||||
const provider = route?.provider ?? agent.options.provider
|
||||
const model = route?.model ?? agent.options.model
|
||||
const llm = agent.ctx.get('llm')
|
||||
if (!llm || !provider || !model) throw new Error('Image reading is unavailable: the current model route is unresolved')
|
||||
const info = await llm.resolveModelInfo(provider, model, signal)
|
||||
if (!info.inputModalities?.includes('image')) throw new Error(`Model ${model} does not support image input; the image has not been inspected`)
|
||||
signal.throwIfAborted()
|
||||
const directory = await mkdtemp(join(stateDir, 'admission-image-'))
|
||||
try {
|
||||
const result = z.object({ saved_path: z.string() }).parse(await commands.withSignal(signal).lark([
|
||||
'im', '+messages-resources-download', '--message-id', messageId, '--file-key', imageKey, '--type', 'image', '--output', './image',
|
||||
], directory))
|
||||
const path = await realpath(result.saved_path)
|
||||
if (dirname(path) !== await realpath(directory)) throw new Error('Image download returned a path outside its temporary directory')
|
||||
const mediaTypes: Record<string, ImageMediaType> = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif' }
|
||||
const mediaType = mediaTypes[extname(path).toLowerCase()]
|
||||
if (!mediaType) throw new Error('The downloaded resource is not a supported PNG/JPEG/WebP/GIF image')
|
||||
const file = await stat(path)
|
||||
if (!file.isFile() || file.size > Math.min(attachments.imageLimits.maxImageBytes, attachments.imageLimits.maxMessageImageBytes)) throw new Error('The image exceeds the deployment byte limit or is not a regular file')
|
||||
const data = await readFile(path, { signal })
|
||||
signal.throwIfAborted()
|
||||
const image = await attachments.saveImage({ data, mediaType, name: `${imageKey}${extname(path)}` })
|
||||
signal.throwIfAborted()
|
||||
return { source_message_id: messageId, image_key: imageKey, image }
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
/** Feishu background loops: live event stream consumer, chat/topic history backfill, chat name refresh, and subscribed thread polling. */
|
||||
import { spawn } from 'node:child_process'
|
||||
import { createInterface } from 'node:readline'
|
||||
import { setTimeout as delay } from 'node:timers/promises'
|
||||
import { z } from 'zod'
|
||||
import { threadKey, type Thread, type Trigger } from '../core/index.ts'
|
||||
import { feishuChatLink } from './feishu-channel.ts'
|
||||
import type { FeishuBot } from '../deployment.ts'
|
||||
import { validItems, type Tally } from '../infra/commands.ts'
|
||||
import { readFeishu, type FeishuRef } from './feishu-read.ts'
|
||||
import { feishuRef, feishuTime, feishuTrigger, HistoricalMessageSchema, historyEvent, HistoryPageSchema, SOURCE, topicEvent }
|
||||
from './feishu-trigger.ts'
|
||||
import { health, type SourceContext } from './host.ts'
|
||||
import { KV } from '../kv.ts'
|
||||
import { cursorAt, POLL_HORIZON_MS, polled, pollThreads as pollEach } from './poll.ts'
|
||||
import type { FeishuConfig } from './feishu.ts'
|
||||
|
||||
export const runFeishu = (ctx: SourceContext, signal: AbortSignal): Promise<unknown> =>
|
||||
Promise.allSettled([watch, backfillLoop, chatNamesLoop].map(loop => loop(ctx, signal)))
|
||||
const enabled = (ctx: SourceContext): FeishuConfig | undefined => ctx.config<FeishuConfig>(SOURCE)
|
||||
const bot = (ctx: SourceContext): FeishuBot => {
|
||||
const resolved = ctx.deployment.feishu
|
||||
if (!resolved) throw new Error('The Feishu Source has no working bot CLI')
|
||||
return resolved
|
||||
}
|
||||
async function receive(ctx: SourceContext, trigger: Trigger): Promise<void> {
|
||||
await ctx.receive(trigger)
|
||||
const chatId = String(trigger.meta.chatId ?? '')
|
||||
if (!chatId) return
|
||||
const name = ctx.state.get(KV.sources.labels(SOURCE))?.[chatId] ?? chatId
|
||||
const chat = trigger.meta.direct === true ? { value: { zh: '私聊', en: 'Direct message' } } : { value: name }
|
||||
for (const ref of trigger.threads) ctx.threads.describe(threadKey(ref), { url: chatLink(ctx, chatId), facts: { chat } })
|
||||
}
|
||||
const chatLink = (ctx: SourceContext, chatId: string): string => feishuChatLink(ctx.deployment.feishu?.brand ?? 'feishu', chatId)
|
||||
function available(ctx: SourceContext): boolean {
|
||||
if (ctx.configured() && ctx.deployment.feishu) return true
|
||||
const waiting = health('paused', ctx.configured() ? 'feishuCliUnset' : 'setupPending')
|
||||
ctx.report('feishu', ctx.problem ? { state: 'paused', detail: ctx.problem.detail } : waiting)
|
||||
return false
|
||||
}
|
||||
|
||||
/** Runs the live event consumer only while the Source is enabled; disabling it stops the consumer within five seconds. */
|
||||
async function watch(ctx: SourceContext, signal: AbortSignal): Promise<void> {
|
||||
await ctx.ready
|
||||
// Setup restarts dsh-superagent once it resolves the bot; until then nothing runs.
|
||||
if (!available(ctx)) return
|
||||
let retry: number | undefined
|
||||
while (!signal.aborted) {
|
||||
if (!enabled(ctx)) {
|
||||
ctx.report('feishu', health('paused', 'sourceOff'))
|
||||
await delay(5000, undefined, { signal }).catch(() => {})
|
||||
continue
|
||||
}
|
||||
const running = new AbortController()
|
||||
const check = setInterval(() => { if (!enabled(ctx)) running.abort() }, 5000)
|
||||
const stop = () => running.abort()
|
||||
signal.addEventListener('abort', stop, { once: true })
|
||||
const began = Date.now()
|
||||
try { await consume(ctx, running.signal) }
|
||||
catch (error) { if (!running.signal.aborted) ctx.report('feishu', { state: 'error', detail: String(error) }) }
|
||||
finally { clearInterval(check); signal.removeEventListener('abort', stop) }
|
||||
if (running.signal.aborted) continue
|
||||
retry = consumerRetry(retry, Date.now() - began)
|
||||
await delay(retry, undefined, { signal }).catch(() => {})
|
||||
}
|
||||
}
|
||||
/** Wait before restarting the event consumer: 1s after a run that stayed up for a minute, otherwise exponential backoff up to 60s. */
|
||||
export const consumerRetry = (previous: number | undefined, ranMs: number): number =>
|
||||
previous === undefined || ranMs >= 60_000 ? 1000 : Math.min(previous * 2, 60_000)
|
||||
async function consume(ctx: SourceContext, signal: AbortSignal): Promise<void> {
|
||||
const child = spawn(bot(ctx).cli, ['event', 'consume', 'im.message.receive_v1', '--as', 'bot'],
|
||||
{ stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, LARKSUITE_CLI_NO_UPDATE_NOTIFIER: '1',
|
||||
LARKSUITE_CLI_NO_SKILLS_NOTIFIER: '1' } })
|
||||
const stdout = createInterface({ input: child.stdout }); const stderr = createInterface({ input: child.stderr })
|
||||
let chain = Promise.resolve(); let diagnostic = ''; let ready = false
|
||||
const stop = (): void => { child.stdin.end(); child.kill('SIGTERM') }
|
||||
signal.addEventListener('abort', stop, { once: true })
|
||||
const timeout = setTimeout(() => { diagnostic = 'Feishu event consumer did not become ready within 45 seconds'; stop() }, 45_000)
|
||||
stderr.on('line', (line: string) => {
|
||||
diagnostic = line.slice(0, 4000)
|
||||
if (line.startsWith('[event] ready')) {
|
||||
ready = true; clearTimeout(timeout)
|
||||
ctx.report('feishu', { state: 'ready', detail: 'Listening for bot events', at: new Date().toISOString() })
|
||||
} else if (/WARN|drop|error/.test(line)) ctx.report('feishu', { state: 'error', detail: diagnostic })
|
||||
})
|
||||
stdout.on('line', (line: string) => {
|
||||
chain = chain.then(async () => {
|
||||
if (line.length > 1_048_576) throw new Error('Feishu event exceeds 1 MiB')
|
||||
const trigger = feishuTrigger(JSON.parse(line), bot(ctx).botId, bot(ctx).appId)
|
||||
// Events already read when the Source is disabled are dropped; the consumer stops shortly after.
|
||||
if (!trigger || !enabled(ctx)) return
|
||||
const chats = ctx.state.get(KV.feishu.chats) ?? {}
|
||||
chats[String(trigger.meta.chatId)] = trigger.meta.direct === true
|
||||
ctx.state.set(KV.feishu.chats, chats)
|
||||
await receive(ctx, trigger)
|
||||
}).catch(error => { ctx.report('feishu', { state: 'error', detail: String(error) }) })
|
||||
})
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
child.once('error', reject)
|
||||
const exited = (code: number | null) => new Error(`Feishu event consumer exited (${code}, ready=${ready}): ${diagnostic}`)
|
||||
child.once('close', (code) => signal.aborted ? resolve() : reject(exited(code)))
|
||||
})
|
||||
} finally { clearTimeout(timeout); signal.removeEventListener('abort', stop); stdout.close(); stderr.close(); await chain }
|
||||
}
|
||||
|
||||
/** Refreshes the map of joined chats (`chat_id -> name`); throws on failure so the previously stored list remains intact. */
|
||||
export async function refreshChatNames(ctx: SourceContext, signal?: AbortSignal): Promise<Record<string, string>> {
|
||||
const commands = ctx.commands.withSignal(signal)
|
||||
const result = z.looseObject({ chats: z.array(z.looseObject({ chat_id: z.string(), name: z.string().nullish() })).optional() })
|
||||
.parse(await commands.lark(['im', '+chat-list', '--page-all', '--page-size', '100']))
|
||||
const names = Object.fromEntries((result.chats ?? []).filter(chat => chat.name).map(chat => [chat.chat_id, chat.name!]))
|
||||
ctx.state.set(KV.sources.labels(SOURCE), names)
|
||||
return names
|
||||
}
|
||||
/** Rereads the chat list every minute independently of history backfill (whose rounds can take much longer), so newly joined chats appear within a minute. */
|
||||
async function chatNamesLoop(ctx: SourceContext, signal: AbortSignal): Promise<void> {
|
||||
await ctx.ready
|
||||
if (!available(ctx)) return
|
||||
while (!signal.aborted) {
|
||||
if (enabled(ctx)) {
|
||||
try { await refreshChatNames(ctx, signal) }
|
||||
catch { /* Names are a display aid; the stored list stays until a later read succeeds. */ }
|
||||
}
|
||||
await delay(60_000, undefined, { signal }).catch(() => {})
|
||||
}
|
||||
}
|
||||
async function backfillLoop(ctx: SourceContext, signal: AbortSignal): Promise<void> {
|
||||
await ctx.ready
|
||||
if (!available(ctx)) { ctx.report('history', health('paused', 'feishuBotMissing')); return }
|
||||
while (!signal.aborted) {
|
||||
const config = enabled(ctx)
|
||||
if (!config) {
|
||||
ctx.report('history', health('paused', 'sourceOff'))
|
||||
await delay(60_000, undefined, { signal }).catch(() => {})
|
||||
continue
|
||||
}
|
||||
const seen = ctx.state.get(KV.feishu.chats) ?? {}
|
||||
const known = Object.entries(seen).filter(([, direct]) => direct && config.directMessages)
|
||||
const chats = { ...Object.fromEntries(config.chats.map(chat => [chat.chatId, false])), ...Object.fromEntries(known) }
|
||||
const errors: string[] = []
|
||||
const tally = { skipped: 0 }
|
||||
for (const [chatId, direct] of Object.entries(chats)) {
|
||||
if (signal.aborted) break
|
||||
const started = new Date().toISOString()
|
||||
const cursorKey = KV.feishu.history(chatId)
|
||||
const since = ctx.state.get(cursorKey)
|
||||
if (since === undefined) { ctx.state.set(cursorKey, started); continue }
|
||||
try {
|
||||
await backfillChat(ctx, chatId, direct, since, started, signal, tally)
|
||||
if (!signal.aborted) ctx.state.set(cursorKey, cursorAt(started))
|
||||
} catch (error) { if (!signal.aborted) errors.push(`${chatId}: ${String(error)}`) }
|
||||
}
|
||||
await pollThreads(ctx, signal, tally)
|
||||
const skipped = tally.skipped ? `; ${tally.skipped} unreadable messages skipped` : ''
|
||||
if (!signal.aborted) ctx.report('history', errors.length
|
||||
? { state: 'error', detail: errors.join('\n') }
|
||||
: { state: 'ready', detail: `Configured groups and known direct chats recovered${skipped}`, at: new Date().toISOString() })
|
||||
await delay(60_000, undefined, { signal }).catch(() => {})
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Backfills chat messages and active topic replies between `since` and `until` before the caller advances the chat cursor.
|
||||
* Topics are stored as `{ id: lastSeenAt }`, and unsubscribed topics older than the polling horizon are pruned.
|
||||
*/
|
||||
export async function backfillChat(ctx: SourceContext, chatId: string, direct: boolean, since: string, until: string,
|
||||
signal: AbortSignal, tally: Tally = { skipped: 0 }): Promise<void> {
|
||||
const commands = ctx.commands.withSignal(signal)
|
||||
const topicsKey = KV.feishu.topics(chatId)
|
||||
const topics = { ...ctx.state.get(topicsKey) }
|
||||
const scan = async (kind: 'chat' | 'thread', id: string): Promise<void> => {
|
||||
let page: string | undefined
|
||||
const pages = new Set<string>()
|
||||
do {
|
||||
if (signal.aborted) throw signal.reason
|
||||
const seconds = (time: string) => String(Math.floor(Date.parse(time) / 1000))
|
||||
const parameters = { container_id_type: kind, container_id: id, start_time: seconds(since), end_time: seconds(until),
|
||||
sort_type: 'ByCreateTimeAsc', page_size: 50, ...(page ? { page_token: page } : {}) }
|
||||
const request = ['api', 'GET', '/open-apis/im/v1/messages', '--params', JSON.stringify(parameters)]
|
||||
const result = HistoryPageSchema.parse(await commands.lark(request))
|
||||
for (const message of validItems(HistoricalMessageSchema, result.items, tally)) {
|
||||
const outside = message.chat_id !== chatId || (kind === 'thread' && message.thread_id !== id)
|
||||
if (outside) throw new Error('Feishu history returned a message outside the requested chat or thread')
|
||||
if (message.deleted) continue
|
||||
const at = feishuTime(message.create_time)
|
||||
if (message.thread_id && !(Date.parse(topics[message.thread_id] ?? '') >= Date.parse(at))) topics[message.thread_id] = at
|
||||
const trigger = feishuTrigger(historyEvent(message, chatId, direct), bot(ctx).botId, bot(ctx).appId)
|
||||
if (trigger) await receive(ctx, trigger)
|
||||
}
|
||||
const stuck = result.has_more && (!result.page_token || pages.has(result.page_token))
|
||||
if (stuck) throw new Error('Feishu history pagination did not advance')
|
||||
page = result.has_more ? result.page_token : undefined
|
||||
if (page) pages.add(page)
|
||||
} while (page)
|
||||
}
|
||||
await scan('chat', chatId)
|
||||
// A quiet topic is forgotten unless a subscribed Thread that is still polled follows it.
|
||||
const followed = new Set(ctx.threads.subscribed(SOURCE).filter(thread => polled(ctx, thread))
|
||||
.map(thread => String(thread.meta.threadId ?? ctx.triggers.latest(thread.key)?.meta.threadId ?? '')))
|
||||
for (const [id, seen] of Object.entries(topics)) {
|
||||
if (!followed.has(id) && Date.parse(until) - Date.parse(seen) >= POLL_HORIZON_MS) delete topics[id]
|
||||
}
|
||||
ctx.state.set(topicsKey, topics)
|
||||
for (const id of Object.keys(topics)) await scan('thread', id)
|
||||
ctx.state.set(topicsKey, topics)
|
||||
}
|
||||
|
||||
/** Resolves the retained source message reference for polling a subscribed thread. */
|
||||
export function threadRef(ctx: SourceContext, thread: Thread): FeishuRef | undefined {
|
||||
const meta = thread.meta as { sourceTriggerId?: string; chatId?: string; messageId?: string }
|
||||
if (meta.chatId && meta.messageId && meta.sourceTriggerId) {
|
||||
return { triggerId: meta.sourceTriggerId, chatId: meta.chatId, messageId: meta.messageId }
|
||||
}
|
||||
const trigger = ctx.triggers.latest(thread.key)
|
||||
return trigger ? feishuRef(trigger) : undefined
|
||||
}
|
||||
/** Polls subscribed Feishu threads within the polling horizon so replies missed by the live stream become triggers. */
|
||||
export async function pollThreads(ctx: SourceContext, signal?: AbortSignal, tally?: Tally): Promise<void> {
|
||||
await pollEach(ctx, SOURCE, () => !!signal?.aborted, thread => pollThread(ctx, thread, signal, tally))
|
||||
}
|
||||
async function pollThread(ctx: SourceContext, thread: Thread, signal?: AbortSignal, tally?: Tally): Promise<void> {
|
||||
const ref = threadRef(ctx, thread)
|
||||
if (!ref) throw new Error('Subscribed thread has no retained source message')
|
||||
const config = enabled(ctx)
|
||||
// The Source was turned off; the next round polls again.
|
||||
if (!config) return
|
||||
const commands = ctx.commands.withSignal(signal)
|
||||
const pageSize = config.readPageSize
|
||||
const checkpoint = KV.feishu.seen(thread.key)
|
||||
const known = new Set(ctx.state.get(checkpoint) ?? [])
|
||||
const initialized = ctx.state.get(KV.feishu.initialized(thread.key))
|
||||
if (!initialized) {
|
||||
// Mark root and parent messages as seen on first poll so historical context is not ingested as new triggers.
|
||||
for (const section of ['root', 'parent']) {
|
||||
const result = await readFeishu(commands, ref, `feishu/${ref.triggerId}/${section}`, pageSize)
|
||||
if ('message' in result && result.message) known.add(result.message.message_id)
|
||||
}
|
||||
ctx.state.set(checkpoint, [...known])
|
||||
}
|
||||
const direct = ctx.state.get(KV.feishu.chats)?.[ref.chatId] === true
|
||||
const since = Date.parse(thread.createdAt)
|
||||
const root = thread.ref.id.slice(thread.ref.id.indexOf(':') + 1)
|
||||
let resource: string | null = `feishu/${ref.triggerId}/thread`
|
||||
while (resource) {
|
||||
if (signal?.aborted) return
|
||||
const result = await readFeishu(commands, ref, resource, pageSize, 'desc')
|
||||
if (!('messages' in result)) throw new Error('Expected Feishu thread messages')
|
||||
if (tally && 'skipped' in result) tally.skipped += result.skipped ?? 0
|
||||
if ('thread_id' in result && result.thread_id && thread.meta.threadId !== result.thread_id) {
|
||||
ctx.threads.patch(thread.key, { threadId: result.thread_id })
|
||||
}
|
||||
const encounteredKnown = result.messages.some(message => known.has(message.message_id))
|
||||
const { botId, appId } = bot(ctx)
|
||||
for (const message of result.messages.filter(item => !known.has(item.message_id) && !item.deleted).reverse()) {
|
||||
if (signal?.aborted) return
|
||||
const trigger = feishuTrigger(topicEvent(message, ref.chatId, direct, root), botId, appId)
|
||||
// Ignore messages older than the thread subscription on initial poll.
|
||||
if (!trigger || (!initialized && Date.parse(trigger.at) < since)) continue
|
||||
await receive(ctx, trigger)
|
||||
}
|
||||
result.messages.forEach(message => known.add(message.message_id))
|
||||
ctx.state.set(checkpoint, [...known])
|
||||
if (initialized && encounteredKnown) break
|
||||
resource = result.next_resource
|
||||
}
|
||||
ctx.state.set(KV.feishu.initialized(thread.key), true)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
/** Trigger-scoped Feishu context reader; retrieved messages are returned for model inspection without entering intake. */
|
||||
import { z } from 'zod'
|
||||
import type { Trigger } from '../core/index.ts'
|
||||
import { validItems, type Commands } from '../infra/commands.ts'
|
||||
import { admissionImageKeys, readAdmissionImage } from './feishu-image.ts'
|
||||
import { feishuRef, feishuTime, HistoricalMessageSchema, HistoryPageSchema, historyText, withMentionNames, type HistoricalMessage }
|
||||
from './feishu-trigger.ts'
|
||||
import type { ContextMessage, ThreadContext } from './kind.ts'
|
||||
|
||||
/** Retained source message reference anchoring context reads; `at` is the timestamp around which `chatAround` queries. */
|
||||
export interface FeishuRef { triggerId: string; chatId: string; messageId: string; at?: string }
|
||||
const messageId = z.string().regex(/^om_[a-zA-Z0-9_-]+$/)
|
||||
const sourceSchema = z.object({
|
||||
message_id: messageId, chat_id: z.string(), root_id: messageId.optional(), parent_id: messageId.optional(),
|
||||
thread_id: z.string().regex(/^omt?_[a-zA-Z0-9_-]+$/).optional(), deleted: z.boolean().optional(),
|
||||
})
|
||||
const messageSchema = z.object({
|
||||
message_id: messageId, chat_id: z.string(), content: z.string().optional(), msg_type: z.string(),
|
||||
sender: z.object({ id: z.string(), name: z.string().optional(), open_bot_id: z.string().optional(), sender_type: z.string() }),
|
||||
create_time: z.string(), update_time: z.string().optional(), updated: z.boolean().optional(), deleted: z.boolean().optional(),
|
||||
mentions: z.array(z.object({ id: z.string(), name: z.string().optional(), key: z.string().optional() })).optional(),
|
||||
message_app_link: z.string().optional(), thread_id: z.string().optional(), root_id: z.string().optional(), parent_id: z.string().optional(),
|
||||
})
|
||||
export type FeishuMessage = z.infer<typeof messageSchema>
|
||||
/** A topic page; messages are validated individually so an unreadable item is skipped alone and counted. */
|
||||
const threadSchema = z.object({ messages: z.array(z.unknown()), has_more: z.boolean(), page_token: z.string().optional(),
|
||||
thread_id: z.string() })
|
||||
|
||||
/** Reads the CLI-provided AppLink for the source message alone, without retaining expanded thread content. */
|
||||
export async function readFeishuLink(commands: Commands, ref: FeishuRef): Promise<string> {
|
||||
const id = messageId.parse(ref.messageId)
|
||||
const result = z.object({ messages: z.array(z.object({ message_id: messageId, chat_id: z.string(), message_app_link: z.url().optional(), deleted: z.boolean().optional() })) })
|
||||
.parse(await commands.lark(['im', '+messages-mget', '--message-ids', id, '--no-reactions']))
|
||||
const message = result.messages.find(item => item.message_id === id)
|
||||
if (!message || message.deleted || message.chat_id !== ref.chatId || !message.message_app_link) throw new Error('Source message link unavailable')
|
||||
return message.message_app_link
|
||||
}
|
||||
|
||||
/** Resource references are anchored to an accepted trigger instead of arbitrary chat or message IDs. */
|
||||
export function feishuReferences(ref: FeishuRef) {
|
||||
const read = (section: string) => `feishu/${ref.triggerId}/${section}`
|
||||
return { skill: 'dsh-superagent-feishu', message: read('message'), root: read('root'), parent: read('parent'), thread: read('thread'),
|
||||
around: read('around') }
|
||||
}
|
||||
|
||||
/** Validates a model-supplied resource string before any network request; only `thread` and `around` take pagination cursors. */
|
||||
export function feishuResource(resource: string) {
|
||||
const match = /^feishu\/([^/]+)\/(message|root|parent|thread|around)(?:\/([^/]+))?$/.exec(resource)
|
||||
if (!match || (match[3] && match[2] !== 'thread' && match[2] !== 'around')) {
|
||||
throw new Error('Use feishu/<trigger-id>/message, /root, /parent, /thread or /around; follow returned next_resource for another page')
|
||||
}
|
||||
return { triggerId: match[1]!, section: match[2]!, cursor: match[3] ? decodeURIComponent(match[3]) : undefined }
|
||||
}
|
||||
async function sourceOf(commands: Commands, ref: FeishuRef) {
|
||||
const id = messageId.parse(ref.messageId)
|
||||
// Use the raw mget endpoint to read parent/root IDs without triggering CLI thread expansion.
|
||||
const params = JSON.stringify({ message_ids: [id] })
|
||||
const listed = await commands.lark(['api', 'GET', '/open-apis/im/v1/messages/mget', '--params', params])
|
||||
const sourceResult = z.object({ items: z.array(sourceSchema) }).parse(listed)
|
||||
const source = sourceResult.items.find(message => message.message_id === id)
|
||||
if (!source || source.deleted) throw new Error('The source Feishu message is unavailable or deleted; retained trigger text is still readable')
|
||||
if (source.chat_id !== ref.chatId) throw new Error('Feishu source chat does not match the accepted trigger')
|
||||
return source
|
||||
}
|
||||
|
||||
export interface FeishuPage<M> { status: string; thread_id?: string; messages: M[]; next_resource: string | null; skipped?: number }
|
||||
export interface FeishuOne<M> { status: string; message: M | null }
|
||||
/** Fetches one message or a bounded topic page of a Trigger's source message with explicit pagination continuation. */
|
||||
export async function readFeishu(commands: Commands, ref: FeishuRef, resource: string, pageSize: number,
|
||||
order: 'asc' | 'desc' = 'asc'): Promise<FeishuPage<FeishuMessage> | FeishuOne<FeishuMessage>> {
|
||||
const request = feishuResource(resource)
|
||||
if (request.triggerId !== ref.triggerId) throw new Error('A Feishu source trigger is required')
|
||||
if (request.section === 'around') throw new Error('The chat around a message is read with feishuRead')
|
||||
const source = await sourceOf(commands, ref)
|
||||
if (request.section === 'thread') {
|
||||
if (!source.thread_id) return { status: 'no_thread', messages: [], next_resource: null }
|
||||
const args = ['im', '+threads-messages-list', '--thread', source.thread_id, '--order', order, '--page-size', String(pageSize), '--no-reactions']
|
||||
if (request.cursor) args.push('--page-token', request.cursor)
|
||||
const result = threadSchema.parse(await commands.lark(args))
|
||||
const tally = { skipped: 0 }
|
||||
const messages = validItems(messageSchema, result.messages, tally)
|
||||
const outside = messages.some(message => message.chat_id !== source.chat_id
|
||||
|| (message.thread_id && message.thread_id !== source.thread_id))
|
||||
if (result.thread_id !== source.thread_id || outside) throw new Error('Feishu returned messages outside the source thread')
|
||||
if (result.has_more && (!result.page_token || result.page_token === request.cursor)) throw new Error('Feishu thread pagination did not advance; history is incomplete')
|
||||
return { status: 'available', thread_id: result.thread_id, messages, ...tally.skipped ? { skipped: tally.skipped } : {},
|
||||
next_resource: result.has_more ? `feishu/${ref.triggerId}/thread/${encodeURIComponent(result.page_token!)}` : null }
|
||||
}
|
||||
const target = request.section === 'root' ? source.root_id : request.section === 'parent' ? source.parent_id : source.message_id
|
||||
if (!target) return { status: `no_${request.section}`, message: null }
|
||||
// The CLI renders rich text and merged forwards; automatic thread replies are omitted in favor of explicit pages.
|
||||
const result = z.object({ messages: z.array(messageSchema) }).parse(await commands.lark(['im', '+messages-mget', '--message-ids', target, '--no-reactions']))
|
||||
const message = result.messages.find(message => message.message_id === target)
|
||||
if (!message) throw new Error('The requested Feishu message is unavailable; do not infer its contents')
|
||||
if (message.chat_id !== source.chat_id) throw new Error('Feishu returned a message outside the source chat')
|
||||
return { status: message.deleted ? 'deleted' : 'available', message }
|
||||
}
|
||||
function isoTime(value: string): string {
|
||||
try { return feishuTime(value) } catch { return value }
|
||||
}
|
||||
export function feishuView(message: FeishuMessage): ContextMessage {
|
||||
return { id: message.message_id, from: message.sender.name || message.sender.id, at: isoTime(message.create_time),
|
||||
text: message.deleted ? '' : withMentionNames(message.content ?? '', message.mentions ?? []),
|
||||
...message.sender.sender_type !== 'user' ? { bot: true } : {}, ...message.deleted ? { deleted: true } : {},
|
||||
...message.parent_id ? { reply_to: message.parent_id } : {}, ...message.message_app_link ? { url: message.message_app_link } : {} }
|
||||
}
|
||||
/** Formats a chat history API message for model inspection; the history API identifies senders by `open_id` only. */
|
||||
function historyView(message: HistoricalMessage): ContextMessage {
|
||||
return { id: message.message_id, from: message.sender.id, at: isoTime(message.create_time),
|
||||
text: message.deleted ? '' : withMentionNames(historyText(message), message.mentions ?? []),
|
||||
...message.sender.sender_type !== 'user' ? { bot: true } : {}, ...message.deleted ? { deleted: true } : {},
|
||||
...message.parent_id ? { reply_to: message.parent_id } : {}, ...message.message_app_link ? { url: message.message_app_link } : {} }
|
||||
}
|
||||
/** Reads chat messages surrounding `ref.at` (five before and five after on initial read, then pages of ten older messages). */
|
||||
async function chatAround(commands: Commands, ref: FeishuRef, cursor: string | undefined, before = 5,
|
||||
after = 5): Promise<FeishuPage<ContextMessage>> {
|
||||
const time = Date.parse(ref.at ?? '')
|
||||
if (!Number.isFinite(time)) throw new Error('The source Feishu message has no time to read the chat around')
|
||||
const list = async (parameters: Record<string, unknown>) => {
|
||||
const params = JSON.stringify({ container_id_type: 'chat', container_id: ref.chatId, ...parameters })
|
||||
const page = HistoryPageSchema.parse(await commands.lark(['api', 'GET', '/open-apis/im/v1/messages', '--params', params]))
|
||||
return { ...page, items: validItems(HistoricalMessageSchema, page.items).filter(item => item.chat_id === ref.chatId) }
|
||||
}
|
||||
// Feishu takes whole seconds; the source message counts among the earlier items, so `before + 1` holds it and those before it.
|
||||
const earlier = await list({ end_time: String(Math.ceil(time / 1000)), sort_type: 'ByCreateTimeDesc', page_size: cursor ? 10 : before + 1,
|
||||
...cursor ? { page_token: cursor } : {} })
|
||||
const later = cursor || !after ? []
|
||||
: (await list({ start_time: String(Math.floor(time / 1000)), sort_type: 'ByCreateTimeAsc', page_size: after + 1 })).items
|
||||
const seen = new Set<string>()
|
||||
const messages = [...earlier.items.reverse(), ...later].filter(item => !seen.has(item.message_id) && seen.add(item.message_id))
|
||||
.map(historyView)
|
||||
const more = earlier.has_more && earlier.page_token && earlier.page_token !== cursor
|
||||
const next = more ? `feishu/${ref.triggerId}/around/${encodeURIComponent(earlier.page_token!)}` : null
|
||||
return { status: 'available', messages, next_resource: next }
|
||||
}
|
||||
export async function feishuRead(commands: Commands, ref: FeishuRef, resource: string,
|
||||
pageSize: number): Promise<FeishuPage<ContextMessage> | FeishuOne<ContextMessage>> {
|
||||
const request = feishuResource(resource)
|
||||
if (request.section === 'around') {
|
||||
if (request.triggerId !== ref.triggerId) throw new Error('A Feishu source trigger is required')
|
||||
await sourceOf(commands, ref)
|
||||
return chatAround(commands, ref, request.cursor)
|
||||
}
|
||||
const result = await readFeishu(commands, ref, resource, pageSize)
|
||||
if ('messages' in result) return { ...result, messages: result.messages.map(feishuView) }
|
||||
return { status: result.status, message: result.message ? feishuView(result.message) : null }
|
||||
}
|
||||
function sourceRef(trigger: Trigger): FeishuRef {
|
||||
const ref = feishuRef(trigger)
|
||||
if (!ref) throw new Error('A Feishu source trigger is required')
|
||||
return ref
|
||||
}
|
||||
export const feishuThreadContext: ThreadContext<{ readPageSize: number }> = {
|
||||
messageId: trigger => String(trigger.meta.messageId ?? ''),
|
||||
read: async (commands, trigger, part, cursor, config) => {
|
||||
const ref = sourceRef(trigger)
|
||||
const resource = `feishu/${trigger.id}/${part}${cursor ? '/' + encodeURIComponent(cursor) : ''}`
|
||||
const body = await feishuRead(commands, ref, resource, config.readPageSize)
|
||||
if (part === 'thread' && body.status === 'no_thread') {
|
||||
// Fall back to preceding chat messages when the trigger message does not belong to a topic thread.
|
||||
const around = await chatAround(commands, ref, undefined, 5, 0)
|
||||
return { messages: around.messages, body: { status: 'chat_neighbours', messages: around.messages }, status: 'chat_neighbours' }
|
||||
}
|
||||
const messages = 'messages' in body ? body.messages : body.message ? [body.message] : []
|
||||
const next = 'next_resource' in body && body.next_resource ? decodeURIComponent(body.next_resource.split('/').at(-1)!) : undefined
|
||||
return { messages, next, body: { ...body }, status: body.status }
|
||||
},
|
||||
images: admissionImageKeys,
|
||||
image: (agent, commands, trigger, message, key, stateDir, signal) =>
|
||||
readAdmissionImage(agent, commands, { ...sourceRef(trigger), messageId: message.id }, message.text, key, stateDir, signal),
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/** Normalizes Feishu live events, chat history items, and topic replies into domain Triggers. */
|
||||
import { z } from 'zod'
|
||||
import { key, type ChannelRef, type Trigger } from '../core/index.ts'
|
||||
import type { FeishuMessage, FeishuRef } from './feishu-read.ts'
|
||||
|
||||
export const SOURCE = 'feishu'
|
||||
/** Constructs a Feishu reply channel address; records `direct` explicitly because both group and direct chat IDs start with `oc_`. */
|
||||
export const feishuChannel = (chatId: string, messageId: string, direct = false): ChannelRef =>
|
||||
({ source: SOURCE, thread: `feishu:${chatId}`, address: { chatId, messageId, ...direct ? { direct: 'true' } : {} } })
|
||||
/** A message as the live event consumer prints it; the history and topic-page paths normalize what they read into this shape. */
|
||||
const EventSchema = z.object({ message_id: z.string(), chat_id: z.string(), chat_type: z.enum(['p2p', 'group']),
|
||||
sender_id: z.string(), sender_type: z.enum(['user', 'bot']), content: z.string(), create_time: z.string(),
|
||||
mentions: z.array(z.object({ id: z.string(), key: z.string().optional(), name: z.string().optional() })).default([]),
|
||||
root_id: z.string().optional(), parent_id: z.string().optional(), thread_id: z.string().optional(), message_app_link: z.url().optional() })
|
||||
export type FeishuEvent = z.infer<typeof EventSchema>
|
||||
export const feishuThreadId = (chatId: string, rootId: string | undefined, messageId: string): string => `${chatId}:${rootId ?? messageId}`
|
||||
/** Deterministic trigger ID for a Feishu message; every read path for this message produces the same ID. */
|
||||
export const feishuTriggerId = (messageId: string): string => key('feishu', messageId)
|
||||
export const withMentionNames = (text: string, mentions: { key?: string | undefined; name?: string | undefined }[]): string =>
|
||||
mentions.reduce((result, mention) => mention.key && mention.name ? result.replaceAll(mention.key, `@${mention.name}`) : result, text)
|
||||
/**
|
||||
* Parses a Feishu message timestamp into an ISO-8601 string: live events and the history API return epoch milliseconds as a
|
||||
* string, whereas topic/thread pages return a local timestamp string such as `2026-09-11 12:00`.
|
||||
*/
|
||||
export function feishuTime(value: string): string {
|
||||
const time = /^\d+$/.test(value) ? Number(value) : Date.parse(value)
|
||||
if (!Number.isFinite(time)) throw new Error(`Unreadable Feishu message time: ${value}`)
|
||||
return new Date(time).toISOString()
|
||||
}
|
||||
/** Converts a normalized Feishu event into a Trigger, returning undefined for messages sent by the bot itself. */
|
||||
export function feishuTrigger(input: unknown, botId: string, botAppId: string): Trigger | undefined {
|
||||
const event = EventSchema.parse(input)
|
||||
if (event.sender_id === botId || event.sender_id === botAppId) return
|
||||
const direct = event.chat_type === 'p2p'
|
||||
const mentioned = event.mentions.some(mention => mention.id === botId || mention.id === botAppId)
|
||||
const thread = feishuThreadId(event.chat_id, event.root_id, event.message_id)
|
||||
return { id: feishuTriggerId(event.message_id), source: SOURCE, threads: [{ source: SOURCE, id: thread }],
|
||||
actor: { source: SOURCE, id: event.sender_id }, human: event.sender_type === 'user',
|
||||
...(direct ? { addressed: 'direct' as const } : mentioned ? { addressed: 'mention' as const } : {}),
|
||||
at: feishuTime(event.create_time), text: withMentionNames(event.content, event.mentions),
|
||||
channel: feishuChannel(event.chat_id, event.message_id, direct), ...(event.message_app_link ? { url: event.message_app_link } : {}),
|
||||
meta: { chatId: event.chat_id, messageId: event.message_id, direct, mentioned, ...(event.root_id ? { rootId: event.root_id } : {}),
|
||||
...(event.parent_id ? { parentId: event.parent_id } : {}), ...(event.thread_id ? { threadId: event.thread_id } : {}) } }
|
||||
}
|
||||
export const feishuParent = (trigger: Trigger): string | undefined =>
|
||||
trigger.source === SOURCE && typeof trigger.meta.parentId === 'string' ? feishuTriggerId(trigger.meta.parentId) : undefined
|
||||
export function feishuRef(trigger: Trigger): FeishuRef | undefined {
|
||||
const meta = trigger.meta as { chatId?: string; messageId?: string }
|
||||
if (trigger.source !== SOURCE || !meta.chatId || !meta.messageId) return undefined
|
||||
return { triggerId: trigger.id, chatId: meta.chatId, messageId: meta.messageId, at: trigger.at }
|
||||
}
|
||||
|
||||
/** A message from the history API; a deleted message may have no body/content. */
|
||||
export const HistoricalMessageSchema = z.looseObject({
|
||||
message_id: z.string(), chat_id: z.string(), create_time: z.string(),
|
||||
root_id: z.string().optional(), parent_id: z.string().optional(), thread_id: z.string().optional(),
|
||||
deleted: z.boolean().optional(), message_app_link: z.string().optional(),
|
||||
sender: z.looseObject({ id: z.string().default(''), sender_type: z.string() }),
|
||||
body: z.looseObject({ content: z.string() }).optional(),
|
||||
mentions: z.array(z.looseObject({ id: z.string(), key: z.string().optional(), name: z.string().optional() })).optional(),
|
||||
})
|
||||
export type HistoricalMessage = z.infer<typeof HistoricalMessageSchema>
|
||||
/** One page of the history API; items are validated individually so an unreadable item is skipped alone. */
|
||||
export const HistoryPageSchema = z.looseObject({
|
||||
items: z.array(z.unknown()).default([]), has_more: z.boolean().default(false), page_token: z.string().optional(),
|
||||
})
|
||||
export function historyText(message: HistoricalMessage): string {
|
||||
let content: unknown
|
||||
try { content = JSON.parse(message.body?.content ?? '') }
|
||||
catch { content = message.body?.content ?? '' /* Feishu also returns plain-text system messages. */ }
|
||||
const plain = content && typeof content === 'object' && 'text' in content && typeof content.text === 'string' ? content.text : undefined
|
||||
return typeof content === 'string' ? content : plain ?? JSON.stringify(content)
|
||||
}
|
||||
export function historyEvent(message: HistoricalMessage, chatId: string, direct: boolean): unknown {
|
||||
return { message_id: message.message_id, chat_id: chatId, chat_type: direct ? 'p2p' : 'group', create_time: message.create_time,
|
||||
sender_id: message.sender.id, sender_type: message.sender.sender_type === 'user' ? 'user' : 'bot', content: historyText(message),
|
||||
mentions: message.mentions ?? [], root_id: message.root_id, parent_id: message.parent_id, thread_id: message.thread_id,
|
||||
message_app_link: message.message_app_link }
|
||||
}
|
||||
/**
|
||||
* Adapts a topic-page message into the live-event schema, falling back to `root` when Feishu omits `root_id` on replies;
|
||||
* an app's message carries its bot's `open_bot_id`, and a deleted message may have no content.
|
||||
*/
|
||||
export function topicEvent(message: FeishuMessage, chatId: string, direct: boolean, root: string): unknown {
|
||||
return { message_id: message.message_id, chat_id: chatId, chat_type: direct ? 'p2p' : 'group', create_time: message.create_time,
|
||||
sender_id: message.sender.open_bot_id ?? message.sender.id, sender_type: message.sender.sender_type === 'user' ? 'user' : 'bot',
|
||||
content: message.content ?? '', mentions: message.mentions ?? [], parent_id: message.parent_id, thread_id: message.thread_id,
|
||||
root_id: message.root_id ?? (message.message_id === root ? undefined : root), message_app_link: message.message_app_link }
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/** Feishu source kind definition, policy synchronization, and Cordis plugin entry. */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { z } from 'zod'
|
||||
import { FeishuBotSchema } from '../deployment.ts'
|
||||
import { feishuLink, sendFeishu } from './feishu-channel.ts'
|
||||
import { feishuThreadContext } from './feishu-read.ts'
|
||||
import { refreshChatNames, runFeishu } from './feishu-loops.ts'
|
||||
import { SOURCE, feishuTriggerId } from './feishu-trigger.ts'
|
||||
import { sourceContext } from './host.ts'
|
||||
import type { Localized, SourceKind } from './kind.ts'
|
||||
import type { SourceRegistry } from './registry.ts'
|
||||
|
||||
export { SOURCE, feishuRef, feishuThreadId, feishuTime, feishuTrigger, feishuTriggerId, historyEvent, withMentionNames }
|
||||
from './feishu-trigger.ts'
|
||||
export type { FeishuEvent } from './feishu-trigger.ts'
|
||||
export { backfillChat, consumerRetry, pollThreads, refreshChatNames, threadRef } from './feishu-loops.ts'
|
||||
const MembersSchema = z.looseObject({
|
||||
users: z.array(z.looseObject({ member_id: z.string(), name: z.string() })),
|
||||
has_more: z.boolean(), truncations: z.array(z.unknown()).optional(),
|
||||
})
|
||||
const FeishuConfigSchema = FeishuBotSchema.extend({
|
||||
readPageSize: z.number().int().min(1).max(50).default(20),
|
||||
linkTimeoutMs: z.number().int().min(1).max(90000).default(5000),
|
||||
chats: z.array(z.object({ chatId: z.string().regex(/^oc_[a-zA-Z0-9]+$/), name: z.string().trim().max(60).optional(), listen: z.enum(['mentions', 'all']) }).strict())
|
||||
.refine(chats => new Set(chats.map(chat => chat.chatId)).size === chats.length, 'Duplicate chat IDs')
|
||||
.default([]),
|
||||
directMessages: z.boolean().default(true),
|
||||
otherChats: z.enum(['none', 'mentions']).default('none'),
|
||||
}).strict()
|
||||
export type FeishuConfig = z.infer<typeof FeishuConfigSchema>
|
||||
export const feishuKind: SourceKind<FeishuConfig> = {
|
||||
kind: SOURCE,
|
||||
refreshLabels: (host, signal) => refreshChatNames(sourceContext(host, feishuKind), signal),
|
||||
title: { zh: '飞书', en: 'Feishu' },
|
||||
description: { zh: '接收下列群和私聊中发给机器人的消息,补收群历史并轮询订阅的线程。', en: 'Receives messages to the bot in these chats and direct messages, recovers chat history and polls subscribed Threads.' },
|
||||
schema: FeishuConfigSchema,
|
||||
fields: [
|
||||
{ key: 'cli', type: 'text', label: { zh: '机器人的 lark-cli', en: 'Bot lark-cli' },
|
||||
hint: { zh: '命令名或绝对路径,指向只允许机器人身份(strict mode bot)的 lark-cli 配置;执行器以 lark-cli 调用它。保存时读取应用和机器人,然后重启 dsh-superagent。', en: 'A command name or absolute path of a lark-cli configuration that allows only the bot identity (strict mode bot); the Executor calls it as lark-cli. Saving reads the app and the bot, then restarts dsh-superagent.' } },
|
||||
{ key: 'chats', type: 'rows', label: { zh: '接收的群', en: 'Chats' },
|
||||
hint: { zh: '“仅 @”只接收 @ 机器人的消息和已有工单的线程;“全部消息”也接收其余消息,评估可以按规则从中推断工作,需要机器人有读取全部消息的权限。', en: '"@ only" receives messages that mention the bot and Threads that have Tickets; "All" also receives the rest, from which Eval may infer work under the rules; the bot needs permission to read all messages.' },
|
||||
columns: [
|
||||
{ key: 'chatId', type: 'text', label: { zh: '群', en: 'Chat' }, pattern: 'oc_[a-zA-Z0-9]+', choose: { zh: '选择机器人已加入的群', en: 'Choose a chat the bot has joined' }, refresh: { zh: '刷新群列表', en: 'Refresh chats' } },
|
||||
{ key: 'listen', type: 'select', label: { zh: '接收', en: 'Receive' }, options: [{ value: 'mentions', label: { zh: '仅 @', en: '@ only' } }, { value: 'all', label: { zh: '全部消息', en: 'All' } }] },
|
||||
] },
|
||||
{ key: 'otherChats', type: 'select', label: { zh: '其他群', en: 'Other chats' }, options: [{ value: 'none', label: { zh: '不接收', en: 'Not received' } }, { value: 'mentions', label: { zh: '仅 @', en: '@ only' } }],
|
||||
hint: { zh: '机器人已加入、但没列在上面的群。“仅 @”接收那里 @ 机器人的消息;谁的消息能发起工作看策略里的准入。', en: 'Chats the bot has joined that are not listed above. "@ only" receives messages there that mention the bot; whose messages may originate work is the Policy\'s admission.' } },
|
||||
{ key: 'directMessages', type: 'switch', label: { zh: '接收私聊', en: 'Direct messages' } },
|
||||
{ key: 'readPageSize', type: 'number', advanced: true, min: 1, max: 50, label: { zh: '每页消息数', en: 'Messages per page' }, hint: { zh: '智能体读取线程和群历史时,每页的消息数。', en: 'Messages per page when agents read Threads and chat history.' } },
|
||||
{ key: 'linkTimeoutMs', type: 'number', advanced: true, min: 1, max: 90000, label: { zh: '原消息链接的查询时限 / 毫秒', en: 'Original message link time limit / ms' },
|
||||
hint: { zh: '查询原消息链接最多等待的时间;超时后智能体改为直接读取原消息。', en: 'How long to wait for an original message\'s link; after that, agents read the message itself.' } },
|
||||
],
|
||||
health: ['feishu', 'history'],
|
||||
scope({ meta }, config) {
|
||||
if (meta.direct === true) return config.directMessages ? { listened: true, observed: false, row: 'direct' } : { listened: false, reason: 'direct_messages_not_allowed' }
|
||||
const chat = config.chats.find(item => item.chatId === meta.chatId)
|
||||
if (chat) return { listened: true, observed: chat.listen === 'all', row: chat.chatId }
|
||||
return config.otherChats === 'mentions' ? { listened: true, observed: false, row: 'other' } : { listened: false, reason: 'chat_not_allowed' }
|
||||
},
|
||||
// A reply goes only to a chat the Source still receives.
|
||||
channel: {
|
||||
allowed: (address, config) =>
|
||||
feishuKind.scope!({ meta: { chatId: address.chatId ?? '', direct: address.direct === 'true' } }, config).listened,
|
||||
send: sendFeishu, link: feishuLink,
|
||||
},
|
||||
threadContext: feishuThreadContext,
|
||||
find: query => ({ threads: [], terms: [...new Set(query.match(/om_[A-Za-z0-9_-]+/g) ?? [])] }),
|
||||
// A message's Trigger is named after the message, so the one it answers is named after its parent.
|
||||
repliesTo: trigger => typeof trigger.meta.parentId === 'string' ? feishuTriggerId(trigger.meta.parentId) : undefined,
|
||||
policy: {
|
||||
unnamed: { person: { zh: '飞书用户', en: 'Feishu user' }, bot: { zh: '飞书机器人', en: 'Feishu bot' } },
|
||||
schema: z.object({
|
||||
allowed: z.array(z.object({ id: z.string().regex(/^ou_[a-zA-Z0-9]+$/), name: z.string().trim().max(100).optional() }).strict())
|
||||
.refine(users => new Set(users.map(user => user.id)).size === users.length, 'Duplicate open_ids').default([]),
|
||||
syncChat: z.string().regex(/^(oc_[a-zA-Z0-9]+)?$/).default(''),
|
||||
bots: z.array(z.object({ id: z.string().regex(/^(cli|ou)_[a-zA-Z0-9]+$/), name: z.string().trim().max(100).optional() }).strict())
|
||||
.refine(bots => new Set(bots.map(bot => bot.id)).size === bots.length, 'Duplicate bot ids').optional(),
|
||||
}).strict(),
|
||||
fields: [
|
||||
{ key: 'allowed', type: 'rows', label: { zh: '能发起工作的飞书用户', en: 'Feishu users who may originate work' },
|
||||
columns: [{ key: 'id', type: 'text', label: { zh: 'open_id', en: 'open_id' }, pattern: 'ou_[a-zA-Z0-9]+' }, { key: 'name', type: 'text', label: { zh: '名字', en: 'Name' }, optional: true }] },
|
||||
{ key: 'syncChat', type: 'text', pattern: '(oc_[a-zA-Z0-9]+)?', label: { zh: '同步来源群', en: 'Sync chat' }, choose: { zh: '不同步', en: 'Do not sync' },
|
||||
hint: { zh: '同步时,名单变成这个群里的人:新成员加入,离开的人移除,名字取群里的名字。启动时和点同步时进行。', en: 'Syncing makes the list the people in this chat: new members join, people who left are removed, and names follow the chat. It runs at startup and on Sync.' } },
|
||||
{ key: 'bots', type: 'rows', label: { zh: '消息可以成为候选的机器人', en: 'Bots whose messages may become candidates' },
|
||||
hint: { zh: '名单里的机器人在接收的群里发的消息成为推断的候选,交给评估;它们不能发起明确的请求,也不能停止工单。其他机器人的消息不接收。', en: 'Messages of a bot on this list, in chats the Source receives, become inferred candidates for an assessment; they never make an explicit request or stop a ticket. Other bots\' messages are not admitted.' },
|
||||
columns: [{ key: 'id', type: 'text', label: { zh: 'App ID 或 open_id', en: 'App ID or open_id' }, pattern: '(cli|ou)_[a-zA-Z0-9]+' },
|
||||
{ key: 'name', type: 'text', label: { zh: '名字', en: 'Name' }, optional: true }] },
|
||||
],
|
||||
explicit: { zh: '名单内的人 @ 机器人或私聊机器人,总是明确的请求,不需要配置。', en: 'Someone on the list who mentions the bot or messages it directly always makes an explicit request; that needs no setting.' },
|
||||
rows(stored) {
|
||||
const config = FeishuConfigSchema.parse(stored)
|
||||
const mentions = { zh: '仅 @', en: '@ only' }
|
||||
return [
|
||||
...config.chats.map(chat => ({ id: chat.chatId, label: chat.name || chat.chatId, note: chat.listen === 'all' ? { zh: '全部消息', en: 'All' } : mentions,
|
||||
cells: { listedUnaddressed: chat.listen === 'all', unlistedAddressed: true, unlistedUnaddressed: chat.listen === 'all' } })),
|
||||
{ id: 'other', label: { zh: '其他群', en: 'Other chats' }, note: config.otherChats === 'mentions' ? mentions : { zh: '不接收', en: 'Not received' },
|
||||
cells: { listedUnaddressed: false, unlistedAddressed: config.otherChats === 'mentions', unlistedUnaddressed: false } },
|
||||
{ id: 'direct', label: { zh: '私聊', en: 'Direct messages' }, note: config.directMessages ? { zh: '接收', en: 'Received' } : { zh: '不接收', en: 'Not received' },
|
||||
cells: { listedUnaddressed: false, unlistedAddressed: config.directMessages, unlistedUnaddressed: false } },
|
||||
]
|
||||
},
|
||||
sync: {
|
||||
label: { zh: '立即同步', en: 'Sync now' },
|
||||
ready: part => !!part.syncChat,
|
||||
async run(host, part, signal) {
|
||||
const commands = sourceContext(host, feishuKind).commands.withSignal(signal)
|
||||
const chat = String(part.syncChat)
|
||||
const members = ['im', '+chat-members-list', '--chat-id', chat, '--member-types', 'user', '--page-all', '--page-limit', '0']
|
||||
const result = MembersSchema.parse(await commands.lark(members))
|
||||
if (result.has_more || result.truncations?.length) throw new Error('Feishu returned an incomplete member list')
|
||||
// Preserve existing member order so policy version hashes stay stable; update names in place and append new members.
|
||||
const users = new Map(result.users.map(user => [user.member_id, user.name]))
|
||||
const known = new Set(part.allowed.map(entry => entry.id))
|
||||
return { ...part, allowed: [...part.allowed.filter(entry => users.has(entry.id)).map(entry => ({ ...entry, name: users.get(entry.id)! })), ...result.users.filter(user => !known.has(user.member_id)).map(user => ({ id: user.member_id, name: user.name }))] }
|
||||
},
|
||||
},
|
||||
},
|
||||
environment: {
|
||||
variables: [
|
||||
{ key: 'DSH_SUPERAGENT_FEISHU_BOT', description: { zh: '飞书机器人;lark-cli 以它行事', en: 'The Feishu bot; lark-cli acts as it' } },
|
||||
{ key: 'DSH_SUPERAGENT_FEISHU_CHAT', description: { zh: '接下工单的飞书群 chat_id;工单不是从飞书来的时为空', en: 'chat_id of the Feishu chat that started the ticket; empty when the ticket did not start on Feishu' } },
|
||||
],
|
||||
values: ({ deployment, trigger }) => ({ DSH_SUPERAGENT_FEISHU_BOT: deployment.feishu ? deployment.feishu.name || deployment.feishu.botId : '', DSH_SUPERAGENT_FEISHU_CHAT: trigger?.source === SOURCE ? String(trigger.meta.chatId ?? '') : '' }),
|
||||
},
|
||||
facts: (deployment): Record<string, Localized | string> => deployment?.feishu ? { cli: { zh: `当前机器人 ${deployment.feishu.name || deployment.feishu.botId}`, en: `Current bot ${deployment.feishu.name || deployment.feishu.botId}` } } : {},
|
||||
}
|
||||
/** Returns the Feishu Source's settings, falling back to schema defaults while the Feishu plugin is disabled. */
|
||||
export const feishuSettings = (sources: SourceRegistry): FeishuConfig => sources.instance<FeishuConfig>(SOURCE)?.config ?? FeishuConfigSchema.parse({})
|
||||
|
||||
export const name = 'dsh-superagent-feishu'
|
||||
export const inject = ['orchestrator']
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.effect(() => ctx.orchestrator.sources.register(feishuKind), 'feishu: source kind')
|
||||
ctx.effect(() => {
|
||||
const controller = new AbortController()
|
||||
const running = runFeishu(sourceContext(ctx.orchestrator, feishuKind), controller.signal)
|
||||
return async () => { controller.abort(); await running }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/** GitHub outbound channel: posts issue/PR comments and review thread replies under the agent account. */
|
||||
import { z } from 'zod'
|
||||
import type { OutboundMessage } from '../core/index.ts'
|
||||
import { CliError } from '../infra/commands.ts'
|
||||
import { sameName } from '../infra/text.ts'
|
||||
import type { SourceContext } from './host.ts'
|
||||
import type { ChannelLink, ChannelLinks } from './kind.ts'
|
||||
import { githubAddress, githubPage } from './github-trigger.ts'
|
||||
|
||||
const CommentSchema = z.looseObject({
|
||||
id: z.number(), body: z.string().nullish(), in_reply_to_id: z.number().nullish(), user: z.looseObject({ login: z.string() }).nullish(),
|
||||
})
|
||||
/** True when an issue is permanently deleted (410) or locked (403); 404 is retried because it can indicate temporary permission loss on a private repo. */
|
||||
const gone = (error: unknown): boolean => error instanceof CliError
|
||||
&& (error.code === 410 || (error.code === 403 && /locked/i.test(error.message)))
|
||||
const missing = (error: unknown): boolean => error instanceof CliError && (error.code === 404 || error.code === 410)
|
||||
/** Posts a comment or review-thread reply with an embedded HTML marker so retries do not create duplicate comments. */
|
||||
export async function sendGithub(host: Pick<SourceContext, 'commands'>, message: OutboundMessage): Promise<'sent' | 'gone'> {
|
||||
const { commands } = host
|
||||
const { repository, github } = commands.deployment
|
||||
const number = Number(message.channel.address.number)
|
||||
if (!number) throw new Error('Missing GitHub reply target')
|
||||
try {
|
||||
// Retained trigger URLs also route replies queued before thread-aware delivery.
|
||||
const address = githubAddress(message.channel.address.url ?? '', repository)
|
||||
let rootId: number | undefined
|
||||
if (address?.pr && address.discussion && address.number === number) {
|
||||
// Fall back to a top-level PR comment if the target inline review comment was deleted.
|
||||
const source = await commands.api(CommentSchema, `pulls/comments/${address.discussion}`)
|
||||
.catch((error: unknown) => { if (missing(error)) return undefined; throw error })
|
||||
rootId = source ? source.in_reply_to_id ?? source.id : undefined
|
||||
}
|
||||
const path = rootId === undefined ? `issues/${number}/comments` : `pulls/${number}/comments`
|
||||
const marker = `<!-- dsh-superagent:${message.id} -->`
|
||||
const mine = (comment: z.infer<typeof CommentSchema>) => sameName(comment.user?.login ?? '', github.login)
|
||||
&& (rootId === undefined || comment.in_reply_to_id === rootId) && (comment.body ?? '').includes(marker)
|
||||
if ((await commands.pages(CommentSchema, path)).some(mine)) return 'sent'
|
||||
const target = rootId === undefined ? path : `${path}/${rootId}/replies`
|
||||
await commands.api(z.unknown(), target, '--method', 'POST', '-f', `body=${message.text}\n\n${marker}`)
|
||||
return 'sent'
|
||||
} catch (error) {
|
||||
if (gone(error)) return 'gone'
|
||||
throw error
|
||||
}
|
||||
}
|
||||
export function githubLink(address: Record<string, string>, links: ChannelLinks): ChannelLink {
|
||||
const number = address.number ?? ''
|
||||
const review = !!links.repository && !!githubAddress(address.url ?? '', links.repository)?.discussion
|
||||
return { url: address.url ?? (links.repository ? githubPage(links.repository, Number(number)) : ''),
|
||||
label: review ? { zh: `GitHub #${number} 的 review 讨论`, en: `Review discussion on GitHub #${number}` }
|
||||
: { zh: `GitHub #${number}`, en: `GitHub #${number}` } }
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/** Pull request binding, lifecycle observation, and terminal IM notification helpers. */
|
||||
import { z } from 'zod'
|
||||
import { key, liveTicket, type Follow, type OutboundMessage, type ThreadFact, type Ticket } from '../core/index.ts'
|
||||
import type { Deployment } from '../deployment.ts'
|
||||
import { sameName } from '../infra/text.ts'
|
||||
import type { SourceContext } from './host.ts'
|
||||
import { githubPage, SOURCE, UserSchema } from './github-trigger.ts'
|
||||
|
||||
const PullSchema = z.looseObject({
|
||||
title: z.string().optional(), state: z.string(), html_url: z.string(), user: UserSchema,
|
||||
body: z.string().nullish(), created_at: z.string().optional(),
|
||||
merged: z.boolean().optional(), merged_at: z.string().nullish(), closed_at: z.string().nullish(), auto_merge: z.unknown().optional(),
|
||||
head: z.looseObject({ sha: z.string(), ref: z.string(), repo: z.looseObject({ full_name: z.string() }).nullish() }),
|
||||
})
|
||||
export type Pull = z.infer<typeof PullSchema> & { number: number }
|
||||
export const readPull = async (ctx: SourceContext, number: number, signal?: AbortSignal): Promise<Pull> =>
|
||||
({ ...await ctx.commands.withSignal(signal).api(PullSchema, `pulls/${number}`), number })
|
||||
const pullNumber = (thread: string): number | undefined => thread.startsWith(`${SOURCE}:`) ? Number(thread.slice(SOURCE.length + 1)) : undefined
|
||||
export const pullsOf = (ticket: Pick<Ticket, 'follows'>, open = false): number[] =>
|
||||
(ticket.follows ?? []).filter(item => !open || !item.closed).flatMap(item => pullNumber(item.thread) ?? [])
|
||||
export const pullOf = (ticket: Pick<Ticket, 'follows'>): number | undefined => pullsOf(ticket, true).at(-1) ?? pullsOf(ticket).at(-1)
|
||||
/** True when the PR was opened by the agent account from a branch in the configured repository (not a fork). */
|
||||
export const agentPull = (pr: { user: { login: string }; head?: { repo?: { full_name: string } | null } | null },
|
||||
deployment: Pick<Deployment, 'github' | 'repository'>): boolean =>
|
||||
sameName(pr.user.login, deployment.github.login) && sameName(pr.head?.repo?.full_name ?? '', deployment.repository)
|
||||
/** True when a branch belongs to the ticket: its last path segment is the ticket's short name, such as `agent/<name>`. */
|
||||
export const ticketBranch = (ticket: Pick<Ticket, 'execution'>, ref: string): boolean =>
|
||||
!!ticket.execution?.name && ref.split('/').at(-1) === ticket.execution.name
|
||||
const AUTO_MERGE_ON: ThreadFact = { label: { zh: '自动合并', en: 'Auto-merge' }, value: { zh: '已开启', en: 'on' } }
|
||||
|
||||
/**
|
||||
* Validates and binds an open PR to a ticket when the PR is either the ticket's origin PR or an agent-authored PR on the ticket's branch, enforcing that one PR binds to at most one open ticket.
|
||||
* Returns an error message when binding is rejected, or undefined when the PR is bound or when the PR is not open (which is not an error).
|
||||
*/
|
||||
export function claimPull(ctx: SourceContext, ticketId: string, pr: Pull): string | undefined {
|
||||
const ticket = ctx.tickets.get(ticketId)
|
||||
if (!ticket || !liveTicket(ticket)) return 'Ticket ended because its PR was closed'
|
||||
const source = ctx.triggers.get(ticket.origin.triggerId)
|
||||
const started = source?.source === SOURCE && source.meta.isPr === true && source.meta.number === pr.number
|
||||
const opened = agentPull(pr, ctx.deployment) && ticketBranch(ticket, pr.head.ref)
|
||||
if (!started && !opened) {
|
||||
const { login } = ctx.deployment.github
|
||||
return `PR does not belong to this ticket: use the source PR or a PR by ${login} from a branch whose last path segment is DSH_SUPERAGENT_NAME; omit pr_number when no PR exists`
|
||||
}
|
||||
const owner = ctx.tickets.open().find(item => pullsOf(item, true).includes(pr.number))
|
||||
if (owner && owner.id !== ticketId) return 'PR already belongs to another active ticket'
|
||||
const follow = ticket.follows?.find(item => item.thread === `${SOURCE}:${pr.number}`)
|
||||
if (pr.state === 'open' && (!follow || follow.closed || (opened && !follow.own))) bind(ctx, ticketId, pr, opened)
|
||||
return undefined
|
||||
}
|
||||
/**
|
||||
* Reconciles a polled PR's open/merged/closed state and auto-merge fact with any bound or branch-matching ticket.
|
||||
* An open agent PR auto-binds to the single open ticket on its branch unless that ticket already follows another open PR
|
||||
* (which only the Executor's report changes).
|
||||
* @returns whether the PR has ended.
|
||||
*/
|
||||
export async function observePull(ctx: SourceContext, pr: Pull): Promise<boolean> {
|
||||
const ended = pr.state === 'closed' || !!pr.merged
|
||||
if (ended) closePull(ctx, pr)
|
||||
else {
|
||||
const candidates = agentPull(pr, ctx.deployment) ? ctx.tickets.open().filter(ticket => ticketBranch(ticket, pr.head.ref)) : []
|
||||
const ticket = candidates.length === 1 ? candidates[0]! : undefined
|
||||
// A PR the ticket followed until it closed, and that is open again, is followed again.
|
||||
const reopened = ticket?.follows?.some(item => item.thread === `${SOURCE}:${pr.number}` && item.closed)
|
||||
if (ticket && (reopened || pullsOf(ticket, true).every(number => number === pr.number))) claimPull(ctx, ticket.id, pr)
|
||||
}
|
||||
ctx.threads.describe(`github:${pr.number}`, { facts: { autoMerge: !ended && pr.auto_merge != null ? AUTO_MERGE_ON : null } })
|
||||
return ended
|
||||
}
|
||||
function bind(ctx: SourceContext, ticketId: string, pr: Pull, own: boolean): void {
|
||||
ctx.atomic(() => {
|
||||
ctx.tickets.bindThread(ticketId, { source: SOURCE, id: String(pr.number) }, own)
|
||||
ctx.threads.patch(`github:${pr.number}`, { number: pr.number, isPr: true, url: pr.html_url, prAuthor: pr.user.login })
|
||||
})
|
||||
}
|
||||
/**
|
||||
* Reports a merged or closed PR once to the open tickets that follow it, whose end the core decides.
|
||||
* Their subscription to the PR stays so a later human comment there resumes the ticket.
|
||||
*/
|
||||
function closePull(ctx: SourceContext, pr: Pull): void {
|
||||
if (!ctx.tickets.open().some(ticket => pullsOf(ticket, true).includes(pr.number))) return
|
||||
const at = pr.merged_at ?? pr.closed_at ?? new Date().toISOString()
|
||||
ctx.tickets.threadClosed({ source: SOURCE, id: String(pr.number) }, pr.merged ? 'merged' : 'closed', at)
|
||||
}
|
||||
/**
|
||||
* Builds idempotent outbound notices for IM conversations when a ticket ends with its PR (selecting the last-closed PR it
|
||||
* opened, else the last one it followed). The notice language follows the ticket's source request: Chinese when it contains
|
||||
* Han characters and no kana, otherwise English.
|
||||
*/
|
||||
export function pullEndNotices(ticket: Ticket, request: string, repository: string): OutboundMessage[] {
|
||||
const outcome = ticket.ended?.outcome
|
||||
const follows = (ticket.follows ?? []).filter(item => item.closed && pullNumber(item.thread) !== undefined)
|
||||
const latest = (items: Follow[]) => items.sort((a, b) => b.closed!.at.localeCompare(a.closed!.at))[0]
|
||||
const follow = latest(follows.filter(item => item.own)) ?? latest(follows)
|
||||
if ((outcome !== 'merged' && outcome !== 'closed') || !follow) return []
|
||||
const number = pullNumber(follow.thread)!
|
||||
const url = githubPage(repository, number, true)
|
||||
const chinese = /\p{Script=Han}/u.test(request) && !/[\p{Script=Hiragana}\p{Script=Katakana}]/u.test(request)
|
||||
const text = chinese
|
||||
? outcome === 'merged' ? `[PR #${number}](${url}) 已合并。` : `[PR #${number}](${url}) 已关闭,未合并。`
|
||||
: outcome === 'merged' ? `[PR #${number}](${url}) has merged.` : `[PR #${number}](${url}) was closed without merging.`
|
||||
// GitHub timelines and linked issues already show the merge or closure, so GitHub channels are skipped.
|
||||
return ticket.channels.filter(channel => channel.source !== SOURCE).map(channel => ({
|
||||
id: key('pr-terminal', String(number), channel.source, JSON.stringify(channel.address), outcome), channel, text, kind: 'notice', ticketId: ticket.id }))
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
/** Read-only GitHub repository, conversation, and CI check inspection for assessments and workers. */
|
||||
import { z } from 'zod'
|
||||
import type { ThreadFact } from '../core/index.ts'
|
||||
import { validItems, type Commands, type Tally } from '../infra/commands.ts'
|
||||
import { CommentSchema, IssueSchema, isHumanLogin, reviewPlace, type GithubComment } from './github-trigger.ts'
|
||||
import type { ContextMessage, ThreadContext } from './kind.ts'
|
||||
|
||||
const CheckSchema = z.looseObject({ name: z.string(), status: z.string(), conclusion: z.string().nullable() })
|
||||
const StatusSchema = z.looseObject({ context: z.string(), state: z.string() })
|
||||
type Check = Pick<z.infer<typeof CheckSchema>, 'name' | 'status' | 'conclusion'>
|
||||
type Status = Pick<z.infer<typeof StatusSchema>, 'context' | 'state'>
|
||||
|
||||
export interface CiSnapshot { head: string; checks: Check[]; statuses: Status[] }
|
||||
/** Check conclusions and status states that count as failures; `success`, `neutral`, `skipped`, and `stale` do not. */
|
||||
const FAILING = new Set(['failure', 'timed_out', 'cancelled', 'action_required', 'startup_failure', 'error'])
|
||||
export function ciCounts(snapshot: CiSnapshot): { failed: number; running: number } {
|
||||
const statuses = snapshot.statuses ?? []
|
||||
return {
|
||||
failed: snapshot.checks.filter(check => FAILING.has(check.conclusion ?? '')).length + statuses.filter(status => FAILING.has(status.state)).length,
|
||||
running: snapshot.checks.filter(check => check.status !== 'completed').length + statuses.filter(status => status.state === 'pending').length,
|
||||
}
|
||||
}
|
||||
export function ciSummary(snapshot: CiSnapshot, where: string, read: string): string {
|
||||
const checks = snapshot.checks.map(check => ({ name: check.name, failed: FAILING.has(check.conclusion ?? ''), running: check.status !== 'completed' }))
|
||||
const statuses = (snapshot.statuses ?? []).map(status => ({ name: status.context, failed: FAILING.has(status.state), running: status.state === 'pending' }))
|
||||
const results = [...checks, ...statuses]
|
||||
const failed = results.filter(result => result.failed).map(result => result.name)
|
||||
const running = results.filter(result => result.running).map(result => result.name)
|
||||
const names = (list: string[]) => list.slice(0, 5).join(', ') + (list.length > 5 ? `, and ${list.length - 5} more` : '')
|
||||
const at = `on ${where} at ${snapshot.head.slice(0, 7)}`
|
||||
const still = running.length ? `; ${running.length} still running` : ''
|
||||
const line = failed.length ? `CI failed ${failed.length}/${results.length} ${at}: ${names(failed)}${still}`
|
||||
: running.length ? `CI running ${running.length}/${results.length} ${at}: ${names(running)}`
|
||||
: `CI passed ${results.length}/${results.length} ${at}`
|
||||
return `${line}\nRead ${read} for every check.`
|
||||
}
|
||||
export function ciFailures(snapshot: CiSnapshot): string[] {
|
||||
return [...snapshot.checks.filter(check => FAILING.has(check.conclusion ?? '')).map(check => check.name),
|
||||
...(snapshot.statuses ?? []).filter(status => FAILING.has(status.state)).map(status => status.context)].sort()
|
||||
}
|
||||
export function ciFact(counts: { failed: number; running: number }): ThreadFact {
|
||||
if (counts.failed) return { label: { zh: 'CI 失败', en: 'CI failed' }, value: String(counts.failed), problem: true }
|
||||
if (counts.running) return { label: { zh: 'CI 进行中', en: 'CI running' }, value: String(counts.running) }
|
||||
return { label: 'CI', value: { zh: '通过', en: 'passed' } }
|
||||
}
|
||||
|
||||
/** Reads a non-hidden file or directory listing from the repository's default branch via the GitHub API; hidden entries and `node_modules` are rejected. */
|
||||
export async function readRepositoryPath(commands: Commands, path: string, kind: 'file' | 'directory'): Promise<string> {
|
||||
const parts = path.split('/').filter(part => part && part !== '.')
|
||||
if (parts.some(part => part === '..' || part.startsWith('.') || part === 'node_modules')) throw new Error('Only non-hidden repository source paths are available')
|
||||
const resource = `contents/${parts.map(encodeURIComponent).join('/')}`
|
||||
if (kind === 'file') return commands.api(resource, '-H', 'Accept: application/vnd.github.raw')
|
||||
// GitHub returns an entry array for a directory and a single object for a file.
|
||||
const entries = await commands.api(z.union([z.array(z.looseObject({ name: z.string() })), z.looseObject({})]), resource)
|
||||
if (!Array.isArray(entries)) throw new Error(`${path} is not a directory`)
|
||||
return entries.map(entry => entry.name).filter(name => !name.startsWith('.') && name !== 'node_modules').sort().join('\n')
|
||||
}
|
||||
|
||||
/** Reads check runs and commit statuses for a commit without emitting a trigger. */
|
||||
export async function pullChecks(commands: Commands, head: string, tally?: Tally): Promise<CiSnapshot> {
|
||||
const page = z.looseObject({ check_runs: z.array(z.unknown()) })
|
||||
const pages = await commands.api(z.array(page), `commits/${head}/check-runs?per_page=100`, '--paginate', '--slurp')
|
||||
const runs = validItems(CheckSchema, pages.flatMap(page => page.check_runs), tally)
|
||||
const statuses = await commands.pages(StatusSchema, `commits/${head}/statuses?per_page=100`, tally)
|
||||
const latest = new Map<string, string>()
|
||||
for (const status of statuses) if (!latest.has(status.context)) latest.set(status.context, status.state)
|
||||
const checks = runs.map(({ name, status, conclusion }) => ({ name, status, conclusion }))
|
||||
return { head, checks: checks.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))),
|
||||
statuses: [...latest].map(([context, state]) => ({ context, state })).sort((a, b) => a.context.localeCompare(b.context)) }
|
||||
}
|
||||
|
||||
/** Formats a GitHub comment, review, or issue as a model-facing ContextMessage, marking bots and GitHub Apps because bot messages never authorize work. */
|
||||
function githubMessage(item: GithubComment, text: string, id: string): ContextMessage {
|
||||
const line = item.line ?? item.original_line
|
||||
return { id, from: item.user.login, at: item.created_at ?? item.submitted_at ?? '', text,
|
||||
...!isHumanLogin(item.user) ? { bot: true } : {}, ...item.html_url ? { url: item.html_url } : {},
|
||||
...item.path ? { path: item.path, ...line != null ? { line } : {} } : {} }
|
||||
}
|
||||
const reviewText = (review: GithubComment): string =>
|
||||
[review.state && review.state !== 'COMMENTED' ? review.state : '', review.body ?? ''].filter(Boolean).join('\n\n')
|
||||
const DescriptionSchema = IssueSchema.extend({
|
||||
labels: z.array(z.looseObject({ name: z.string() })).nullish(), draft: z.boolean().nullish(), merged: z.boolean().nullish(),
|
||||
head: z.looseObject({ ref: z.string() }).nullish(), base: z.looseObject({ ref: z.string() }).nullish(),
|
||||
})
|
||||
async function reviewThread(commands: Commands, number: number, comment: number, tally?: Tally): Promise<GithubComment[]> {
|
||||
const comments = await commands.pages(CommentSchema, `pulls/${number}/comments?per_page=100`, tally)
|
||||
const root = comments.find(item => item.id === comment)?.in_reply_to_id ?? comment
|
||||
return comments.filter(item => item.id === root || item.in_reply_to_id === root)
|
||||
.sort((a, b) => (a.created_at ?? '').localeCompare(b.created_at ?? ''))
|
||||
}
|
||||
function threadMessages(thread: GithubComment[], id: (comment: GithubComment) => string): ContextMessage[] {
|
||||
const [first] = thread
|
||||
return thread.map(item => item === first
|
||||
? githubMessage(item, [reviewPlace(item), item.body ?? ''].filter(Boolean).join('\n\n'), id(item))
|
||||
: { ...githubMessage(item, item.body ?? '', id(item)), ...first ? { reply_to: id(first) } : {} })
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads an Issue or PR, a page of its comments or reviews, a review thread, or a CI snapshot as model-facing
|
||||
* `{ id, from, at, text, reply_to?, path?, line?, url }` messages rather than raw GitHub API records.
|
||||
*/
|
||||
export async function readGithub(commands: Commands, resource: string): Promise<unknown> {
|
||||
// A commit's CI, such as the default branch's, which no PR names.
|
||||
const commit = /^github\/commits\/([0-9a-f]{7,40})\/checks$/.exec(resource)
|
||||
if (commit) return pullChecks(commands, commit[1]!)
|
||||
const match = /^github\/(issues|pulls)\/([1-9]\d*)(?:\/(comments|reviews|checks|thread)(?:\/([1-9]\d*))?)?$/.exec(resource)
|
||||
if (!match) {
|
||||
throw new Error('Use github/issues/<number>, github/pulls/<number>, or append /comments/<page>, /reviews/<page>, /thread/<comment-id> or /checks; or github/commits/<sha>/checks')
|
||||
}
|
||||
const [, kind, number, section, page = '1'] = match
|
||||
if (kind === 'issues' && (section === 'reviews' || section === 'checks' || section === 'thread')) {
|
||||
throw new Error('Reviews, review threads and checks require a pull request')
|
||||
}
|
||||
const path = `${kind}/${number}`
|
||||
if (section === 'checks') {
|
||||
if (match[4]) throw new Error('Checks do not take a page number')
|
||||
const pr = await commands.api(z.looseObject({ head: z.looseObject({ sha: z.string() }) }), path)
|
||||
return pullChecks(commands, pr.head.sha)
|
||||
}
|
||||
if (section === 'thread') {
|
||||
if (!match[4]) throw new Error('Name the review thread by one of its comments: github/pulls/<number>/thread/<comment-id>')
|
||||
const thread = await reviewThread(commands, Number(number), Number(match[4]))
|
||||
if (!thread.length) throw new Error('No review comment of this PR has that id')
|
||||
return { messages: threadMessages(thread, item => String(item.id)) }
|
||||
}
|
||||
if (!section) {
|
||||
const item = await commands.api(DescriptionSchema, path)
|
||||
const branches = item.head && item.base ? { head: item.head.ref, base: item.base.ref } : {}
|
||||
const labels = item.labels?.length ? { labels: item.labels.map(label => label.name) } : {}
|
||||
return { number: item.number, title: item.title, state: item.state, ...item.merged ? { merged: true } : {},
|
||||
...item.draft ? { draft: true } : {}, ...branches, ...labels, ...githubMessage(item, item.body ?? '', String(item.number)) }
|
||||
}
|
||||
const listed = await commands.api(z.array(z.unknown()), `${path}/${section}?per_page=20&page=${page}`)
|
||||
const tally = { skipped: 0 }
|
||||
const items = validItems(CommentSchema, listed, tally)
|
||||
const messages = items.map(item => section === 'reviews'
|
||||
? githubMessage({ ...item, created_at: item.submitted_at ?? item.created_at }, reviewText(item), String(item.id))
|
||||
: { ...githubMessage(item, item.body ?? '', String(item.id)), ...item.in_reply_to_id ? { reply_to: String(item.in_reply_to_id) } : {} })
|
||||
return { messages, ...tally.skipped ? { skipped: tally.skipped } : {},
|
||||
...listed.length === 20 ? { next_resource: `github/${kind}/${number}/${section}/${Number(page) + 1}` } : {} }
|
||||
}
|
||||
/** Reads one page of an Issue or PR conversation (with the Issue/PR description first on page 1), using comment URLs as message IDs. */
|
||||
async function conversationPage(commands: Commands, number: number, page: number) {
|
||||
const messages: ContextMessage[] = []
|
||||
if (page === 1) {
|
||||
const issue = await commands.api(IssueSchema, `issues/${number}`)
|
||||
messages.push(githubMessage(issue, `${issue.title}\n\n${issue.body ?? ''}`, issue.html_url))
|
||||
}
|
||||
const listed = await commands.api(z.array(z.unknown()), `issues/${number}/comments?per_page=100&page=${page}`)
|
||||
const tally = { skipped: 0 }
|
||||
const comments = validItems(CommentSchema, listed, tally)
|
||||
messages.push(...comments.map(comment => githubMessage(comment, comment.body ?? '', comment.html_url ?? String(comment.id))))
|
||||
return { messages, next_resource: listed.length === 100 ? String(page + 1) : null, ...tally.skipped ? { skipped: tally.skipped } : {} }
|
||||
}
|
||||
/**
|
||||
* Context reader for a GitHub Trigger: reads the review thread for an inline review comment (whose first comment is its
|
||||
* root and parent), or the Issue/PR conversation by page otherwise (whose first page is its root and parent).
|
||||
*/
|
||||
export const githubThreadContext: ThreadContext<unknown> = {
|
||||
messageId: trigger => trigger.url ?? '',
|
||||
read: async (commands, trigger, part, cursor) => {
|
||||
// The default branch's CI has no conversation; its Trigger is its context.
|
||||
if (typeof trigger.meta.branch === 'string') {
|
||||
const message = { id: trigger.url ?? trigger.id, from: 'CI', at: trigger.at, text: trigger.text, bot: true as const }
|
||||
return { messages: [message], body: { status: 'branch_ci', messages: [message] }, status: 'branch_ci' }
|
||||
}
|
||||
const number = Number(trigger.meta.number)
|
||||
if (trigger.meta.kind === 'inline' && typeof trigger.meta.commentId === 'number') {
|
||||
const thread = await reviewThread(commands, number, trigger.meta.commentId)
|
||||
const messages = threadMessages(thread, item => item.html_url ?? String(item.id))
|
||||
const read = part === 'thread' ? messages : messages.slice(0, 1)
|
||||
return { messages: read, body: { status: 'review_thread', messages: read }, status: 'review_thread' }
|
||||
}
|
||||
const body = await conversationPage(commands, number, Number(cursor ?? '1'))
|
||||
return { messages: body.messages, next: body.next_resource ?? undefined, body }
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/** Normalizes GitHub issues, pull requests, comments, reviews, and CI transitions into domain Triggers. */
|
||||
import { z } from 'zod'
|
||||
import { key, type ChannelRef, type Trigger } from '../core/index.ts'
|
||||
import { escapeRegExp, sameName } from '../infra/text.ts'
|
||||
|
||||
export const SOURCE = 'github'
|
||||
/** GitHub reply address: issue/PR conversation, or a review thread when `url` points at a discussion comment. */
|
||||
export const githubChannel = (number: number, url?: string): ChannelRef =>
|
||||
({ source: SOURCE, thread: `github:${number}`, address: { number: String(number), ...(url ? { url } : {}) } })
|
||||
export const githubPage = (repository: string, number: number, pr = false): string =>
|
||||
`https://github.com/${repository}/${pr ? 'pull' : 'issues'}/${number}`
|
||||
/** Parses a web or API URL for an Issue, PR, or review comment in `repository`; returns undefined for other URLs or repositories. */
|
||||
export function githubAddress(url: string, repository: string): { number: number; pr: boolean; discussion?: number } | undefined {
|
||||
const address = /^https:\/\/(?:api\.github\.com\/repos|github\.com)\/([^/]+\/[^/]+)\/(issues|pulls?)\/(\d+)\/?(?:#discussion_r(\d+)|#[\w-]*)?$/i
|
||||
const match = address.exec(url)
|
||||
if (!match || !sameName(match[1]!, repository)) return undefined
|
||||
return { number: Number(match[3]), pr: match[2]!.toLowerCase() !== 'issues', ...match[4] ? { discussion: Number(match[4]) } : {} }
|
||||
}
|
||||
/** GitHub returns a deleted account as `null` and displays it as `ghost`. */
|
||||
export const UserSchema = z.looseObject({ login: z.string(), type: z.string().optional() }).nullable()
|
||||
.transform(user => user ?? { login: 'ghost', type: 'User' })
|
||||
/**
|
||||
* A GitHub comment or review; a review carries `state` and `commit_id`, and an inline review comment carries its file,
|
||||
* line, diff hunk, and `in_reply_to_id` (the root comment ID of its review thread).
|
||||
*/
|
||||
export const CommentSchema = z.looseObject({
|
||||
id: z.number(), body: z.string().nullish(), user: UserSchema, html_url: z.string().nullish(),
|
||||
created_at: z.string().nullish(), updated_at: z.string().nullish(), submitted_at: z.string().nullish(),
|
||||
state: z.string().nullish(), commit_id: z.string().nullish(),
|
||||
path: z.string().nullish(), line: z.number().nullish(), original_line: z.number().nullish(), diff_hunk: z.string().nullish(),
|
||||
in_reply_to_id: z.number().nullish(),
|
||||
})
|
||||
export type GithubComment = z.infer<typeof CommentSchema>
|
||||
/** An Issue as `issues/<number>` returns it; GitHub sets `pull_request` when the number is a pull request. */
|
||||
export const IssueSchema = CommentSchema.extend({
|
||||
number: z.number(), title: z.string(), state: z.string(), created_at: z.string(), html_url: z.string(),
|
||||
pull_request: z.looseObject({ url: z.string() }).nullish(),
|
||||
})
|
||||
export type GithubIssue = z.infer<typeof IssueSchema>
|
||||
type Opened = Pick<GithubIssue, 'number' | 'title' | 'body' | 'user' | 'created_at' | 'html_url'>
|
||||
export interface PullRef { number: number; isPr: boolean; url?: string; prAuthor?: string }
|
||||
/**
|
||||
* True when GitHub reports the account as a regular user rather than a bot or GitHub App.
|
||||
* Review bots running under ordinary user accounts count as human here; the prompts identify which accounts are automated.
|
||||
*/
|
||||
export const isHumanLogin = (user: { login: string; type?: string }): boolean =>
|
||||
!/\[bot\]$/.test(user.login) && (!user.type || user.type === 'User')
|
||||
/** Formats an inline review comment's file:line and the trailing lines of its diff hunk, which GitHub ends at the commented line. */
|
||||
export function reviewPlace(comment: GithubComment): string {
|
||||
const line = comment.line ?? comment.original_line
|
||||
const hunk = (comment.diff_hunk ?? '').split('\n').slice(-3).join('\n')
|
||||
return [`${comment.path}${line != null ? `:${line}` : ''}`, hunk].filter(Boolean).join('\n')
|
||||
}
|
||||
function commentText(comment: GithubComment, kind: 'conversation' | 'inline' | 'review'): string {
|
||||
const decided = kind === 'review' && comment.state && comment.state !== 'COMMENTED'
|
||||
const decision = decided ? `${comment.state} review of commit ${comment.commit_id ?? 'unknown'}` : ''
|
||||
const place = kind === 'inline' && comment.path ? reviewPlace(comment) : ''
|
||||
return [decision, place, comment.body ?? ''].filter(Boolean).join('\n\n')
|
||||
}
|
||||
export const mentions = (text: string, login: string): boolean =>
|
||||
new RegExp(`(?:^|[^a-zA-Z0-9_@])@${escapeRegExp(login)}(?![a-zA-Z0-9_-])`, 'i').test(text)
|
||||
const author = (user: { login: string; type?: string }, agent: string) =>
|
||||
({ actor: { source: SOURCE, id: user.login }, human: isHumanLogin(user) && !sameName(user.login, agent) })
|
||||
/** Builds a trigger for a comment or review; text is empty for a bodyless comment or a `COMMENTED` review without a body. */
|
||||
export function commentTrigger(comment: GithubComment, ref: PullRef, kind: 'conversation' | 'inline' | 'review', agent: string): Trigger {
|
||||
const at = comment.updated_at ?? comment.submitted_at ?? comment.created_at ?? new Date().toISOString()
|
||||
const version = comment.updated_at ?? comment.submitted_at ?? ''
|
||||
const id = key('github-comment', comment.html_url ?? `github:${ref.number}`, String(comment.id), version)
|
||||
return { id, source: SOURCE, threads: [{ source: SOURCE, id: String(ref.number) }], ...author(comment.user, agent), at,
|
||||
text: commentText(comment, kind),
|
||||
...(mentions(comment.body ?? '', agent) ? { addressed: 'mention' as const } : {}),
|
||||
channel: githubChannel(ref.number, comment.html_url ?? undefined),
|
||||
...(comment.html_url ?? ref.url ? { url: comment.html_url ?? ref.url } : {}),
|
||||
meta: { number: ref.number, isPr: ref.isPr, ...(ref.prAuthor ? { prAuthor: ref.prAuthor } : {}), commentId: comment.id, kind,
|
||||
...comment.path ? { path: comment.path, line: comment.line ?? comment.original_line ?? null } : {},
|
||||
...comment.in_reply_to_id ? { inReplyTo: comment.in_reply_to_id } : {} } }
|
||||
}
|
||||
/**
|
||||
* Builds the trigger for a newly opened Issue's title and body.
|
||||
* The ID is the same as when Issue bodies were stored previously, so already-stored bodies are not ingested again.
|
||||
*/
|
||||
export function issueTrigger(issue: Opened, agent: string): Trigger {
|
||||
const body = issue.body ?? ''
|
||||
return { id: key('github-issue', String(issue.number)), source: SOURCE,
|
||||
threads: [{ source: SOURCE, id: String(issue.number) }], ...author(issue.user, agent), at: issue.created_at,
|
||||
text: `${issue.title}\n\n${body}`,
|
||||
...(mentions(body, agent) ? { addressed: 'mention' as const } : {}),
|
||||
channel: githubChannel(issue.number, issue.html_url), url: issue.html_url, meta: { number: issue.number, isPr: false, kind: 'issue' } }
|
||||
}
|
||||
/**
|
||||
* Builds the trigger for a newly opened PR's title and description.
|
||||
* PR and Issue numbers share one sequence in GitHub and never collide, so this reuses `issueTrigger`'s `github-issue` ID format.
|
||||
*/
|
||||
export function pullTrigger(pull: Opened, agent: string): Trigger {
|
||||
return { ...issueTrigger(pull, agent), meta: { number: pull.number, isPr: true, prAuthor: pull.user.login, kind: 'pull' } }
|
||||
}
|
||||
/** Builds a trigger for a default-branch CI transition; default-branch CI has no person to reply to, so it has no `channel`. */
|
||||
export function branchCiTrigger(branch: string, sha: string, revision: number, summary: string, url: string): Trigger {
|
||||
return { id: key('github-ci', `branch:${branch}`, String(revision)), source: SOURCE,
|
||||
threads: [{ source: SOURCE, id: `ci:${branch}` }], actor: { source: SOURCE, id: '' }, human: false, at: new Date().toISOString(),
|
||||
text: summary, url, meta: { ci: true, branch, sha, revision } }
|
||||
}
|
||||
export function ciTrigger(pr: { number: number; html_url: string; user: { login: string } }, revision: number, summary: string): Trigger {
|
||||
return { id: key('github-ci', String(pr.number), String(revision)), source: SOURCE, threads: [{ source: SOURCE, id: String(pr.number) }],
|
||||
actor: { source: SOURCE, id: '' }, human: false, at: new Date().toISOString(),
|
||||
text: summary, channel: githubChannel(pr.number), url: pr.html_url,
|
||||
meta: { number: pr.number, isPr: true, prAuthor: pr.user.login, ci: true, revision } }
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
/** GitHub source kind definition, notification and repository polling loops, and Cordis plugin entry. */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { key, type Thread } from '../core/index.ts'
|
||||
import { sameName } from '../infra/text.ts'
|
||||
import { GithubAccountsSchema } from '../deployment.ts'
|
||||
import { CliError, validItems, type Tally } from '../infra/commands.ts'
|
||||
import { ciCounts, ciFact, ciFailures, ciSummary, githubThreadContext, pullChecks } from './github-read.ts'
|
||||
import { branchCiTrigger, ciTrigger, commentTrigger, CommentSchema, githubAddress, issueTrigger, IssueSchema, pullTrigger, SOURCE }
|
||||
from './github-trigger.ts'
|
||||
import { githubLink, sendGithub } from './github-channel.ts'
|
||||
import type { GithubComment, PullRef } from './github-trigger.ts'
|
||||
import { observePull, pullsOf, readPull, type Pull } from './github-pull.ts'
|
||||
import { health, sourceContext, type Health, type SourceContext } from './host.ts'
|
||||
import { KV } from '../kv.ts'
|
||||
import { cursorAt, pause, POLL_HORIZON_MS, pollThreads as pollEach } from './poll.ts'
|
||||
import type { Localized, SourceKind } from './kind.ts'
|
||||
import { z } from 'zod'
|
||||
|
||||
export { commentTrigger, type GithubComment } from './github-trigger.ts'
|
||||
/** A notification of the agent account; `subject.url` is the API address of its Issue or PR. */
|
||||
const NotificationSchema = z.looseObject({
|
||||
repository: z.looseObject({ full_name: z.string() }), subject: z.looseObject({ url: z.string().nullish() }),
|
||||
})
|
||||
|
||||
const GithubConfigSchema = GithubAccountsSchema.extend({ pollSeconds: z.number().int().min(60).max(3600).default(60),
|
||||
failuresBeforeError: z.number().int().min(1).max(20).default(3),
|
||||
observeIssues: z.boolean().default(false), observePulls: z.boolean().default(false), observeComments: z.boolean().default(false),
|
||||
observeCi: z.boolean().default(false) }).strict()
|
||||
export type GithubConfig = z.infer<typeof GithubConfigSchema>
|
||||
const OBSERVED = { issues: 'observeIssues', pulls: 'observePulls', comments: 'observeComments', ci: 'observeCi' } as const
|
||||
type Scope = keyof typeof OBSERVED
|
||||
function scopeOf(meta: Record<string, unknown>): Scope | undefined {
|
||||
if (meta.ci === true) return typeof meta.branch === 'string' ? 'ci' : undefined
|
||||
if (meta.kind === 'issue') return 'issues'
|
||||
if (meta.kind === 'pull') return 'pulls'
|
||||
return meta.kind === 'conversation' || meta.kind === 'inline' || meta.kind === 'review' ? 'comments' : undefined
|
||||
}
|
||||
export const githubKind: SourceKind<GithubConfig> = {
|
||||
kind: SOURCE,
|
||||
title: { zh: 'GitHub', en: 'GitHub' },
|
||||
description: { zh: '智能体的 GitHub 账号;接收仓库里发给它的通知、已订阅 Issue 与 PR 的讨论、CI 状态变化和 PR 合并关闭。', en: 'The agent\'s GitHub accounts; receives notifications to it in the repository, discussion on subscribed Issues and PRs, CI changes, and PR merge or close.' },
|
||||
schema: GithubConfigSchema,
|
||||
fields: [
|
||||
{ key: 'repository', type: 'text', pattern: '[A-Za-z0-9_.\\-]+/[A-Za-z0-9_.\\-]+', label: { zh: '监听的仓库', en: 'Repository' },
|
||||
hint: { zh: '写作 owner/name。这个来源接收这个仓库的通知和讨论;保存时检查智能体账号能否推送到它,然后重启 dsh-superagent。', en: 'Written owner/name. This Source receives the repository\'s notifications and discussion; saving checks that the agent account can push to it, then restarts dsh-superagent.' } },
|
||||
{ key: 'cli', type: 'text', label: { zh: '智能体的 GitHub CLI', en: 'Agent GitHub CLI' },
|
||||
hint: { zh: '命令名或绝对路径,通常是用 GH_CONFIG_DIR 登录智能体账号的 gh 包装脚本。智能体以它登录的账号行事,执行器以 gh 调用它。保存时检查它登录的账号能否推送到仓库,然后重启 dsh-superagent。', en: 'A command name or absolute path, usually a gh wrapper signed in to the agent account through GH_CONFIG_DIR. The agent acts as the account it is signed in to, and the Executor calls it as gh. Saving checks that this account can push to the repository, then restarts dsh-superagent.' } },
|
||||
{ key: 'pollSeconds', type: 'number', advanced: true, min: 60, max: 3600, label: { zh: '轮询间隔 / 秒', en: 'Polling interval / seconds' }, hint: { zh: 'GitHub 要求更长间隔时以 GitHub 为准。', en: 'A longer interval requested by GitHub wins.' } },
|
||||
{ key: 'failuresBeforeError', type: 'number', advanced: true, min: 1, max: 20, label: { zh: '连续失败几次后报告异常', en: 'Failed polls before reporting an error' }, hint: { zh: '单次网络中断会在下一次轮询时自动重试,不必报告。', en: 'A single network interruption is retried on the next poll and need not be reported.' } },
|
||||
{ key: 'observeIssues', type: 'switch', label: { zh: '接收仓库里的新 Issue', en: 'Receive new Issues in the repository' },
|
||||
hint: { zh: '打开后,没有 @ 智能体的新 Issue 也会收到,由策略的接收表决定是否成为候选。从打开的时候开始,不读以前的。', en: 'New Issues that do not mention the agent are received too, and the Policy\'s admission table decides whether they become candidates. Starts when switched on; earlier ones are not read.' } },
|
||||
{ key: 'observePulls', type: 'switch', label: { zh: '接收仓库里的新 PR', en: 'Receive new pull requests in the repository' },
|
||||
hint: { zh: '打开后,没有 @ 智能体的新 PR 也会收到,由策略的接收表决定是否成为候选。', en: 'New pull requests that do not mention the agent are received too, and the Policy\'s admission table decides whether they become candidates.' } },
|
||||
{ key: 'observeComments', type: 'switch', label: { zh: '接收仓库里的评论', en: 'Receive comments in the repository' },
|
||||
hint: { zh: '打开后,Issue 和 PR 上没有 @ 智能体的评论和代码评论也会收到,由策略的接收表决定是否成为候选。', en: 'Comments and review comments on Issues and PRs that do not mention the agent are received too, and the Policy\'s admission table decides whether they become candidates.' } },
|
||||
{ key: 'observeCi', type: 'switch', label: { zh: '接收默认分支的 CI', en: 'Receive the default branch\'s CI' },
|
||||
hint: { zh: '打开后,默认分支的 CI 失败和恢复会收到,由策略的接收表决定是否成为候选。', en: 'Failures of the default branch\'s CI, and its recovery, are received, and the Policy\'s admission table decides whether they become candidates.' } }],
|
||||
// What the Source observes across the repository it receives in full; elsewhere, only what addresses the agent.
|
||||
scope: ({ meta }, config) => {
|
||||
const row = scopeOf(meta)
|
||||
return { listened: true, observed: row !== undefined && config[OBSERVED[row]], ...row ? { row } : {} }
|
||||
},
|
||||
health: ['github'],
|
||||
channel: { send: sendGithub, link: githubLink },
|
||||
threadContext: githubThreadContext,
|
||||
// `#12`, `12`, or the page of an Issue or PR, of any repository, names Thread 12.
|
||||
find: query => {
|
||||
const number = (/^#?(\d+)$/.exec(query.trim()) ?? /github\.com\/[^/]+\/[^/]+\/(?:pull|issues)\/(\d+)/.exec(query))?.[1]
|
||||
return { threads: number ? [`${SOURCE}:${number}`] : [], terms: [] }
|
||||
},
|
||||
policy: {
|
||||
schema: z.object({ allowed: z.array(z.object({ id: z.string().regex(/^[A-Za-z0-9-]+$/) }).strict()).default([]),
|
||||
bots: z.array(z.object({ id: z.string().regex(/^[A-Za-z0-9-]+(\[bot\])?$/) }).strict()).optional() }).strict(),
|
||||
fields: [{ key: 'allowed', type: 'rows', label: { zh: '能发起工作的 GitHub 账号', en: 'GitHub accounts that may originate work' },
|
||||
columns: [{ key: 'id', type: 'text', label: { zh: '账号', en: 'Account' }, pattern: '[A-Za-z0-9\\-]+' }] },
|
||||
{ key: 'bots', type: 'rows', label: { zh: '评论可以成为候选的机器人', en: 'Bots whose comments may become candidates' },
|
||||
hint: { zh: '名单里的机器人账号(例如 dependabot[bot])的评论在来源收到时成为推断的候选,交给评估:评论 @ 了智能体,或者打开了“接收仓库里的评论”。它们不能发起明确的请求,也不能停止工单;其他机器人的评论不接收。', en: 'A comment of a bot account on this list, such as dependabot[bot], becomes an inferred candidate for an assessment when the Source receives it: when it mentions the agent, or with Receive comments in the repository on. Such a comment never makes an explicit request or stops a ticket; other bots\' comments are not admitted.' },
|
||||
columns: [{ key: 'id', type: 'text', label: { zh: '账号', en: 'Account' }, pattern: '[A-Za-z0-9\\-]+(\\[bot\\])?' }] }],
|
||||
explicit: { zh: '名单里的账号在 Issue 或评论里 @ 智能体的账号。', en: 'An account on the list mentions the agent\'s account in an Issue or a comment.' },
|
||||
rows(stored) {
|
||||
const config = GithubConfigSchema.partial().parse(stored)
|
||||
const note = (observed: boolean | undefined) => observed ? { zh: '全部', en: 'All' } : { zh: '仅 @', en: '@ only' }
|
||||
const row = (id: Scope, label: { zh: string; en: string }) => ({ id, label, note: note(config[OBSERVED[id]]),
|
||||
cells: { listedUnaddressed: !!config[OBSERVED[id]], unlistedAddressed: true, unlistedUnaddressed: !!config[OBSERVED[id]] } })
|
||||
return [row('issues', { zh: '新 Issue', en: 'New Issues' }), row('pulls', { zh: '新 PR', en: 'New pull requests' }),
|
||||
row('comments', { zh: '评论', en: 'Comments' }),
|
||||
// CI has no sender, so only the cell of a sender off the list who does not address the agent applies.
|
||||
{ id: 'ci', label: { zh: '默认分支的 CI', en: 'Default branch CI' }, note: config.observeCi ? { zh: '接收', en: 'Received' } : { zh: '不接收', en: 'Not received' },
|
||||
cells: { listedUnaddressed: false, unlistedAddressed: false, unlistedUnaddressed: !!config.observeCi } }]
|
||||
},
|
||||
},
|
||||
environment: {
|
||||
variables: [
|
||||
{ key: 'DSH_SUPERAGENT_GITHUB_REPOSITORY', description: { zh: '这个来源监听的仓库,owner/name。智能体在这里开的 PR,分支最后一段是 DSH_SUPERAGENT_NAME 时(例如 agent/$DSH_SUPERAGENT_NAME)会关联到工单', en: 'The repository this Source watches, as owner/name. A PR the agent opens here from a branch whose last path segment is DSH_SUPERAGENT_NAME (for example agent/$DSH_SUPERAGENT_NAME) binds to the ticket' } },
|
||||
{ key: 'DSH_SUPERAGENT_GITHUB_ACCOUNT', description: { zh: '智能体的 GitHub 账号;gh 以它登录,除自动合并外的 GitHub 操作都用 gh,不用其他账号或凭据', en: 'The agent\'s GitHub account. gh is signed in to it; use gh for every GitHub operation except auto-merge, and never another account or credential' } },
|
||||
{ key: 'DSH_SUPERAGENT_GITHUB_COMMIT_EMAIL', description: { zh: '智能体账号的提交邮箱。这里的 git 没有身份:按提示词写明的作者提交,没有写明时用智能体账号和这个邮箱', en: 'Commit email of the agent\'s account. git has no identity here: commit as the author these instructions name, or as the agent\'s account with this email when they name none' } },
|
||||
{ key: 'DSH_SUPERAGENT_GITHUB_NUMBER', description: { zh: '接下工单的 Issue 或 PR 编号;工单不是从 GitHub 来的时为空', en: 'Number of the Issue or PR that started the ticket; empty when the ticket did not start on GitHub' } },
|
||||
{ key: 'DSH_SUPERAGENT_GITHUB_PR_HEAD', description: { zh: '接下工单的 PR 的 head 引用 refs/pull/<编号>/head;不是 PR 时为空', en: 'Head ref refs/pull/<number>/head of the PR that started the ticket; empty when it was not a PR' } },
|
||||
],
|
||||
values: ({ deployment, trigger }) => {
|
||||
const number = trigger?.threads[0]?.source === SOURCE ? Number(trigger.meta.number) || 0 : 0
|
||||
return { DSH_SUPERAGENT_GITHUB_REPOSITORY: deployment.repository, DSH_SUPERAGENT_GITHUB_ACCOUNT: deployment.github.login, DSH_SUPERAGENT_GITHUB_COMMIT_EMAIL: deployment.github.noreply, DSH_SUPERAGENT_GITHUB_NUMBER: number ? String(number) : '',
|
||||
DSH_SUPERAGENT_GITHUB_PR_HEAD: number && trigger?.meta.isPr === true ? `refs/pull/${number}/head` : '' }
|
||||
},
|
||||
},
|
||||
facts: (deployment): Record<string, Localized | string> => deployment ? {
|
||||
cli: { zh: `当前账号 @${deployment.github.login}`, en: `Current account @${deployment.github.login}` },
|
||||
} : {},
|
||||
}
|
||||
export const name = 'dsh-superagent-github'
|
||||
export const inject = ['orchestrator']
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.effect(() => ctx.orchestrator.sources.register(githubKind), 'github: source kind')
|
||||
ctx.effect(() => {
|
||||
const source = sourceContext(ctx.orchestrator, githubKind)
|
||||
const controller = new AbortController()
|
||||
// Unhandled rejections terminate the DSH process, so a failure escaping the polling loop is reported as source health instead.
|
||||
const running = watch(source, controller.signal)
|
||||
.catch((error: unknown) => { source.report('github', { state: 'error', detail: String(error) }) })
|
||||
return async () => { controller.abort(); await running }
|
||||
})
|
||||
}
|
||||
/** Returns an error health once `failures` consecutive polls fail, or undefined so a single network interruption stays quiet. */
|
||||
export function pollFailure(failures: number, threshold: number, error: unknown): Health | undefined {
|
||||
return failures >= threshold ? health('error', 'pollFailures', { count: String(failures), error: String(error) }) : undefined
|
||||
}
|
||||
async function watch(ctx: SourceContext, signal: AbortSignal): Promise<void> {
|
||||
let interval = 60
|
||||
let failures = 0
|
||||
await ctx.ready
|
||||
// Setup restarts dsh-superagent once it resolves the accounts; until then nothing is polled.
|
||||
if (!ctx.configured()) { ctx.report('github', health('paused', 'setupPending')); return }
|
||||
while (!signal.aborted) {
|
||||
const config = ctx.config<GithubConfig>(SOURCE)
|
||||
try {
|
||||
if (config) interval = await pollRound(ctx, signal)
|
||||
else ctx.report('github', health('paused', 'sourceOff'))
|
||||
failures = 0
|
||||
} catch (error) {
|
||||
failures += 1
|
||||
const failed = pollFailure(failures, config?.failuresBeforeError ?? 1, error)
|
||||
if (!signal.aborted && failed) ctx.report('github', failed)
|
||||
}
|
||||
await pause(Math.max(interval, ctx.config<GithubConfig>(SOURCE)?.pollSeconds ?? 60) * 1000, signal)
|
||||
}
|
||||
}
|
||||
interface Round {
|
||||
started: string; signal?: AbortSignal | undefined
|
||||
pulls: Map<number, Promise<Pull>>; read: Set<number>; failures: string[]; dropped: number[]; abandoned: number[]
|
||||
tally: Tally
|
||||
}
|
||||
const newRound = (signal?: AbortSignal): Round => ({ started: new Date().toISOString(), signal, pulls: new Map(), read: new Set(),
|
||||
failures: [], dropped: [], abandoned: [], tally: { skipped: 0 } })
|
||||
const stopped = (ctx: SourceContext, round: Round): boolean => !!round.signal?.aborted || !ctx.config(SOURCE)
|
||||
export interface Retry { since: string; pr: boolean; failedAt?: string; attempts?: number }
|
||||
const RETRY_WINDOW_MS = 24 * 3600_000
|
||||
/** True when GitHub returns HTTP 410 (Gone); HTTP 404 is retried because it can indicate revoked access to a private repository. */
|
||||
const deleted = (error: unknown): boolean => error instanceof CliError && error.code === 410
|
||||
/** Records a failed read on the Thread and in the round; deleted (410) or abandoned (24h+) threads do not fail the round. */
|
||||
function failed(ctx: SourceContext, round: Round, number: number, error: unknown, abandoned = false): void {
|
||||
if (deleted(error)) round.dropped.push(number)
|
||||
else if (abandoned) round.abandoned.push(number)
|
||||
else round.failures.push(`#${number}: ${String(error)}`)
|
||||
ctx.threads.patch(`github:${number}`, { pollError: String(error) })
|
||||
}
|
||||
function included(raw: string): { headers: string; body: string } {
|
||||
const split = raw.search(/\r?\n\r?\n/)
|
||||
if (split < 0) throw new Error('GitHub returned no HTTP headers')
|
||||
return { headers: raw.slice(0, split), body: raw.slice(split).trim() }
|
||||
}
|
||||
const ListedSchema = CommentSchema.extend({ issue_url: z.string().nullish(), pull_request_url: z.string().nullish() })
|
||||
/** Repository-wide event streams; GitHub's `/issues` endpoint includes PRs, and `/issues/comments` covers issue and PR conversation comments (excluding inline review comments). */
|
||||
const STREAMS = [
|
||||
{ id: 'issues', observed: (config: GithubConfig) => config.observeIssues || config.observePulls, path: 'issues?state=all' },
|
||||
{ id: 'comments', observed: (config: GithubConfig) => config.observeComments, path: 'issues/comments?' },
|
||||
{ id: 'review-comments', observed: (config: GithubConfig) => config.observeComments, path: 'pulls/comments?' },
|
||||
] as const
|
||||
/** Polls repository-wide streams (new Issues/PRs, comments, default-branch CI); each stream reads from when it was switched on, resets when switched off, and fails independently. */
|
||||
async function pollRepository(ctx: SourceContext, round: Round): Promise<void> {
|
||||
const config = ctx.config<GithubConfig>(SOURCE)
|
||||
if (!config) return
|
||||
for (const stream of STREAMS) {
|
||||
if (stopped(ctx, round)) return
|
||||
if (!stream.observed(config)) { ctx.state.set(KV.github.stream(stream.id), undefined); continue }
|
||||
try { await readStream(ctx, round, stream.id, stream.path, config) }
|
||||
catch (error) { if (!stopped(ctx, round)) round.failures.push(`${stream.id}: ${String(error)}`) }
|
||||
}
|
||||
// When CI observation is turned off, clear the failure baseline but preserve the revision counter so future triggers have unique IDs.
|
||||
if (!config.observeCi) {
|
||||
const seen = ctx.state.get(KV.github.branchCi)
|
||||
ctx.state.set(KV.github.branchCi, seen && { revision: seen.revision })
|
||||
return
|
||||
}
|
||||
try { await pollBranchCi(ctx, round) }
|
||||
catch (error) { if (!stopped(ctx, round)) round.failures.push(`default branch CI: ${String(error)}`) }
|
||||
}
|
||||
/**
|
||||
* Polls one repository-wide event stream (oldest first) since its cursor and steps the cursor to five seconds before the
|
||||
* newest item (`cursorAt`), relying on Trigger ID deduplication so repeated messages are no-ops. While the cursor stays
|
||||
* unchanged (including when clamped to the hourly horizon), sending the last page's ETag makes an unchanged list return
|
||||
* HTTP 304 and cost no rate limit. Items from before their kind was switched on or beyond the horizon are ignored.
|
||||
*/
|
||||
async function readStream(ctx: SourceContext, round: Round, id: string, path: string, config: GithubConfig): Promise<void> {
|
||||
const name = KV.github.stream(id)
|
||||
const stored = ctx.state.get(name)
|
||||
const kinds = id === 'issues' ? [...config.observeIssues ? ['issues'] : [], ...config.observePulls ? ['pulls'] : []] : ['comments']
|
||||
const before = typeof stored?.started === 'object' ? stored.started : {}
|
||||
const started: Record<string, string> = Object.fromEntries(kinds.map(kind => [kind, before[kind] ?? round.started]))
|
||||
const horizon = Math.floor((Date.parse(round.started) - POLL_HORIZON_MS) / 3_600_000) * 3_600_000
|
||||
const since = new Date(Math.max(Date.parse(stored?.since ?? round.started), horizon)).toISOString()
|
||||
const conditional = stored?.etag && stored.since === since ? ['-H', `If-None-Match: ${stored.etag}`] : []
|
||||
let raw: string
|
||||
const listed = `${path}&sort=updated&direction=asc&per_page=100&since=${encodeURIComponent(since)}`
|
||||
try { raw = await ctx.commands.withSignal(round.signal).api(listed, '--include', ...conditional) }
|
||||
catch (error) {
|
||||
if (error instanceof CliError && error.code === 304) return
|
||||
throw error
|
||||
}
|
||||
const { headers, body } = included(raw)
|
||||
const items = validItems(ListedSchema, z.array(z.unknown()).parse(JSON.parse(body)), round.tally)
|
||||
const { login } = ctx.deployment.github
|
||||
const fresh = (at: string | null | undefined, kind: string) =>
|
||||
kind in started && Date.parse(at ?? '') >= Math.max(Date.parse(started[kind]!), horizon)
|
||||
for (const item of items) {
|
||||
// Stopping mid-stream aborts before advancing the cursor so unread items are retried.
|
||||
if (stopped(ctx, round)) throw new Error('The GitHub Source stopped during the poll')
|
||||
if (sameName(item.user.login, login)) continue
|
||||
if (id === 'issues') {
|
||||
const issue = IssueSchema.safeParse(item)
|
||||
const pr = issue.success && !!issue.data.pull_request
|
||||
if (!issue.success || !fresh(issue.data.created_at, pr ? 'pulls' : 'issues')) continue
|
||||
await ctx.receive(pr ? pullTrigger(issue.data, login) : issueTrigger(issue.data, login))
|
||||
nameThread(ctx, issue.data.number, issue.data.title)
|
||||
continue
|
||||
}
|
||||
const number = Number(/\/(?:issues|pulls)\/(\d+)$/.exec((id === 'comments' ? item.issue_url : item.pull_request_url) ?? '')?.[1])
|
||||
if (!number || !fresh(item.updated_at ?? item.created_at, 'comments')) continue
|
||||
const isPr = id === 'review-comments' || /\/pull\//.test(item.html_url ?? '')
|
||||
const ref = { number, isPr, ...item.html_url ? { url: item.html_url } : {} }
|
||||
const trigger = commentTrigger(item, ref, id === 'comments' ? 'conversation' : 'inline', login)
|
||||
if (trigger.text) await ctx.receive(trigger)
|
||||
}
|
||||
const newest = Math.max(...items.map(item => Date.parse(item.updated_at ?? '')).filter(Number.isFinite))
|
||||
const moved = Number.isFinite(newest) ? cursorAt(new Date(newest).toISOString()) : since
|
||||
const next = Date.parse(moved) > Date.parse(since) ? moved : since
|
||||
const etag = /^etag:\s*(.+)$/im.exec(headers)?.[1]?.trim()
|
||||
// The ETag belongs to this request, which the next round repeats only while the cursor stays unchanged.
|
||||
ctx.state.set(name, { since: next, started, ...etag && next === since ? { etag } : {} })
|
||||
}
|
||||
/** Polls default-branch CI checks, emitting a trigger only when the set of failing checks changes after the initial baseline. */
|
||||
async function pollBranchCi(ctx: SourceContext, round: Round): Promise<void> {
|
||||
const commands = ctx.commands.withSignal(round.signal)
|
||||
const { default_branch: branch } = await commands.api(z.looseObject({ default_branch: z.string() }), '')
|
||||
const head = await commands.api(z.looseObject({ sha: z.string() }), `commits/${encodeURIComponent(branch)}`)
|
||||
const snapshot = await pullChecks(commands, head.sha, round.tally)
|
||||
const previous = ctx.state.get(KV.github.branchCi)
|
||||
// The first observation after switching on or renaming the default branch establishes the baseline.
|
||||
const known = previous?.branch === branch ? previous.failures : undefined
|
||||
const failures = ciFailures(snapshot)
|
||||
const counts = ciCounts(snapshot)
|
||||
// Retain previous failures while checks are still running so recovery is reported only once all checks pass.
|
||||
const now = failures.length || !counts.running ? failures : known ?? []
|
||||
let revision = previous?.revision ?? 0
|
||||
if (known && JSON.stringify(now) !== JSON.stringify(known)) {
|
||||
revision += 1
|
||||
const summary = ciSummary(snapshot, branch, `github/commits/${head.sha}/checks`)
|
||||
const url = `https://github.com/${ctx.deployment.repository}/commit/${head.sha}`
|
||||
await ctx.receive(branchCiTrigger(branch, head.sha, revision, summary, url))
|
||||
ctx.threads.describe(`github:ci:${branch}`, { label: `CI on ${branch}`, facts: { ci: ciFact(counts) } })
|
||||
}
|
||||
ctx.state.set(KV.github.branchCi, { branch, failures: now, revision })
|
||||
}
|
||||
/**
|
||||
* One polling round: notifications, then observed repository streams, then the PRs of open tickets with their CI, then
|
||||
* subscribed Threads. Each step and each Issue, PR, or Thread fails independently so all steps run before the round fails.
|
||||
* Other read failures (including 404) fail the round toward the error threshold, whereas deleted (410) or abandoned
|
||||
* (24h+) threads are recorded only on the thread and in health detail.
|
||||
* @returns the polling interval requested by GitHub, in seconds.
|
||||
*/
|
||||
export async function pollRound(ctx: SourceContext, signal?: AbortSignal): Promise<number> {
|
||||
const round = newRound(signal)
|
||||
const notified = await pollNotifications(ctx, round).then(value => ({ value }), (error: unknown) => ({ error }))
|
||||
await pollRepository(ctx, round)
|
||||
await pollTicketPulls(ctx, signal, round)
|
||||
await pollThreads(ctx, signal, round)
|
||||
if ('error' in notified) throw notified.error
|
||||
const { count, interval } = notified.value
|
||||
if (signal?.aborted) return interval
|
||||
if (round.failures.length) throw new Error(`${round.failures.length} GitHub reads failed: ${round.failures.slice(0, 3).join('; ')}`)
|
||||
const named = (numbers: number[]) => [...new Set(numbers)].sort((a, b) => a - b).map(number => `#${number}`).join(', ')
|
||||
const dropped = (round.dropped.length ? `; deleted on GitHub: ${named(round.dropped)}` : '')
|
||||
+ (round.abandoned.length ? `; dropped after their reads kept failing: ${named(round.abandoned)}` : '')
|
||||
+ (round.tally.skipped ? `; ${round.tally.skipped} unreadable items skipped` : '')
|
||||
ctx.report('github', { state: 'ready', detail: `${count} notifications; next poll ≥ ${interval}s${dropped}`, at: round.started })
|
||||
return interval
|
||||
}
|
||||
/** Reads notifications since the cursor and each Issue or PR they name, advancing the cursor atomically with the retry map for failed reads; deleted (410) Issues are dropped once their error is recorded. */
|
||||
async function pollNotifications(ctx: SourceContext, round: Round): Promise<{ count: number; interval: number }> {
|
||||
const { state } = ctx
|
||||
const commands = ctx.commands.withSignal(round.signal)
|
||||
const since = state.get(KV.github.since) ?? round.started
|
||||
if (state.get(KV.github.since) === undefined) state.set(KV.github.since, since)
|
||||
const { repository } = ctx.deployment
|
||||
const path = `notifications?all=true&per_page=50&since=${encodeURIComponent(since)}`
|
||||
const raw = await commands.api(path, '--include')
|
||||
const split = raw.search(/\r?\n\r?\n/)
|
||||
if (split < 0) throw new Error('GitHub returned no HTTP headers')
|
||||
const headers = raw.slice(0, split)
|
||||
const body = raw.slice(split).trim()
|
||||
const asked = Number(/x-poll-interval:\s*(\d+)/i.exec(headers)?.[1] ?? 60)
|
||||
const interval = Math.max(60, Number.isFinite(asked) ? asked : 60)
|
||||
const notifications = /rel="next"/.test(headers)
|
||||
? await commands.pages(NotificationSchema, path, round.tally)
|
||||
: validItems(NotificationSchema, z.array(z.unknown()).parse(JSON.parse(body)), round.tally)
|
||||
const retries = { ...state.get(KV.github.retries) }
|
||||
// Failed items from the previous round run first, followed by newly notified items in chronological order; each is read once.
|
||||
const items = new Map(Object.entries(retries).map(([number, retry]) => [Number(number), retry]))
|
||||
for (const notification of notifications.reverse()) {
|
||||
if (!sameName(notification.repository.full_name, repository)) continue
|
||||
const address = githubAddress(notification.subject.url ?? '', repository)
|
||||
if (!address || address.discussion || items.has(address.number)) continue
|
||||
const { number, pr } = address
|
||||
items.set(number, { since: [state.get(KV.github.cursor(number)) ?? since, since].sort().at(-1)!, pr })
|
||||
}
|
||||
for (const [number, item] of items) {
|
||||
if (stopped(ctx, round)) return { count: notifications.length, interval }
|
||||
round.read.add(number)
|
||||
try {
|
||||
await readNotified(ctx, round, number, item)
|
||||
delete retries[number]
|
||||
} catch (error) {
|
||||
if (stopped(ctx, round)) return { count: notifications.length, interval }
|
||||
const retry = { ...item, failedAt: item.failedAt ?? round.started, attempts: (item.attempts ?? 0) + 1 }
|
||||
// Abandon notification retries after 24 hours of failures so a permanently unreadable thread does not hold source health in error; its cursor stays so a later notification or thread poll still reads what was missed.
|
||||
const abandoned = Date.parse(round.started) - Date.parse(retry.failedAt) >= RETRY_WINDOW_MS
|
||||
failed(ctx, round, number, error, abandoned)
|
||||
if (!deleted(error) && !abandoned) { retries[number] = retry; continue }
|
||||
delete retries[number]
|
||||
}
|
||||
}
|
||||
ctx.atomic(() => { state.set(KV.github.since, cursorAt(round.started)); state.set(KV.github.retries, retries) })
|
||||
return { count: notifications.length, interval }
|
||||
}
|
||||
async function readNotified(ctx: SourceContext, round: Round, number: number, item: Retry): Promise<void> {
|
||||
const { since } = item
|
||||
let isPr = item.pr
|
||||
if (!isPr) {
|
||||
const issue = await ctx.commands.withSignal(round.signal).api(IssueSchema, `issues/${number}`)
|
||||
// `issues/<n>` also returns PRs with `pull_request` set; handle them in the PR branch below.
|
||||
isPr = !!issue.pull_request
|
||||
if (!isPr) {
|
||||
// An Issue opened by someone other than the agent since the cursor brings its body so a mention in it reaches the gate.
|
||||
// A mention added by a later edit to the body is not read: only the author can be the actor of the body.
|
||||
const { login } = ctx.deployment.github
|
||||
const opened = Date.parse(issue.created_at) >= Date.parse(since) && !sameName(issue.user.login, login)
|
||||
if (opened) await ctx.receive(issueTrigger(issue, login))
|
||||
await intakeComments(ctx, { number, isPr: false, url: issue.html_url }, since, round)
|
||||
nameThread(ctx, number, issue.title)
|
||||
}
|
||||
}
|
||||
if (isPr) {
|
||||
const pr = await pull(ctx, round, number)
|
||||
// Emit a PR description trigger for PRs opened since the cursor; its ID is the same as the one produced by the repository PR stream.
|
||||
const { login } = ctx.deployment.github
|
||||
if (pr.created_at && Date.parse(pr.created_at) >= Date.parse(since) && !sameName(pr.user.login, login)) {
|
||||
const opened = { number, title: pr.title ?? '', body: pr.body, user: pr.user, created_at: pr.created_at, html_url: pr.html_url }
|
||||
await ctx.receive(pullTrigger(opened, login))
|
||||
}
|
||||
await intakeComments(ctx, { number, isPr: true, url: pr.html_url, prAuthor: pr.user.login }, since, round)
|
||||
if (pr.title) nameThread(ctx, number, pr.title)
|
||||
}
|
||||
ctx.state.set(KV.github.cursor(number), cursorAt(round.started))
|
||||
ctx.threads.patch(`github:${number}`, { pollError: undefined })
|
||||
}
|
||||
/** Reads and observes a PR at most once per polling round. */
|
||||
function pull(ctx: SourceContext, round: Round, number: number): Promise<Pull> {
|
||||
let read = round.pulls.get(number)
|
||||
if (!read) {
|
||||
read = readPull(ctx, number, round.signal).then(async pr => { await observePull(ctx, pr); return pr })
|
||||
round.pulls.set(number, read)
|
||||
}
|
||||
return read
|
||||
}
|
||||
async function intakeComments(ctx: SourceContext, ref: PullRef, since: string, round: Round): Promise<void> {
|
||||
const commands = ctx.commands.withSignal(round.signal)
|
||||
const { login } = ctx.deployment.github
|
||||
const kinds = ref.isPr ? ['conversation', 'inline', 'review'] as const : ['conversation'] as const
|
||||
for (const kind of kinds) {
|
||||
const suffix = `&since=${encodeURIComponent(since)}`
|
||||
const path = kind === 'conversation' ? `issues/${ref.number}/comments?per_page=100${suffix}` : kind === 'inline' ? `pulls/${ref.number}/comments?per_page=100${suffix}` : `pulls/${ref.number}/reviews?per_page=100`
|
||||
const comments = await commands.pages(CommentSchema, path, round.tally)
|
||||
for (const comment of comments) {
|
||||
// Stopping mid-read throws so the thread's cursor stays where it was.
|
||||
if (stopped(ctx, round)) throw new Error('The GitHub Source stopped during the poll')
|
||||
const timestamp = Date.parse(comment.updated_at ?? comment.submitted_at ?? comment.created_at ?? '')
|
||||
if (!Number.isFinite(timestamp) || timestamp < Date.parse(since)) continue
|
||||
if (sameName(comment.user.login, login)) continue
|
||||
// A review decision without a body still reaches the ticket: a worker may be waiting for that approval.
|
||||
const trigger = commentTrigger(comment, ref, kind, login)
|
||||
if (trigger.text) await ctx.receive(trigger)
|
||||
}
|
||||
}
|
||||
}
|
||||
export async function pollTicketPulls(ctx: SourceContext, signal?: AbortSignal, round = newRound(signal)): Promise<void> {
|
||||
const numbers = [...new Set(ctx.tickets.open().flatMap(ticket => pullsOf(ticket, true)))]
|
||||
for (const number of numbers) {
|
||||
if (stopped(ctx, round)) return
|
||||
try {
|
||||
const pr = await pull(ctx, round, number)
|
||||
if (pr.state !== 'closed' && !pr.merged) await pollChecks(ctx, round, number, pr)
|
||||
} catch (error) { if (!stopped(ctx, round)) failed(ctx, round, number, error) }
|
||||
}
|
||||
}
|
||||
/** Polls a PR's CI checks; only changes after the first baseline observation become Triggers. */
|
||||
async function pollChecks(ctx: SourceContext, round: Round, number: number, pr: Pull): Promise<void> {
|
||||
const snapshot = await pullChecks(ctx.commands.withSignal(round.signal), pr.head.sha, round.tally)
|
||||
const fingerprint = key(JSON.stringify(snapshot))
|
||||
const previous = ctx.state.get(KV.github.ci(number))
|
||||
if (previous?.fingerprint === fingerprint) return
|
||||
const revision = previous ? previous.revision + 1 : 0
|
||||
if (previous) await ctx.receive(ciTrigger(pr, revision, ciSummary(snapshot, `PR #${number}`, `github/pulls/${number}/checks`)))
|
||||
ctx.state.set(KV.github.ci(number), { fingerprint, revision })
|
||||
ctx.threads.describe(`github:${number}`, { facts: { ci: ciFact(ciCounts(snapshot)) } })
|
||||
}
|
||||
function nameThread(ctx: SourceContext, number: number, title: string): void {
|
||||
ctx.threads.patch(`github:${number}`, { title })
|
||||
ctx.threads.describe(`github:${number}`, { label: `#${number} ${title}`, facts: { number: { value: `#${number}` } } })
|
||||
}
|
||||
/** Polls subscribed Issue and PR Threads not already read in this round; a deleted (410) Issue is still polled within the horizon (costing one read) and does not fail the round. */
|
||||
export async function pollThreads(ctx: SourceContext, signal?: AbortSignal, round = newRound(signal)): Promise<void> {
|
||||
await pollEach(ctx, SOURCE, () => stopped(ctx, round), async thread => {
|
||||
try { await pollThread(ctx, round, thread) }
|
||||
catch (error) {
|
||||
if (stopped(ctx, round)) throw error
|
||||
if (deleted(error)) round.dropped.push(Number(thread.ref.id)); else round.failures.push(`${thread.key}: ${String(error)}`)
|
||||
throw error
|
||||
}
|
||||
}, thread => round.read.has(Number(thread.ref.id)))
|
||||
}
|
||||
async function pollThread(ctx: SourceContext, round: Round, thread: Thread): Promise<void> {
|
||||
// The default branch's CI Thread has no Issue or PR to read; its CI arrives as Triggers.
|
||||
if (thread.ref.id.startsWith('ci:')) return
|
||||
const number = Number(thread.ref.id)
|
||||
if (!number) throw new Error('Subscribed thread has no GitHub issue or PR number')
|
||||
round.read.add(number)
|
||||
if (typeof thread.meta.isPr !== 'boolean' || typeof thread.meta.title !== 'string') {
|
||||
const issue = await ctx.commands.withSignal(round.signal).api(IssueSchema, `issues/${number}`)
|
||||
ctx.threads.patch(thread.key, { number, isPr: !!issue.pull_request, url: issue.html_url })
|
||||
if (issue.title) nameThread(ctx, number, issue.title)
|
||||
thread = ctx.threads.get(thread.key) ?? thread
|
||||
}
|
||||
const checkpoint = KV.github.cursor(number)
|
||||
const since = ctx.state.get(checkpoint) ?? thread.createdAt
|
||||
if (thread.meta.isPr === true) {
|
||||
const pr = await pull(ctx, round, number)
|
||||
await intakeComments(ctx, { number, isPr: true, url: pr.html_url, prAuthor: pr.user.login }, since, round)
|
||||
} else await intakeComments(ctx, { number, isPr: false }, since, round)
|
||||
ctx.state.set(checkpoint, cursorAt(round.started))
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/** Host and context interfaces connecting Source plugins to the application; also imported by the web panel, so it must not import Node-only modules. */
|
||||
import type { ChannelRef, ReceiveResult, OutboundMessage, Thread, ThreadFact, ThreadRef, Ticket, Trigger } from '../core/index.ts'
|
||||
import type { Commands } from '../infra/commands.ts'
|
||||
import type { Deployment } from '../deployment.ts'
|
||||
import type { SourceRegistry } from './registry.ts'
|
||||
import type { SetupProblem } from '../setup.ts'
|
||||
import type { Family, Key } from '../kv.ts'
|
||||
import { fill } from '../infra/text.ts'
|
||||
|
||||
/** Fixed English health templates (`{name}` placeholders); the panel localizes each `code` in the interface language. */
|
||||
export const HEALTH_TEXT = {
|
||||
setupPending: 'Waiting for the deployment check',
|
||||
sourceOff: 'Off',
|
||||
feishuCliUnset: 'The Source is on, and its bot lark-cli is not set',
|
||||
feishuBotMissing: 'Waiting for the Feishu bot',
|
||||
pollFailures: '{count} consecutive failed polls; last: {error}',
|
||||
} as const
|
||||
export type HealthCode = keyof typeof HEALTH_TEXT
|
||||
export type Health = { state: 'starting' | 'ready' | 'paused' | 'error'; detail: string; at?: string; code?: HealthCode; values?: Record<string, string> }
|
||||
export function health(state: Health['state'], code: HealthCode, values: Record<string, string> = {}): Health {
|
||||
return { state, detail: fill(HEALTH_TEXT[code], values), code, ...Object.keys(values).length ? { values } : {} }
|
||||
}
|
||||
export interface SourceHost {
|
||||
readonly sources: SourceRegistry
|
||||
/** Returns the context for one Source kind; its `report` writes only the health entries declared by the kind. */
|
||||
context(kind: { kind: string; health: readonly string[] }): SourceContext
|
||||
}
|
||||
/** Display label, URL, and facts (`null` removes a fact) that a Source publishes on a Thread. */
|
||||
export interface ThreadDescription { label?: string; url?: string; facts?: Record<string, ThreadFact | null> }
|
||||
/** Application capabilities exposed to a Source kind for reading state, ingesting triggers, managing threads/tickets, and reporting health. */
|
||||
export interface SourceContext {
|
||||
readonly deployment: Deployment
|
||||
readonly commands: Commands
|
||||
readonly ready: Promise<void>
|
||||
/** True once setup resolved the repository and accounts; Sources poll only then. */
|
||||
configured(): boolean
|
||||
/** Setup problem keeping this Source's account from running; only the Feishu bot check reports one. */
|
||||
readonly problem?: SetupProblem | undefined
|
||||
/** Returns a Source's configuration while enabled, or undefined while disabled or uninstalled. */
|
||||
config<C extends Record<string, unknown>>(kind: string): C | undefined
|
||||
/** Ingests a trigger into the Engine and schedules the next evaluation pass; callers should await completion before advancing read cursors. */
|
||||
receive(trigger: Trigger): ReceiveResult | Promise<ReceiveResult>
|
||||
atomic<T>(work: () => T): T
|
||||
/** Source-scoped KV records (such as read cursors); setting `undefined` removes an entry. */
|
||||
state: { get<V>(key: Key<V>): V | undefined; set<V>(key: Key<V>, value: V | undefined): void; keys<V>(family: Family<V, never>): Key<V>[] }
|
||||
threads: {
|
||||
get(key: string): Thread | undefined
|
||||
subscribed(source: string): Thread[]
|
||||
describe(key: string, description: ThreadDescription): void
|
||||
/** Sets or removes (`undefined`) keys in a Thread's `meta` after rereading it; a missing Thread is left alone. */
|
||||
patch(key: string, meta: Record<string, unknown>): void
|
||||
}
|
||||
tickets: {
|
||||
get(id: string): Ticket | undefined
|
||||
/** Open tickets, newest first. */
|
||||
open(): Ticket[]
|
||||
/** Binds an open ticket to follow a Source Thread (`own` when opened by the ticket) so the Thread's close, not a ready report, ends the ticket. */
|
||||
bindThread(id: string, thread: ThreadRef, own: boolean): void
|
||||
/** Reports that a followed Thread closed at `at`; each open ticket following it ends once no `own` Thread remains open and no resumed work is still running. */
|
||||
threadClosed(thread: ThreadRef, outcome: 'merged' | 'closed', at: string): void
|
||||
}
|
||||
triggers: { get(id: string): Trigger | undefined; /** The newest Trigger on a Thread. */ latest(thread: string): Trigger | undefined }
|
||||
notify(message: OutboundMessage): void
|
||||
/** Reports the state of one of this kind's declared health entries; throws for undeclared entry names. */
|
||||
report(entry: string, health: Health): void
|
||||
}
|
||||
export const sourceContext = (host: SourceHost, kind: { kind: string; health: readonly string[] }): SourceContext => host.context(kind)
|
||||
@@ -0,0 +1,115 @@
|
||||
/** SourceKind plugin contract: configuration schema, UI form fields, policy integration, channel adapter, and thread context reader. */
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { z } from 'zod'
|
||||
import type { OutboundMessage, ScopeResult, Trigger } from '../core/index.ts'
|
||||
import type { Deployment } from '../deployment.ts'
|
||||
import type { Commands } from '../infra/commands.ts'
|
||||
import type { SourceContext, SourceHost } from './host.ts'
|
||||
|
||||
export interface Localized { zh: string; en: string }
|
||||
/** `advanced` folds a rarely changed field (such as a polling tunable) behind the card's Advanced section. */
|
||||
interface FieldBase { key: string; label: Localized; hint?: Localized; advanced?: true }
|
||||
/**
|
||||
* Configuration field specification; `rows` edits a list of records keyed by its first column, `choose` picks from
|
||||
* resolved source labels, and `fact` displays a read-only value from `facts`.
|
||||
*/
|
||||
export type FieldSpec =
|
||||
| FieldBase & { type: 'switch' }
|
||||
| FieldBase & { type: 'select'; options: { value: string; label: Localized }[] }
|
||||
| FieldBase & { type: 'number'; min: number; max: number }
|
||||
| FieldBase & { type: 'text'; pattern?: string; readonly?: boolean; choose?: Localized }
|
||||
| FieldBase & { type: 'fact'; settings?: 'deployment' }
|
||||
| FieldBase & { type: 'rows'; columns: ({ key: string; label: Localized; type: 'text'; pattern?: string; optional?: boolean; choose?: Localized; refresh?: Localized } | { key: string; label: Localized; type: 'select'; options: { value: string; label: Localized }[] })[] }
|
||||
|
||||
export interface VariableSpec { key: string; description: Localized }
|
||||
export interface EnvironmentInput<C> { deployment: Deployment; config: C; trigger?: Trigger }
|
||||
export interface AdmissionRowView { id: string; label: Localized | string; note: Localized; cells: { listedUnaddressed: boolean; unlistedAddressed: boolean; unlistedUnaddressed: boolean } }
|
||||
/** Policy extension for a source kind, defining allowed senders, admission table rows, and optional external roster sync. */
|
||||
export interface SourcePolicyPart<P extends Record<string, unknown>> {
|
||||
/** Validates the Source's part of the Policy, including `allowed` (senders who may originate work). */
|
||||
schema: z.ZodType<P>
|
||||
/** Form fields of the policy part; the first is `allowed`. */
|
||||
fields: FieldSpec[]
|
||||
/** Describes what the Source marks as addressed to the agent, which an allowed sender's explicit request requires. */
|
||||
explicit: Localized
|
||||
/** Fallback display labels for unlisted human or bot senders when raw sender IDs are not human-readable. */
|
||||
unnamed?: { person: Localized; bot: Localized }
|
||||
/** Receiving scopes of the Policy's admission table under `config` (matching `scope().row`); kinds without `rows` use the table's defaults. */
|
||||
rows?(config: Record<string, unknown>): AdmissionRowView[]
|
||||
/** Optional external roster sync (such as chat members); `ready` reports whether `part` names a target to refresh from. */
|
||||
sync?: { label: Localized; ready(part: P): boolean; run(host: SourceHost, part: P, signal: AbortSignal): Promise<P> }
|
||||
}
|
||||
/** UI link for a channel address; `triggerId` names the Trigger whose own deep link is preferred when available. */
|
||||
export interface ChannelLink { url: string; label: Localized; triggerId?: string }
|
||||
export type ChannelLinks = Partial<Pick<Deployment, 'repository' | 'feishu'>>
|
||||
/** Outbound messaging adapter for a source kind's threads. */
|
||||
export interface SourceChannel<C> {
|
||||
/** Whether a reply may still go to `address`; without it, every address may receive replies. */
|
||||
allowed?(address: Record<string, string>, config: C): boolean
|
||||
/**
|
||||
* Sends a message idempotently by ID; returns `'gone'` when the target conversation no longer accepts replies.
|
||||
* @throws when delivery failed but may succeed on a later retry.
|
||||
*/
|
||||
send(host: Pick<SourceContext, 'commands'>, message: OutboundMessage): Promise<'sent' | 'gone'>
|
||||
/** Returns the URL and label for `address`; `url` is empty when `links` lack the required deployment info. */
|
||||
link(address: Record<string, string>, links: ChannelLinks): ChannelLink
|
||||
}
|
||||
/** Normalized message returned by thread context readers; `bot` marks a non-human sender whose message can never authorize work on its own (at most an inferred candidate). */
|
||||
export interface ContextMessage {
|
||||
id: string; from: string; at: string; text: string
|
||||
bot?: true; deleted?: true; reply_to?: string; url?: string; path?: string; line?: number
|
||||
}
|
||||
export type ContextPartName = 'thread' | 'root' | 'parent'
|
||||
export interface ContextPart { messages: ContextMessage[]; next?: string | undefined; body: Record<string, unknown>; status?: string }
|
||||
/** Bounded thread context reader used by assessments to inspect messages and images around a trigger. */
|
||||
export interface ThreadContext<C> {
|
||||
/** The ID of the Trigger's own message among the messages of its Thread. */
|
||||
messageId(trigger: Trigger): string
|
||||
/** Reads a page of thread messages (`thread`, first page when `cursor` is omitted), the topic root (`root`), or the replied-to message (`parent`). */
|
||||
read(commands: Commands, trigger: Trigger, part: ContextPartName, cursor: string | undefined, config: C): Promise<ContextPart>
|
||||
/** Extracts image keys from message text that `image` can read; omitted for kinds without image support. */
|
||||
images?(text: string): string[]
|
||||
/** Reads one image of a message in the Trigger's Thread as an attachment for `agent`. */
|
||||
image?(agent: Agent, commands: Commands, trigger: Trigger, message: { id: string; text: string }, key: string, stateDir: string,
|
||||
signal: AbortSignal): Promise<unknown>
|
||||
}
|
||||
export interface TriggerContext {
|
||||
readonly messageId: string
|
||||
read(commands: Commands, part: ContextPartName, cursor?: string): Promise<ContextPart>
|
||||
images(text: string): string[]
|
||||
/** @throws for a kind without image support. */
|
||||
image(agent: Agent, commands: Commands, message: { id: string; text: string }, key: string, stateDir: string,
|
||||
signal: AbortSignal): Promise<unknown>
|
||||
}
|
||||
/** Pluggable source kind descriptor registered with SourceRegistry. */
|
||||
export interface SourceKind<C extends Record<string, unknown> = Record<string, unknown>> {
|
||||
/** Stable ID; a default instance with the same ID exists when none is stored. */
|
||||
kind: string
|
||||
title: Localized
|
||||
description: Localized
|
||||
/** Read-only status facts derived from `deployment` (undefined while waiting for setup), keyed by field key. */
|
||||
facts?(deployment: Deployment | undefined): Record<string, Localized | string>
|
||||
/** Validates stored and submitted configuration and fills defaults. */
|
||||
schema: z.ZodType<C>
|
||||
fields: FieldSpec[]
|
||||
/** Health entry names owned by this kind under `health[...]`; no other kind or core loop may report them. */
|
||||
health: string[]
|
||||
/** Listening rule for a trigger (or recorded Mandate basis) of this kind; kinds without `scope` listen to everything they produce and observe none of it. */
|
||||
scope?(input: { meta: Record<string, unknown> }, config: C): ScopeResult
|
||||
/** Outbound reply adapter; kinds without `channel` produce no replies or channel checks. */
|
||||
channel?: SourceChannel<C>
|
||||
/** Bounded context reader for assessments; assessments of a kind without `threadContext` read none. */
|
||||
threadContext?: ThreadContext<C>
|
||||
/** Maps a search query to candidate Thread keys (such as `github:12`) and Trigger search terms (such as a Feishu `om_` ID). */
|
||||
find?(query: string): { threads: string[]; terms: string[] }
|
||||
/** Returns the ID of the Trigger whose message this Trigger replies to, when derivable without a network read. */
|
||||
repliesTo?(trigger: Trigger): string | undefined
|
||||
/** Policy section for kinds whose senders have their own identity. */
|
||||
policy?: SourcePolicyPart<Record<string, unknown> & { allowed: { id: string; name?: string }[] }>
|
||||
/** Rereads display names for row keys (such as chat names), stores the resolved names in `KV.sources.labels(kind)`, and returns them. */
|
||||
refreshLabels?(host: SourceHost, signal?: AbortSignal): Promise<Record<string, string>>
|
||||
/** Variables the kind registers with the execution environment and how their values are computed. */
|
||||
environment?: { variables: readonly VariableSpec[]; values(input: EnvironmentInput<C>): Record<string, string> }
|
||||
}
|
||||
|
||||
export interface SourceInstance<C extends Record<string, unknown> = Record<string, unknown>> { id: string; kind: string; enabled: boolean; config: C }
|
||||
@@ -0,0 +1,40 @@
|
||||
/** Shared polling helpers for subscribed threads, overlap-safe cursors, and timer-friendly delays. */
|
||||
import { liveTicket, type Thread } from '../core/index.ts'
|
||||
import type { SourceContext } from './host.ts'
|
||||
|
||||
/** How long a subscribed Thread with no live Ticket continues to be polled after its last Trigger or Ticket end. */
|
||||
export const POLL_HORIZON_MS = 14 * 24 * 3600_000
|
||||
/** True when a subscribed thread has a live ticket or recent activity/completion within `POLL_HORIZON_MS`. */
|
||||
export function polled(ctx: SourceContext, thread: Thread, now = Date.now()): boolean {
|
||||
const tickets = thread.subscriptions.map(id => ctx.tickets.get(id))
|
||||
if (tickets.some(ticket => !!ticket && liveTicket(ticket))) return true
|
||||
const times = [thread.lastAt ?? thread.createdAt, ...tickets.map(ticket => ticket?.ended?.at ?? '')]
|
||||
.map(Date.parse).filter(Number.isFinite)
|
||||
return now - Math.max(...times) < POLL_HORIZON_MS
|
||||
}
|
||||
/** Steps a poll start timestamp back by 5 seconds so late-arriving remote events are not missed; deterministic trigger IDs deduplicate overlaps. */
|
||||
export const cursorAt = (started: string): string => new Date(Date.parse(started) - 5000).toISOString()
|
||||
/** Polls each subscribed Thread of `source` within the horizon independently; records failures on the Thread as `pollError` (unless `stopped()`) and clears `pollError` on success. */
|
||||
export async function pollThreads(ctx: SourceContext, source: string, stopped: () => boolean, poll: (thread: Thread) => Promise<void>,
|
||||
skip: (thread: Thread) => boolean = () => false): Promise<void> {
|
||||
for (const thread of ctx.threads.subscribed(source)) {
|
||||
if (stopped()) return
|
||||
if (skip(thread) || !polled(ctx, thread)) continue
|
||||
try {
|
||||
await poll(thread)
|
||||
ctx.threads.patch(thread.key, { pollError: undefined })
|
||||
} catch (error) {
|
||||
if (stopped()) return
|
||||
ctx.threads.patch(thread.key, { pollError: String(error) })
|
||||
}
|
||||
}
|
||||
}
|
||||
/** Waits `ms` or until `signal` aborts using global `setTimeout` so test fake timers can drive polling loops. */
|
||||
export function pause(ms: number, signal: AbortSignal): Promise<void> {
|
||||
return new Promise(resolve => {
|
||||
if (signal.aborted) { resolve(); return }
|
||||
const stop = () => { clearTimeout(timer); resolve() }
|
||||
const timer = setTimeout(() => { signal.removeEventListener('abort', stop); resolve() }, ms)
|
||||
signal.addEventListener('abort', stop, { once: true })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/** Registry of installed Source kinds, their configured instances, and the Engine's SourceScope implementation. */
|
||||
import type { ChannelRef, OutboundMessage, ScopeResult, SourceScope, Trigger } from '../core/index.ts'
|
||||
import type { Commands } from '../infra/commands.ts'
|
||||
import { HealthBoard, type HealthEntry } from '../infra/health.ts'
|
||||
import { AdmissionTableSchema, type Settings } from '../policy.ts'
|
||||
import type { ChannelLink, ChannelLinks, SourceInstance, SourceKind, TriggerContext, VariableSpec } from './kind.ts'
|
||||
|
||||
|
||||
/**
|
||||
* Manages registered SourceKind plugins, validates their instance and policy configurations, and implements SourceScope for the Engine.
|
||||
* Claims each registered kind's health entries on HealthBoard and mediates access to source channels, links, environment variables, and thread context readers for Runtime, Settings, Outbox, and MasterAssessor.
|
||||
*/
|
||||
export class SourceRegistry implements SourceScope {
|
||||
private readonly kinds = new Map<string, SourceKind>()
|
||||
private readonly health = new Map<string, HealthEntry>()
|
||||
constructor(private readonly settings: () => Settings, private readonly board: Pick<HealthBoard, 'claim'> = new HealthBoard(),
|
||||
private readonly registered: (kind: SourceKind) => () => void = () => () => {}) {}
|
||||
|
||||
/** Registers a SourceKind, claims its health entries on HealthBoard (rejecting `sync:` prefixes owned by Settings), and returns an unregister disposer. */
|
||||
register<C extends Record<string, unknown>>(kind: SourceKind<C>): () => void {
|
||||
if (this.kinds.has(kind.kind)) throw new Error(`Source kind already registered: ${kind.kind}`)
|
||||
const refused = (name: string) => new Error(`Source kind ${kind.kind} declares the health entry ${name}, which another part reports`)
|
||||
const synced = kind.health.find(name => name.startsWith('sync:'))
|
||||
if (synced) throw refused(synced)
|
||||
const entries = new Map<string, HealthEntry>()
|
||||
const release = () => { for (const [name, entry] of entries) { entry.release(); this.health.delete(name) } }
|
||||
for (const name of kind.health) {
|
||||
try { entries.set(name, this.board.claim(name, { state: 'starting', detail: '' })) }
|
||||
catch { release(); throw refused(name) }
|
||||
}
|
||||
for (const [name, entry] of entries) this.health.set(name, entry)
|
||||
this.kinds.set(kind.kind, kind as unknown as SourceKind)
|
||||
const unregistered = this.registered(kind as unknown as SourceKind)
|
||||
return () => { this.kinds.delete(kind.kind); release(); unregistered() }
|
||||
}
|
||||
/**
|
||||
* Returns the active health entries for `names`. A Source context writes through the entries it received when built,
|
||||
* so reports sent after that registration ended do nothing even if the kind registers again.
|
||||
*/
|
||||
healthEntries(names: readonly string[]): Map<string, HealthEntry> {
|
||||
return new Map(names.flatMap(name => { const entry = this.health.get(name); return entry ? [[name, entry] as const] : [] }))
|
||||
}
|
||||
kind(id: string): SourceKind | undefined { return this.kinds.get(id) }
|
||||
/** Every registered kind's instance ordered by ID, followed by stored instances whose kind is not installed (with their config kept as stored). */
|
||||
instances(settings = this.settings()): (SourceInstance & { installed: boolean })[] {
|
||||
const installed = [...this.kinds.values()].sort((a, b) => a.kind.localeCompare(b.kind)).map(kind => ({ ...this.resolve(kind, settings.sources[kind.kind]), installed: true }))
|
||||
const orphaned = Object.entries(settings.sources).filter(([id]) => !this.kinds.has(id)).map(([id, stored]) => ({ id, kind: id, enabled: stored.enabled, config: stored.config, installed: false }))
|
||||
return [...installed, ...orphaned]
|
||||
}
|
||||
instance<C extends Record<string, unknown>>(id: string, settings = this.settings()): SourceInstance<C> | undefined {
|
||||
const kind = this.kinds.get(id)
|
||||
return kind ? this.resolve(kind, settings.sources[id]) as SourceInstance<C> : undefined
|
||||
}
|
||||
validate(id: string, value: { enabled: boolean; config: unknown }): { enabled: boolean; config: Record<string, unknown> } {
|
||||
const kind = this.kinds.get(id)
|
||||
if (!kind) throw new Error(`Source kind not installed: ${id}`)
|
||||
return { enabled: value.enabled, config: kind.schema.parse(value.config) }
|
||||
}
|
||||
/** Validates each Source's part of a submitted Policy against its kind; policy parts for uninstalled Source kinds are kept as stored. */
|
||||
validatePolicy(parts: Record<string, Record<string, unknown>>): Record<string, Record<string, unknown> & { allowed: { id: string; name?: string }[] }> {
|
||||
return Object.fromEntries(Object.entries(parts).map(([id, part]) => {
|
||||
const policy = this.kinds.get(id)?.policy
|
||||
// `admission` is validated by the Policy's own schema (shared across all kinds), while the kind validates the rest of its part.
|
||||
const { admission, ...rest } = part
|
||||
return [id, policy ? { ...policy.schema.parse(rest), admission: AdmissionTableSchema.parse(admission ?? {}) } : part as Record<string, unknown> & { allowed: { id: string; name?: string }[] }]
|
||||
}))
|
||||
}
|
||||
policyPart(id: string, settings = this.settings()) {
|
||||
const policy = this.kinds.get(id)?.policy
|
||||
if (!policy) return undefined
|
||||
const { admission, ...rest } = settings.policy.sources[id] ?? {}
|
||||
const parsed = policy.schema.safeParse(rest)
|
||||
return { ...parsed.success ? parsed.data : policy.schema.parse({}), admission: AdmissionTableSchema.safeParse(admission ?? {}).data ?? {} }
|
||||
}
|
||||
channelAllowed(channel: { source: string; address: Record<string, string> }, settings = this.settings()): boolean {
|
||||
const kind = this.kinds.get(channel.source)
|
||||
if (!kind) return false
|
||||
const instance = this.resolve(kind, settings.sources[channel.source])
|
||||
return instance.enabled && (kind.channel?.allowed?.(channel.address, instance.config) ?? true)
|
||||
}
|
||||
/** Environment variable declarations for every installed kind, whether enabled or disabled. */
|
||||
declarations(): { kind: string; variables: readonly VariableSpec[] }[] {
|
||||
return [...this.kinds.values()].sort((a, b) => a.kind.localeCompare(b.kind)).flatMap(kind => kind.environment ? [{ kind: kind.kind, variables: kind.environment.variables }] : [])
|
||||
}
|
||||
environments(settings = this.settings()) {
|
||||
return [...this.kinds.values()].sort((a, b) => a.kind.localeCompare(b.kind)).flatMap(kind => {
|
||||
const instance = this.resolve(kind, settings.sources[kind.kind])
|
||||
return kind.environment && instance.enabled ? [{ kind, environment: kind.environment, config: instance.config }] : []
|
||||
})
|
||||
}
|
||||
/**
|
||||
* Sends a message idempotently through its Channel's kind; returns `'waiting'` while the kind is not installed, or
|
||||
* `'gone'` when the target (or a kind without channels) can never receive it.
|
||||
*/
|
||||
async send(message: OutboundMessage, commands: Commands): Promise<'sent' | 'gone' | 'waiting'> {
|
||||
const kind = this.kinds.get(message.channel.source)
|
||||
if (!kind) return 'waiting'
|
||||
return kind.channel ? kind.channel.send({ commands }, message) : 'gone'
|
||||
}
|
||||
link(channel: ChannelRef, links: ChannelLinks): ChannelLink {
|
||||
const link = this.kinds.get(channel.source)?.channel?.link(channel.address, links)
|
||||
return link ?? { url: '', label: { zh: channel.source, en: channel.source } }
|
||||
}
|
||||
find(query: string): { threads: string[]; terms: string[] } {
|
||||
const found = [...this.kinds.values()].flatMap(kind => kind.find ? [kind.find(query)] : [])
|
||||
return { threads: [...new Set(found.flatMap(item => item.threads))], terms: [...new Set(found.flatMap(item => item.terms))] }
|
||||
}
|
||||
repliesTo(trigger: Trigger): string | undefined { return this.kinds.get(trigger.source)?.repliesTo?.(trigger) }
|
||||
threadContext(trigger: Trigger, settings = this.settings()): TriggerContext | undefined {
|
||||
const kind = this.kinds.get(trigger.source)
|
||||
const context = kind?.threadContext
|
||||
if (!kind || !context) return undefined
|
||||
const { config } = this.resolve(kind, settings.sources[kind.kind])
|
||||
return {
|
||||
messageId: context.messageId(trigger),
|
||||
read: (commands, part, cursor) => context.read(commands, trigger, part, cursor, config),
|
||||
images: text => context.images?.(text) ?? [],
|
||||
image: async (agent, commands, message, key, stateDir, signal) => {
|
||||
if (!context.image) throw new Error(`The ${kind.kind} Source has no images`)
|
||||
return context.image(agent, commands, trigger, message, key, stateDir, signal)
|
||||
},
|
||||
}
|
||||
}
|
||||
scope(input: { source: string; meta: Record<string, unknown> }, settings = this.settings()): ScopeResult {
|
||||
const kind = this.kinds.get(input.source)
|
||||
const stored = settings.sources[input.source]
|
||||
// Without its kind plugin the stored scope cannot be evaluated, so a configured Source whose plugin is missing listens to nothing.
|
||||
if (!kind) return stored ? { listened: false, reason: 'source_not_installed' } : { listened: true, observed: false }
|
||||
const instance = this.resolve(kind, stored)
|
||||
if (!instance.enabled) return { listened: false, reason: 'source_disabled' }
|
||||
return kind.scope ? kind.scope({ meta: input.meta }, instance.config) : { listened: true, observed: false }
|
||||
}
|
||||
/** Falls back to schema defaults and disables the instance when stored configuration fails validation. */
|
||||
private resolve(kind: SourceKind, stored: { enabled: boolean; config: Record<string, unknown> } | undefined): SourceInstance {
|
||||
const parsed = kind.schema.safeParse(stored?.config ?? {})
|
||||
return { id: kind.kind, kind: kind.kind, enabled: parsed.success && (stored?.enabled ?? true), config: parsed.success ? parsed.data : kind.schema.parse({}) }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user