From 9ab42427af805aaa89cedf4d62bacd9f20e6597f Mon Sep 17 00:00:00 2001 From: joshavant <830519+joshavant@users.noreply.github.com> Date: Fri, 24 Apr 2026 19:48:48 -0500 Subject: [PATCH 01/19] feat(kiloclaw): deliver Morning Briefing to configured channels Send generated briefings to configured Telegram, Discord, and Slack routes with channel-friendly formatting and delivery status reporting so users can receive briefings where they work. Add robust routing, timeout/retry hardening, and run-path warmup handling so generation success is decoupled from delivery flakiness. --- .../app/(app)/claw/components/SettingsTab.tsx | 25 + apps/web/src/lib/kiloclaw/types.ts | 11 + .../src/briefing-utils.test.ts | 26 + .../src/briefing-utils.ts | 40 ++ .../src/index.lifecycle.test.ts | 448 +++++++++++++++++- .../kiloclaw-morning-briefing/src/index.ts | 398 +++++++++++++++- .../gateway-controller-types.ts | 13 + .../kiloclaw-instance/gateway.test.ts | 51 +- .../kiloclaw-instance/gateway.ts | 3 +- .../routes/platform-morning-briefing.test.ts | 91 ++++ services/kiloclaw/src/routes/platform.ts | 17 +- 11 files changed, 1106 insertions(+), 17 deletions(-) diff --git a/apps/web/src/app/(app)/claw/components/SettingsTab.tsx b/apps/web/src/app/(app)/claw/components/SettingsTab.tsx index 3196ac0b5..063585088 100644 --- a/apps/web/src/app/(app)/claw/components/SettingsTab.tsx +++ b/apps/web/src/app/(app)/claw/components/SettingsTab.tsx @@ -516,6 +516,13 @@ function MorningBriefingCard({ linear: { configured: boolean; summary: string }; web: { configured: boolean; summary: string }; }; + lastDelivery?: Array<{ + channel: 'telegram' | 'discord' | 'slack'; + status: 'sent' | 'skipped' | 'failed'; + reason?: 'missing_target' | 'ambiguous_target' | 'send_failed' | 'config_unavailable'; + error?: string; + target?: string; + }>; } | undefined; fallbackReadiness: { @@ -623,6 +630,7 @@ function MorningBriefingCard({ const showScheduleDetails = !isWarmupState && hasSchedule && desiredEnabled; const controlsEnabled = actionsReady && !isWarmupState; const canUseBriefingControls = controlsEnabled && desiredEnabled; + const lastDelivery = briefingStatus?.lastDelivery ?? []; return (
@@ -727,6 +735,23 @@ function MorningBriefingCard({

{sourceSummaryText}

+ {lastDelivery.length > 0 && ( +

+ Last delivery:{' '} + {lastDelivery + .map(entry => { + if (entry.status === 'sent') { + return `${entry.channel}: sent`; + } + if (entry.status === 'skipped') { + return `${entry.channel}: skipped (${entry.reason ?? 'missing_target'})`; + } + return `${entry.channel}: failed${entry.error ? ` (${entry.error})` : ''}`; + }) + .join(' • ')} +

+ )} + {requestedDay && (
{isReading ? ( diff --git a/apps/web/src/lib/kiloclaw/types.ts b/apps/web/src/lib/kiloclaw/types.ts index 90ac7a3ec..080a0fea5 100644 --- a/apps/web/src/lib/kiloclaw/types.ts +++ b/apps/web/src/lib/kiloclaw/types.ts @@ -408,6 +408,15 @@ export type MorningBriefingSourceReadiness = { summary: string; }; +export type MorningBriefingDeliveryResult = { + channel: 'telegram' | 'discord' | 'slack'; + status: 'sent' | 'skipped' | 'failed'; + target?: string; + accountId?: string; + reason?: 'missing_target' | 'ambiguous_target' | 'send_failed' | 'config_unavailable'; + error?: string; +}; + export type MorningBriefingStatusResponse = { ok: boolean; enabled?: boolean; @@ -427,6 +436,7 @@ export type MorningBriefingStatusResponse = { linear: MorningBriefingSourceReadiness; web: MorningBriefingSourceReadiness; }; + lastDelivery?: MorningBriefingDeliveryResult[]; code?: string; retryAfterSec?: number; error?: string; @@ -441,6 +451,7 @@ export type MorningBriefingActionResponse = { date?: string; filePath?: string; failures?: string[]; + delivery?: MorningBriefingDeliveryResult[]; code?: string; retryAfterSec?: number; error?: string; diff --git a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/briefing-utils.test.ts b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/briefing-utils.test.ts index 677b95f44..717d306ad 100644 --- a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/briefing-utils.test.ts +++ b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/briefing-utils.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { buildBriefingMarkdown, + formatBriefingMarkdownForMessage, formatDateKey, offsetDateKey, resolveBriefingPath, @@ -47,4 +48,29 @@ describe('briefing-utils', () => { const filePath = resolveBriefingPath('/tmp/briefings', '2026-04-23'); expect(filePath.endsWith('/briefings/2026-04-23.md')).toBe(true); }); + + it('adapts briefing markdown into channel-friendly text', () => { + const markdown = [ + '# Morning Briefing - 2026-04-23', + '', + '## GitHub', + '- [Fix flaky build](https://example.com/issue/1) (updated 2026-04-23)', + '', + '## Source Status', + '- github: [ok] Fetched 1 open issue', + '', + '_Generated at 2026-04-23T07:00:01.000Z_', + ].join('\n'); + + const message = formatBriefingMarkdownForMessage(markdown); + + expect(message).toContain('Morning Briefing - 2026-04-23'); + expect(message).toContain('GitHub'); + expect(message).toContain( + '• Fix flaky build - https://example.com/issue/1 (updated 2026-04-23)' + ); + expect(message).toContain('Generated at 2026-04-23T07:00:01.000Z'); + expect(message).not.toContain('# '); + expect(message).not.toContain('['); + }); }); diff --git a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/briefing-utils.ts b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/briefing-utils.ts index f9db8dabc..0f355983e 100644 --- a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/briefing-utils.ts +++ b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/briefing-utils.ts @@ -88,3 +88,43 @@ export function buildBriefingMarkdown(params: { return lines.join('\n'); } + +function convertInlineMarkdownToText(line: string): string { + const withLinksExpanded = line.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1 - $2'); + return withLinksExpanded + .replace(/\[(ok|error|skipped)\]/gi, '$1') + .replace(/\*\*([^*]+)\*\*/g, '$1') + .replace(/`([^`]+)`/g, '$1'); +} + +export function formatBriefingMarkdownForMessage(markdown: string): string { + const transformedLines = markdown.split(/\r?\n/).map(rawLine => { + const heading = /^#{1,2}\s+(.+)$/.exec(rawLine); + if (heading) { + return heading[1]?.trim() ?? ''; + } + + if (/^_.*_$/.test(rawLine.trim())) { + return rawLine.trim().slice(1, -1); + } + + if (rawLine.startsWith('- ')) { + return `• ${convertInlineMarkdownToText(rawLine.slice(2))}`; + } + + return convertInlineMarkdownToText(rawLine); + }); + + const compacted: string[] = []; + let previousBlank = false; + for (const line of transformedLines) { + const blank = line.trim().length === 0; + if (blank && previousBlank) { + continue; + } + compacted.push(line); + previousBlank = blank; + } + + return compacted.join('\n').trim(); +} diff --git a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/index.lifecycle.test.ts b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/index.lifecycle.test.ts index 8d671dd72..e41c68935 100644 --- a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/index.lifecycle.test.ts +++ b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/index.lifecycle.test.ts @@ -33,7 +33,14 @@ type TestHarness = { commandHandler: (ctx: { args?: string }) => Promise<{ text: string }>; statusHttpHandler: (_req: unknown, res: FakeResponse) => Promise; enableHttpHandler: (req: unknown, res: FakeResponse) => Promise; + runHttpHandler: (_req: unknown, res: FakeResponse) => Promise; cronJobs: CronJob[]; + sentMessages: Array<{ + channel: string; + target: string; + accountId?: string; + message: string; + }>; runCommandWithTimeout: ReturnType; }; @@ -63,6 +70,12 @@ async function createHarness(options?: { disableCommandFails?: boolean; preloadedConfig?: Record; preloadedStatus?: Record; + githubAuthReady?: boolean; + githubIssues?: Array<{ title: string; url: string; updatedAt?: string }>; + channelsConfig?: Record; + messageSendFailures?: Partial>; + messageSendFailureCounts?: Partial>; + omitRuntimeChannelsConfig?: boolean; }): Promise { const { default: morningBriefingPlugin } = await import('./index'); const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), 'morning-briefing-')); @@ -86,11 +99,81 @@ async function createHarness(options?: { let sequence = 0; const cronJobs: CronJob[] = []; + const sentMessages: Array<{ + channel: string; + target: string; + accountId?: string; + message: string; + }> = []; const runCommandWithTimeout = vi.fn(async (argv: string[]) => { if (argv[0] === 'gh' && argv[1] === 'auth' && argv[2] === 'status') { + if (options?.githubAuthReady) { + return { stdout: '', stderr: '', code: 0 }; + } return { stdout: '', stderr: 'not authenticated', code: 1 }; } + if (argv[0] === 'gh' && argv[1] === 'search' && argv[2] === 'issues') { + return { + stdout: JSON.stringify(options?.githubIssues ?? []), + stderr: '', + code: 0, + }; + } + + if ( + argv[0] === 'openclaw' && + argv[1] === 'config' && + argv[2] === 'get' && + argv[3] === 'channels' + ) { + if (!options?.channelsConfig) { + return { + stdout: '', + stderr: 'Config path not found: channels', + code: 1, + }; + } + return { + stdout: JSON.stringify(options.channelsConfig), + stderr: '', + code: 0, + }; + } + + if (argv[0] === 'openclaw' && argv[1] === 'message' && argv[2] === 'send') { + const channelIndex = argv.indexOf('--channel'); + const targetIndex = argv.indexOf('--target'); + const messageIndex = argv.indexOf('--message'); + const accountIndex = argv.indexOf('--account'); + const channel = channelIndex >= 0 ? (argv[channelIndex + 1] ?? '') : ''; + const target = targetIndex >= 0 ? (argv[targetIndex + 1] ?? '') : ''; + const message = messageIndex >= 0 ? (argv[messageIndex + 1] ?? '') : ''; + const accountId = accountIndex >= 0 ? argv[accountIndex + 1] : undefined; + if (channel && target && message) { + sentMessages.push({ channel, target, accountId, message }); + } + const configuredFailure = + channel === 'telegram' || channel === 'discord' || channel === 'slack' + ? options?.messageSendFailures?.[channel] + : undefined; + const configuredFailureCount = + channel === 'telegram' || channel === 'discord' || channel === 'slack' + ? options?.messageSendFailureCounts?.[channel] + : undefined; + if (configuredFailure && configuredFailureCount && configuredFailureCount > 0) { + if (!options?.messageSendFailureCounts) { + return { stdout: '', stderr: configuredFailure, code: 1 }; + } + options.messageSendFailureCounts[channel] = configuredFailureCount - 1; + return { stdout: '', stderr: configuredFailure, code: 1 }; + } + if (configuredFailure && configuredFailureCount === undefined) { + return { stdout: '', stderr: configuredFailure, code: 1 }; + } + return { stdout: JSON.stringify({ ok: true }), stderr: '', code: 0 }; + } + if (argv[0] === 'openclaw' && argv[1] === 'cron') { const subcommand = argv[2]; @@ -156,6 +239,7 @@ async function createHarness(options?: { let commandHandler: ((ctx: { args?: string }) => Promise<{ text: string }>) | null = null; let statusHttpHandler: ((_req: unknown, res: FakeResponse) => Promise) | null = null; let enableHttpHandler: ((req: unknown, res: FakeResponse) => Promise) | null = null; + let runHttpHandler: ((_req: unknown, res: FakeResponse) => Promise) | null = null; morningBriefingPlugin.register({ runtime: { @@ -168,6 +252,7 @@ async function createHarness(options?: { }, config: { agents: { defaults: { userTimezone: 'America/Chicago' } }, + ...(options?.omitRuntimeChannelsConfig ? {} : { channels: options?.channelsConfig ?? {} }), }, logger: { warn: vi.fn() }, registerCommand: (def: { handler: (ctx: { args?: string }) => Promise<{ text: string }> }) => { @@ -181,13 +266,15 @@ async function createHarness(options?: { statusHttpHandler = route.handler; } else if (route.path.endsWith('/enable')) { enableHttpHandler = route.handler; + } else if (route.path.endsWith('/run')) { + runHttpHandler = route.handler; } }, registerTool: vi.fn(), on: vi.fn(), } as never); - if (!commandHandler || !statusHttpHandler || !enableHttpHandler) { + if (!commandHandler || !statusHttpHandler || !enableHttpHandler || !runHttpHandler) { throw new Error('Failed to register command or HTTP handlers'); } @@ -196,7 +283,9 @@ async function createHarness(options?: { commandHandler, statusHttpHandler, enableHttpHandler, + runHttpHandler, cronJobs, + sentMessages, runCommandWithTimeout, }; } @@ -431,4 +520,361 @@ describe('morning briefing lifecycle', () => { const config = await readJson(path.join(harness.stateDir, 'morning-briefing', 'config.json')); expect(config.timezone).toBe('UTC'); }); + + it('sends adapted briefing message to configured channel targets and persists delivery metadata', async () => { + const harness = await createHarness({ + githubAuthReady: true, + githubIssues: [ + { + title: 'Fix failing deploy workflow', + url: 'https://github.com/Kilo-Org/cloud/issues/123', + updatedAt: '2026-04-24T10:00:00Z', + }, + ], + channelsConfig: { + telegram: { + enabled: true, + defaultTo: '-100123456', + }, + discord: { + enabled: true, + accounts: { + default: { + defaultTo: 'channel:1234567890', + }, + }, + }, + }, + }); + + const response = new FakeResponse(); + await harness.runHttpHandler({}, response); + expect(response.statusCode).toBe(200); + + const payload = JSON.parse(response.body) as { + ok: boolean; + delivery?: Array<{ channel: string; status: string; target?: string; accountId?: string }>; + }; + expect(payload.ok).toBe(true); + expect(payload.delivery).toEqual( + expect.arrayContaining([ + expect.objectContaining({ channel: 'telegram', status: 'sent', target: '-100123456' }), + expect.objectContaining({ + channel: 'discord', + status: 'sent', + target: 'channel:1234567890', + accountId: 'default', + }), + ]) + ); + + expect(harness.sentMessages).toHaveLength(2); + for (const sent of harness.sentMessages) { + expect(sent.message).toContain('Morning Briefing -'); + expect(sent.message).toContain('GitHub'); + expect(sent.message).toContain('• '); + expect(sent.message).not.toContain('# '); + expect(sent.message).toContain('https://github.com/Kilo-Org/cloud/issues/123'); + expect(sent.message).not.toContain('Repository:'); + } + + const statusPayload = new FakeResponse(); + await harness.statusHttpHandler({}, statusPayload); + const statusBody = JSON.parse(statusPayload.body) as { + ok: boolean; + lastDelivery?: Array<{ channel: string; status: string }>; + }; + expect(statusBody.ok).toBe(true); + expect(statusBody.lastDelivery).toEqual( + expect.arrayContaining([ + expect.objectContaining({ channel: 'telegram', status: 'sent' }), + expect.objectContaining({ channel: 'discord', status: 'sent' }), + ]) + ); + }); + + it('marks missing default targets as skipped and send errors as failed without failing run', async () => { + const harness = await createHarness({ + githubAuthReady: true, + githubIssues: [ + { + title: 'Investigate queue latency', + url: 'https://github.com/Kilo-Org/cloud/issues/456', + updatedAt: '2026-04-24T12:00:00Z', + }, + ], + channelsConfig: { + telegram: { + enabled: true, + }, + slack: { + enabled: true, + defaultTo: 'channel:C123', + }, + }, + messageSendFailures: { + slack: 'slack send failed', + }, + }); + + const response = new FakeResponse(); + await harness.runHttpHandler({}, response); + expect(response.statusCode).toBe(200); + + const payload = JSON.parse(response.body) as { + ok: boolean; + delivery?: Array<{ channel: string; status: string; reason?: string; error?: string }>; + }; + expect(payload.ok).toBe(true); + expect(payload.delivery).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + channel: 'telegram', + status: 'skipped', + reason: 'missing_target', + }), + expect.objectContaining({ + channel: 'slack', + status: 'failed', + reason: 'send_failed', + }), + ]) + ); + const slackFailure = payload.delivery?.find(entry => entry.channel === 'slack'); + expect(slackFailure?.error).toContain('slack send failed'); + }); + + it('uses single configured telegram group as fallback target when defaultTo is missing', async () => { + const harness = await createHarness({ + githubAuthReady: true, + githubIssues: [ + { + title: 'Review release checklist', + url: 'https://github.com/Kilo-Org/cloud/issues/789', + updatedAt: '2026-04-24T13:00:00Z', + }, + ], + channelsConfig: { + telegram: { + enabled: true, + groups: { + '-5055658641': { + requireMention: false, + }, + }, + }, + }, + }); + + const response = new FakeResponse(); + await harness.runHttpHandler({}, response); + expect(response.statusCode).toBe(200); + + const payload = JSON.parse(response.body) as { + ok: boolean; + delivery?: Array<{ channel: string; status: string; target?: string }>; + }; + expect(payload.ok).toBe(true); + expect(payload.delivery).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + channel: 'telegram', + status: 'sent', + target: '-5055658641', + }), + ]) + ); + }); + + it('skips with ambiguous_target when multiple fallback destinations are available', async () => { + const harness = await createHarness({ + githubAuthReady: true, + githubIssues: [ + { + title: 'Investigate flaky integration test', + url: 'https://github.com/Kilo-Org/cloud/issues/790', + updatedAt: '2026-04-24T14:00:00Z', + }, + ], + channelsConfig: { + telegram: { + enabled: true, + groups: { + '-5055658641': { + requireMention: false, + }, + '-5055658642': { + requireMention: false, + }, + }, + }, + }, + }); + + const response = new FakeResponse(); + await harness.runHttpHandler({}, response); + expect(response.statusCode).toBe(200); + + const payload = JSON.parse(response.body) as { + ok: boolean; + delivery?: Array<{ channel: string; status: string; reason?: string }>; + }; + expect(payload.ok).toBe(true); + expect(payload.delivery).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + channel: 'telegram', + status: 'skipped', + reason: 'ambiguous_target', + }), + ]) + ); + expect(harness.sentMessages).toHaveLength(0); + }); + + it('uses runtime config channels for delivery without shelling out', async () => { + const harness = await createHarness({ + githubAuthReady: true, + githubIssues: [ + { + title: 'Confirm runtime config path', + url: 'https://github.com/Kilo-Org/cloud/issues/800', + updatedAt: '2026-04-24T15:00:00Z', + }, + ], + channelsConfig: { + telegram: { + enabled: true, + groups: { + '-5055658641': { + requireMention: false, + }, + }, + }, + }, + }); + + const response = new FakeResponse(); + await harness.runHttpHandler({}, response); + expect(response.statusCode).toBe(200); + + const payload = JSON.parse(response.body) as { + ok: boolean; + delivery?: Array<{ channel: string; status: string; target?: string }>; + }; + expect(payload.ok).toBe(true); + expect(payload.delivery).toEqual( + expect.arrayContaining([ + expect.objectContaining({ channel: 'telegram', status: 'sent', target: '-5055658641' }), + ]) + ); + expect( + harness.runCommandWithTimeout.mock.calls.some( + call => + Array.isArray(call[0]) && + call[0][0] === 'openclaw' && + call[0][1] === 'config' && + call[0][2] === 'get' && + call[0][3] === 'channels' + ) + ).toBe(false); + }); + + it('falls back to CLI channel config when runtime channels are unavailable', async () => { + const harness = await createHarness({ + githubAuthReady: true, + githubIssues: [ + { + title: 'Confirm CLI fallback path', + url: 'https://github.com/Kilo-Org/cloud/issues/801', + updatedAt: '2026-04-24T15:10:00Z', + }, + ], + channelsConfig: { + telegram: { + enabled: true, + groups: { + '-5055658641': { + requireMention: false, + }, + }, + }, + }, + omitRuntimeChannelsConfig: true, + }); + + const response = new FakeResponse(); + await harness.runHttpHandler({}, response); + expect(response.statusCode).toBe(200); + + const payload = JSON.parse(response.body) as { + ok: boolean; + delivery?: Array<{ channel: string; status: string; target?: string }>; + }; + expect(payload.ok).toBe(true); + expect(payload.delivery).toEqual( + expect.arrayContaining([ + expect.objectContaining({ channel: 'telegram', status: 'sent', target: '-5055658641' }), + ]) + ); + expect( + harness.runCommandWithTimeout.mock.calls.some( + call => + Array.isArray(call[0]) && + call[0][0] === 'openclaw' && + call[0][1] === 'config' && + call[0][2] === 'get' && + call[0][3] === 'channels' + ) + ).toBe(true); + }); + + it('retries timed-out delivery once before marking send_failed', async () => { + const harness = await createHarness({ + githubAuthReady: true, + githubIssues: [ + { + title: 'Retry flaky channel send', + url: 'https://github.com/Kilo-Org/cloud/issues/900', + updatedAt: '2026-04-25T00:00:00Z', + }, + ], + channelsConfig: { + telegram: { + enabled: true, + defaultTo: '-5055658641', + }, + }, + messageSendFailures: { + telegram: 'The operation was aborted due to timeout', + }, + messageSendFailureCounts: { + telegram: 1, + }, + }); + + const response = new FakeResponse(); + await harness.runHttpHandler({}, response); + expect(response.statusCode).toBe(200); + + const payload = JSON.parse(response.body) as { + ok: boolean; + delivery?: Array<{ channel: string; status: string }>; + }; + expect(payload.ok).toBe(true); + expect(payload.delivery).toEqual( + expect.arrayContaining([expect.objectContaining({ channel: 'telegram', status: 'sent' })]) + ); + + const sendCalls = harness.runCommandWithTimeout.mock.calls.filter( + call => + Array.isArray(call[0]) && + call[0][0] === 'openclaw' && + call[0][1] === 'message' && + call[0][2] === 'send' + ); + expect(sendCalls).toHaveLength(2); + expect(sendCalls[0]?.[1]).toMatchObject({ timeoutMs: 120_000 }); + expect(sendCalls[1]?.[1]).toMatchObject({ timeoutMs: 120_000 }); + }); }); diff --git a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/index.ts b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/index.ts index cb1bc30a8..c47646148 100644 --- a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/index.ts +++ b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/index.ts @@ -2,7 +2,12 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { Type } from '@sinclair/typebox'; import { definePluginEntry } from 'openclaw/plugin-sdk/plugin-entry'; -import { buildBriefingMarkdown, offsetDateKey, resolveBriefingPath } from './briefing-utils'; +import { + buildBriefingMarkdown, + formatBriefingMarkdownForMessage, + offsetDateKey, + resolveBriefingPath, +} from './briefing-utils'; import { filterEnabledBriefingJobs, pickCanonicalCronJobId, @@ -41,6 +46,7 @@ type StoredStatus = { lastPath: string | null; sourceSummary: Array<{ source: string; configured: boolean; ok: boolean; summary: string }>; failures: string[]; + lastDelivery: BriefingDeliveryResult[]; observedEnabled: boolean | null; reconcileState: 'idle' | 'in_progress' | 'succeeded' | 'failed'; lastReconcileAt: string | null; @@ -57,6 +63,25 @@ type SourceCollectionResult = { sectionLines: string[]; }; +type DeliveryChannel = 'telegram' | 'discord' | 'slack'; + +type BriefingDeliveryResult = { + channel: DeliveryChannel; + status: 'sent' | 'skipped' | 'failed'; + target?: string; + accountId?: string; + reason?: 'missing_target' | 'ambiguous_target' | 'send_failed' | 'config_unavailable'; + error?: string; +}; + +type DeliveryRoute = { + channel: DeliveryChannel; + target: string; + accountId?: string; +}; + +const DELIVERY_CHANNELS: DeliveryChannel[] = ['telegram', 'discord', 'slack']; + function asObject(value: unknown): Record { return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record) @@ -329,6 +354,7 @@ async function readStoredStatus(paths: { statusPath: string }): Promise typeof value === 'string') : [], + lastDelivery: Array.isArray(existing.lastDelivery) + ? existing.lastDelivery + .map(entry => asObject(entry)) + .map(entry => { + const channel = + entry.channel === 'telegram' || + entry.channel === 'discord' || + entry.channel === 'slack' + ? entry.channel + : null; + const status = + entry.status === 'sent' || entry.status === 'skipped' || entry.status === 'failed' + ? entry.status + : null; + if (!channel || !status) { + return null; + } + return { + channel, + status, + target: typeof entry.target === 'string' ? entry.target : undefined, + accountId: typeof entry.accountId === 'string' ? entry.accountId : undefined, + reason: + entry.reason === 'missing_target' || + entry.reason === 'ambiguous_target' || + entry.reason === 'send_failed' || + entry.reason === 'config_unavailable' + ? entry.reason + : undefined, + error: typeof entry.error === 'string' ? entry.error : undefined, + } satisfies BriefingDeliveryResult; + }) + .filter((entry): entry is BriefingDeliveryResult => entry !== null) + : [], observedEnabled: typeof existing.observedEnabled === 'boolean' ? existing.observedEnabled : null, reconcileState: @@ -398,6 +458,312 @@ async function patchStoredStatus( }); } +function normalizeDeliveryTarget(value: unknown): string | null { + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; + } + if (typeof value === 'number' && Number.isFinite(value)) { + return String(value); + } + return null; +} + +function toEnabledObjectEntries(value: unknown): Array<[string, Record]> { + const record = asObject(value); + return Object.entries(record) + .filter((entry): entry is [string, Record] => { + const [key, raw] = entry; + if (key.trim() === '' || key === '*') { + return false; + } + return typeof raw === 'object' && raw !== null && !Array.isArray(raw); + }) + .filter(([, raw]) => raw.enabled !== false); +} + +function collectTelegramFallbackTargets(rawChannelConfig: Record): string[] { + const groups = toEnabledObjectEntries(rawChannelConfig.groups).map(([groupId]) => groupId); + return groups; +} + +function collectDiscordFallbackTargets(rawChannelConfig: Record): string[] { + const guildEntries = toEnabledObjectEntries(rawChannelConfig.guilds); + const targets = guildEntries.flatMap(([, guildConfig]) => { + const channels = toEnabledObjectEntries(guildConfig.channels); + return channels.map(([channelId]) => `channel:${channelId}`); + }); + return targets; +} + +function collectSlackFallbackTargets(rawChannelConfig: Record): string[] { + const channels = toEnabledObjectEntries(rawChannelConfig.channels); + return channels.map(([channelId]) => `channel:${channelId}`); +} + +function collectFallbackTargets( + channel: DeliveryChannel, + rawChannelConfig: Record +): string[] { + if (channel === 'telegram') { + return collectTelegramFallbackTargets(rawChannelConfig); + } + if (channel === 'discord') { + return collectDiscordFallbackTargets(rawChannelConfig); + } + return collectSlackFallbackTargets(rawChannelConfig); +} + +function resolveDeliveryRoute(params: { + channel: DeliveryChannel; + channelsConfig: Record; +}): { + configured: boolean; + route: DeliveryRoute | null; + skipReason?: 'missing_target' | 'ambiguous_target'; +} { + const rawChannelConfig = asObject(params.channelsConfig[params.channel]); + if (Object.keys(rawChannelConfig).length === 0) { + return { configured: false, route: null }; + } + + if (rawChannelConfig.enabled === false) { + return { configured: false, route: null }; + } + + const accountsConfig = asObject(rawChannelConfig.accounts); + const defaultAccount = asObject(accountsConfig.default); + const defaultAccountTarget = normalizeDeliveryTarget(defaultAccount.defaultTo); + if (defaultAccountTarget) { + return { + configured: true, + route: { + channel: params.channel, + target: defaultAccountTarget, + accountId: 'default', + }, + }; + } + + const topLevelTarget = normalizeDeliveryTarget(rawChannelConfig.defaultTo); + if (topLevelTarget) { + return { + configured: true, + route: { + channel: params.channel, + target: topLevelTarget, + }, + }; + } + + const fallbackTargets = collectFallbackTargets(params.channel, rawChannelConfig); + if (fallbackTargets.length === 1) { + return { + configured: true, + route: { + channel: params.channel, + target: fallbackTargets[0], + }, + }; + } + + return { + configured: true, + route: null, + skipReason: fallbackTargets.length > 1 ? 'ambiguous_target' : 'missing_target', + }; +} + +function readChannelsConfigFromRuntimeConfig(config: unknown): Record | null { + const rawConfig = asObject(config); + if (!Object.prototype.hasOwnProperty.call(rawConfig, 'channels')) { + return null; + } + return asObject(rawConfig.channels); +} + +async function readChannelsConfig(api: { + config: unknown; + runtime: { + system: { + runCommandWithTimeout: ( + argv: string[], + options: { timeoutMs: number; cwd?: string } + ) => Promise<{ stdout: string; stderr: string; code: number | null }>; + }; + }; +}): Promise> { + const fromRuntimeConfig = readChannelsConfigFromRuntimeConfig(api.config); + if (fromRuntimeConfig) { + return fromRuntimeConfig; + } + + let lastError: unknown = null; + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + const { stdout } = await runCommand( + api, + ['openclaw', 'config', 'get', 'channels', '--json'], + 60_000 + ); + return asObject(JSON.parse(stdout)); + } catch (error) { + const errorText = error instanceof Error ? error.message : String(error); + if (errorText.includes('Config path not found: channels')) { + return {}; + } + lastError = error; + } + } + + if (lastError) { + throw lastError; + } + + return {}; +} + +function formatDeliverySummary(delivery: BriefingDeliveryResult[]): string[] { + if (delivery.length === 0) { + return ['- delivery: no configured messaging channels found']; + } + + return delivery.map(entry => { + const targetSuffix = entry.target ? ` (${entry.target})` : ''; + if (entry.status === 'sent') { + return `- delivery: ${entry.channel} sent${targetSuffix}`; + } + if (entry.status === 'skipped') { + return `- delivery: ${entry.channel} skipped (${entry.reason ?? 'unknown'})`; + } + return `- delivery: ${entry.channel} failed${targetSuffix}${entry.error ? `: ${entry.error}` : ''}`; + }); +} + +function isLikelyTimeoutError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return ( + message.includes('operation was aborted due to timeout') || + message.includes('timed out') || + message.includes('ETIMEDOUT') || + message.includes('Command failed') + ); +} + +async function deliverBriefingToConfiguredChannels( + api: { + config: unknown; + runtime: { + system: { + runCommandWithTimeout: ( + argv: string[], + options: { timeoutMs: number; cwd?: string } + ) => Promise<{ stdout: string; stderr: string; code: number | null }>; + }; + }; + logger: { warn?: (message: string) => void }; + }, + markdown: string +): Promise { + const messageText = formatBriefingMarkdownForMessage(markdown); + if (!messageText) { + return []; + } + + let channelsConfig: Record; + try { + channelsConfig = await readChannelsConfig(api); + } catch (error) { + const errorText = error instanceof Error ? error.message : String(error); + api.logger.warn?.(`Morning briefing delivery config read failed: ${errorText}`); + return DELIVERY_CHANNELS.map(channel => ({ + channel, + status: 'failed', + reason: 'config_unavailable', + error: errorText, + })); + } + + const delivery: BriefingDeliveryResult[] = []; + const routes: DeliveryRoute[] = []; + + for (const channel of DELIVERY_CHANNELS) { + const resolution = resolveDeliveryRoute({ channel, channelsConfig }); + if (!resolution.configured) { + continue; + } + if (!resolution.route) { + delivery.push({ + channel, + status: 'skipped', + reason: resolution.skipReason ?? 'missing_target', + }); + continue; + } + routes.push(resolution.route); + } + + for (const route of routes) { + const argv = [ + 'openclaw', + 'message', + 'send', + '--channel', + route.channel, + '--target', + route.target, + '--message', + messageText, + ]; + if (route.accountId) { + argv.push('--account', route.accountId); + } + try { + await runCommand(api, argv, 120_000); + delivery.push({ + channel: route.channel, + status: 'sent', + target: route.target, + accountId: route.accountId, + }); + } catch (error) { + if (isLikelyTimeoutError(error)) { + try { + await runCommand(api, argv, 120_000); + delivery.push({ + channel: route.channel, + status: 'sent', + target: route.target, + accountId: route.accountId, + }); + continue; + } catch (retryError) { + delivery.push({ + channel: route.channel, + status: 'failed', + reason: 'send_failed', + target: route.target, + accountId: route.accountId, + error: retryError instanceof Error ? retryError.message : String(retryError), + }); + continue; + } + } + + delivery.push({ + channel: route.channel, + status: 'failed', + reason: 'send_failed', + target: route.target, + accountId: route.accountId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + return delivery; +} + async function ensureCronJob( api: { runtime: { @@ -669,13 +1035,7 @@ async function collectWebSearch(api: { configured: true, ok: true, summary: `Fetched ${results.length} web results (${response.provider})`, - sectionLines: results - .slice(0, 6) - .map(item => - item.summary.length > 0 - ? `- [${item.title}](${item.url}) - ${item.summary}` - : `- [${item.title}](${item.url})` - ), + sectionLines: results.slice(0, 6).map(item => `- [${item.title}](${item.url})`), }; } catch (error) { return { @@ -793,6 +1153,7 @@ async function generateBriefing( }; }; config: unknown; + logger: { warn?: (message: string) => void }; }, dateKey: string ): Promise<{ @@ -801,6 +1162,7 @@ async function generateBriefing( markdown: string; sources: SourceCollectionResult[]; failures: string[]; + delivery: BriefingDeliveryResult[]; }> { const paths = getStatePaths(api); await ensureStorage(paths); @@ -841,6 +1203,19 @@ async function generateBriefing( const filePath = resolveBriefingPath(paths.briefingsDir, dateKey); await fs.writeFile(filePath, markdown, 'utf8'); + let delivery: BriefingDeliveryResult[]; + try { + delivery = await deliverBriefingToConfiguredChannels(api, markdown); + } catch (error) { + const errorText = error instanceof Error ? error.message : String(error); + api.logger.warn?.(`Morning briefing delivery failed unexpectedly: ${errorText}`); + delivery = DELIVERY_CHANNELS.map(channel => ({ + channel, + status: 'failed', + reason: 'config_unavailable', + error: errorText, + })); + } await patchStoredStatus(paths, { lastGeneratedDate: dateKey, @@ -853,6 +1228,7 @@ async function generateBriefing( summary: source.summary, })), failures, + lastDelivery: delivery, }); return { @@ -861,6 +1237,7 @@ async function generateBriefing( markdown, sources, failures, + delivery, }; } @@ -944,6 +1321,7 @@ async function getStatusSnapshot(api: { linear: { configured: boolean; summary: string }; web: { configured: boolean; summary: string }; }; + lastDelivery: BriefingDeliveryResult[]; reconcileState: 'idle' | 'in_progress' | 'succeeded' | 'failed'; lastReconcileAt: string | null; lastReconcileError: string | null; @@ -971,6 +1349,7 @@ async function getStatusSnapshot(api: { linear, web, }, + lastDelivery: status.lastDelivery, reconcileState: status.reconcileState, lastReconcileAt: status.lastReconcileAt, lastReconcileError: status.lastReconcileError, @@ -1218,6 +1597,7 @@ export default definePluginEntry({ `Generated briefing for ${result.dateKey}.`, `- file: ${result.filePath}`, ...result.failures.map(failure => `- note: ${failure}`), + ...formatDeliverySummary(result.delivery), ].join('\n'); } @@ -1322,6 +1702,7 @@ export default definePluginEntry({ `Morning briefing generated for ${result.dateKey}.`, `Saved to ${result.filePath}.`, ...result.failures.map(failure => `Note: ${failure}`), + ...formatDeliverySummary(result.delivery).map(line => line.replace(/^- /, '')), ].join('\n'), }, ], @@ -1468,6 +1849,7 @@ export default definePluginEntry({ date: result.dateKey, filePath: result.filePath, failures: result.failures, + delivery: result.delivery, }); } catch (error) { sendJson(res, 500, { diff --git a/services/kiloclaw/src/durable-objects/gateway-controller-types.ts b/services/kiloclaw/src/durable-objects/gateway-controller-types.ts index e1665a73e..c8b0e7b8c 100644 --- a/services/kiloclaw/src/durable-objects/gateway-controller-types.ts +++ b/services/kiloclaw/src/durable-objects/gateway-controller-types.ts @@ -104,6 +104,17 @@ const MorningBriefingSourceReadinessSchema = z.object({ summary: z.string(), }); +const MorningBriefingDeliverySchema = z.object({ + channel: z.enum(['telegram', 'discord', 'slack']), + status: z.enum(['sent', 'skipped', 'failed']), + target: z.string().optional(), + accountId: z.string().optional(), + reason: z + .enum(['missing_target', 'ambiguous_target', 'send_failed', 'config_unavailable']) + .optional(), + error: z.string().optional(), +}); + export const MorningBriefingStatusResponseSchema = z.object({ ok: z.boolean(), enabled: z.boolean().optional(), @@ -125,6 +136,7 @@ export const MorningBriefingStatusResponseSchema = z.object({ web: MorningBriefingSourceReadinessSchema, }) .optional(), + lastDelivery: z.array(MorningBriefingDeliverySchema).optional(), code: z.string().optional(), retryAfterSec: z.number().int().positive().optional(), error: z.string().optional(), @@ -139,6 +151,7 @@ export const MorningBriefingActionResponseSchema = z.object({ date: z.string().optional(), filePath: z.string().optional(), failures: z.array(z.string()).optional(), + delivery: z.array(MorningBriefingDeliverySchema).optional(), code: z.string().optional(), retryAfterSec: z.number().int().positive().optional(), error: z.string().optional(), diff --git a/services/kiloclaw/src/durable-objects/kiloclaw-instance/gateway.test.ts b/services/kiloclaw/src/durable-objects/kiloclaw-instance/gateway.test.ts index d3b794bd6..99985dbab 100644 --- a/services/kiloclaw/src/durable-objects/kiloclaw-instance/gateway.test.ts +++ b/services/kiloclaw/src/durable-objects/kiloclaw-instance/gateway.test.ts @@ -1,7 +1,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { deriveGatewayToken } from '../../auth/gateway-token'; import { createMutableState } from './state'; -import { getGatewayProcessStatus, getMorningBriefingStatus, waitForHealthy } from './gateway'; +import { + getGatewayProcessStatus, + getMorningBriefingStatus, + runMorningBriefing, + waitForHealthy, +} from './gateway'; import { GatewayControllerError } from '../gateway-controller-types'; type FetchMock = ReturnType< @@ -166,4 +171,48 @@ describe('gateway controller routing', () => { } as never) ).rejects.toBeInstanceOf(GatewayControllerError); }); + + it('accepts run response with delivery metadata', async () => { + const state = createMutableState(); + state.provider = 'fly'; + state.sandboxId = 'sandbox-1'; + state.flyAppName = 'test-app'; + state.flyMachineId = 'machine-1'; + + const fetchMock: FetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + ok: true, + date: '2026-04-24', + filePath: '/tmp/morning-briefing/2026-04-24.md', + failures: [], + delivery: [ + { channel: 'telegram', status: 'sent', target: '-100123' }, + { channel: 'discord', status: 'skipped', reason: 'ambiguous_target' }, + ], + }), + { + status: 200, + headers: { 'content-type': 'application/json' }, + } + ) + ); + vi.stubGlobal('fetch', fetchMock); + const timeoutSpy = vi.spyOn(AbortSignal, 'timeout'); + + const result = await runMorningBriefing(state, { + GATEWAY_TOKEN_SECRET: 'gateway-secret', + FLY_APP_NAME: 'fallback-app', + } as never); + + expect(result).toMatchObject({ + ok: true, + date: '2026-04-24', + delivery: [ + { channel: 'telegram', status: 'sent', target: '-100123' }, + { channel: 'discord', status: 'skipped', reason: 'ambiguous_target' }, + ], + }); + expect(timeoutSpy).toHaveBeenCalledWith(120_000); + }); }); diff --git a/services/kiloclaw/src/durable-objects/kiloclaw-instance/gateway.ts b/services/kiloclaw/src/durable-objects/kiloclaw-instance/gateway.ts index e0de463de..4ec9daee7 100644 --- a/services/kiloclaw/src/durable-objects/kiloclaw-instance/gateway.ts +++ b/services/kiloclaw/src/durable-objects/kiloclaw-instance/gateway.ts @@ -572,7 +572,8 @@ export async function runMorningBriefing( '/_kilo/morning-briefing/run', 'POST', MorningBriefingActionResponseSchema, - {} + {}, + { timeoutMs: 120_000 } ); } catch (error) { if (isErrorUnknownRoute(error)) return null; diff --git a/services/kiloclaw/src/routes/platform-morning-briefing.test.ts b/services/kiloclaw/src/routes/platform-morning-briefing.test.ts index 5b1a31c78..ac4438a90 100644 --- a/services/kiloclaw/src/routes/platform-morning-briefing.test.ts +++ b/services/kiloclaw/src/routes/platform-morning-briefing.test.ts @@ -121,4 +121,95 @@ describe('platform morning-briefing warm-up handling', () => { expect(response.status).toBe(500); expect(enableMorningBriefing).toHaveBeenCalledTimes(1); }); + + it('returns delivery metadata from run endpoint', async () => { + const runMorningBriefing = vi.fn<() => Promise>().mockResolvedValue({ + ok: true, + date: '2026-04-24', + filePath: '/tmp/morning-briefing/2026-04-24.md', + failures: [], + delivery: [ + { channel: 'telegram', status: 'sent', target: '-100123' }, + { channel: 'discord', status: 'skipped', reason: 'ambiguous_target' }, + ], + }); + const env = baseEnv({ runMorningBriefing }); + + const response = await platform.request( + '/morning-briefing/run', + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ userId: 'user-1' }), + }, + env + ); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + ok: true, + date: '2026-04-24', + delivery: [ + { channel: 'telegram', status: 'sent', target: '-100123' }, + { channel: 'discord', status: 'skipped', reason: 'ambiguous_target' }, + ], + }); + }); + + it('retries run when gateway is warming up and then succeeds', async () => { + const runMorningBriefing = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error('Failed to reach gateway')) + .mockResolvedValueOnce({ + ok: true, + date: '2026-04-24', + filePath: '/tmp/morning-briefing/2026-04-24.md', + failures: [], + }); + const env = baseEnv({ runMorningBriefing }); + + const requestPromise = platform.request( + '/morning-briefing/run', + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ userId: 'user-1' }), + }, + env + ); + + await vi.runAllTimersAsync(); + const response = await requestPromise; + + expect(response.status).toBe(200); + expect(runMorningBriefing).toHaveBeenCalledTimes(2); + }); + + it('returns warm-up payload for run timeout instead of generic 500', async () => { + const runMorningBriefing = vi + .fn<() => Promise>() + .mockRejectedValue( + new Error('Gateway controller request failed: The operation was aborted due to timeout') + ); + const env = baseEnv({ runMorningBriefing }); + + const requestPromise = platform.request( + '/morning-briefing/run', + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ userId: 'user-1' }), + }, + env + ); + + await vi.runAllTimersAsync(); + const response = await requestPromise; + + expect(response.status).toBe(503); + expect(await response.json()).toMatchObject({ + error: 'Gateway warming up, retrying shortly.', + code: 'gateway_warming_up', + }); + }); }); diff --git a/services/kiloclaw/src/routes/platform.ts b/services/kiloclaw/src/routes/platform.ts index 974a6d92d..5652fb398 100644 --- a/services/kiloclaw/src/routes/platform.ts +++ b/services/kiloclaw/src/routes/platform.ts @@ -2078,12 +2078,14 @@ platform.post('/morning-briefing/run', async c => { if ('error' in iidResult) return iidResult.error; try { - const response = await withResolvedDORetry( - c.env, - result.data.userId, - iidResult.instanceId, - stub => stub.runMorningBriefing(), - 'runMorningBriefing' + const response = await withMorningBriefingWarmupRetry(() => + withResolvedDORetry( + c.env, + result.data.userId, + iidResult.instanceId, + stub => stub.runMorningBriefing(), + 'runMorningBriefing' + ) ); if (!response) { return jsonError( @@ -2094,6 +2096,9 @@ platform.post('/morning-briefing/run', async c => { } return c.json(response, 200); } catch (err) { + if (isMorningBriefingWarmupError(err)) { + return jsonError('Gateway warming up, retrying shortly.', 503, 'gateway_warming_up'); + } const { message, status, code } = sanitizeOpenclawConfigError(err, 'morning-briefing/run'); return jsonError(message, status, code); } From a10d0c1bef22c354a6f0d98f368fd1e844ade2aa Mon Sep 17 00:00:00 2001 From: joshavant <830519+joshavant@users.noreply.github.com> Date: Fri, 24 Apr 2026 19:57:13 -0500 Subject: [PATCH 02/19] refactor(kiloclaw): consolidate morning briefing delivery internals Split command and channel-delivery concerns into dedicated modules, tighten timeout retry semantics, and reuse shared UI typing to reduce schema drift. Add cron JSON compatibility fallback to avoid controller/runtime option skew. --- .../app/(app)/claw/components/SettingsTab.tsx | 28 +- apps/web/src/lib/kiloclaw/types.ts | 15 + .../src/command-utils.ts | 64 +++ .../src/delivery-utils.ts | 343 +++++++++++++++ .../kiloclaw-morning-briefing/src/index.ts | 414 +----------------- .../gateway-controller-types.ts | 17 +- 6 files changed, 460 insertions(+), 421 deletions(-) create mode 100644 services/kiloclaw/plugins/kiloclaw-morning-briefing/src/command-utils.ts create mode 100644 services/kiloclaw/plugins/kiloclaw-morning-briefing/src/delivery-utils.ts diff --git a/apps/web/src/app/(app)/claw/components/SettingsTab.tsx b/apps/web/src/app/(app)/claw/components/SettingsTab.tsx index 063585088..1c4ff9d1b 100644 --- a/apps/web/src/app/(app)/claw/components/SettingsTab.tsx +++ b/apps/web/src/app/(app)/claw/components/SettingsTab.tsx @@ -23,7 +23,7 @@ import { toast } from 'sonner'; import { useModelSelectorList } from '@/app/api/openrouter/hooks'; import { useUser } from '@/hooks/useUser'; import { ModelCombobox, type ModelOption } from '@/components/shared/ModelCombobox'; -import type { KiloClawDashboardStatus } from '@/lib/kiloclaw/types'; +import type { KiloClawDashboardStatus, MorningBriefingStatusLite } from '@/lib/kiloclaw/types'; import { calverAtLeast, cleanVersion } from '@/lib/kiloclaw/version'; import type { useKiloClawMutations } from '@/hooks/useKiloClaw'; import { @@ -500,31 +500,7 @@ function MorningBriefingCard({ actionsReady, }: { mutations: ClawMutations; - briefingStatus: - | { - enabled?: boolean; - desiredEnabled?: boolean; - observedEnabled?: boolean | null; - reconcileState?: 'idle' | 'in_progress' | 'succeeded' | 'failed'; - lastReconcileAction?: 'enable' | 'disable' | null; - code?: string; - cron?: string; - timezone?: string; - lastGeneratedDate?: string | null; - sourceReadiness?: { - github: { configured: boolean; summary: string }; - linear: { configured: boolean; summary: string }; - web: { configured: boolean; summary: string }; - }; - lastDelivery?: Array<{ - channel: 'telegram' | 'discord' | 'slack'; - status: 'sent' | 'skipped' | 'failed'; - reason?: 'missing_target' | 'ambiguous_target' | 'send_failed' | 'config_unavailable'; - error?: string; - target?: string; - }>; - } - | undefined; + briefingStatus: MorningBriefingStatusLite | undefined; fallbackReadiness: { githubConfigured: boolean; linearConfigured: boolean; diff --git a/apps/web/src/lib/kiloclaw/types.ts b/apps/web/src/lib/kiloclaw/types.ts index 080a0fea5..9931c0549 100644 --- a/apps/web/src/lib/kiloclaw/types.ts +++ b/apps/web/src/lib/kiloclaw/types.ts @@ -417,6 +417,21 @@ export type MorningBriefingDeliveryResult = { error?: string; }; +export type MorningBriefingStatusLite = Pick< + MorningBriefingStatusResponse, + | 'enabled' + | 'desiredEnabled' + | 'observedEnabled' + | 'reconcileState' + | 'lastReconcileAction' + | 'code' + | 'cron' + | 'timezone' + | 'lastGeneratedDate' + | 'sourceReadiness' + | 'lastDelivery' +>; + export type MorningBriefingStatusResponse = { ok: boolean; enabled?: boolean; diff --git a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/command-utils.ts b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/command-utils.ts new file mode 100644 index 000000000..0bc599651 --- /dev/null +++ b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/command-utils.ts @@ -0,0 +1,64 @@ +export type CommandCapableRuntime = { + runtime: { + system: { + runCommandWithTimeout: ( + argv: string[], + options: { timeoutMs: number; cwd?: string } + ) => Promise<{ stdout: string; stderr: string; code: number | null }>; + }; + }; +}; + +export class CommandExecutionError extends Error { + readonly argv: string[]; + readonly code: number | null; + readonly stdout: string; + readonly stderr: string; + + constructor(params: { argv: string[]; code: number | null; stdout: string; stderr: string }) { + const detail = params.stderr.trim() || params.stdout.trim() || 'Command failed'; + super(`${params.argv.join(' ')} failed: ${detail}`); + this.name = 'CommandExecutionError'; + this.argv = params.argv; + this.code = params.code; + this.stdout = params.stdout; + this.stderr = params.stderr; + } +} + +export function isTimeoutExecutionError(error: unknown): boolean { + if (!(error instanceof CommandExecutionError)) { + return false; + } + + if (error.code === null) { + return true; + } + + const text = `${error.stderr}\n${error.stdout}\n${error.message}`; + return ( + text.includes('operation was aborted due to timeout') || + text.includes('timed out') || + text.includes('ETIMEDOUT') || + text.includes('AbortError') + ); +} + +export async function runCommand( + api: CommandCapableRuntime, + argv: string[], + timeoutMs = 30_000 +): Promise<{ stdout: string; stderr: string }> { + const result = await api.runtime.system.runCommandWithTimeout(argv, { + timeoutMs, + }); + if (result.code !== 0) { + throw new CommandExecutionError({ + argv, + code: result.code, + stdout: result.stdout, + stderr: result.stderr, + }); + } + return { stdout: result.stdout, stderr: result.stderr }; +} diff --git a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/delivery-utils.ts b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/delivery-utils.ts new file mode 100644 index 000000000..0dde5c385 --- /dev/null +++ b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/delivery-utils.ts @@ -0,0 +1,343 @@ +import { formatBriefingMarkdownForMessage } from './briefing-utils'; +import { type CommandCapableRuntime, isTimeoutExecutionError, runCommand } from './command-utils'; + +export const DELIVERY_CHANNELS = ['telegram', 'discord', 'slack'] as const; +export type DeliveryChannel = (typeof DELIVERY_CHANNELS)[number]; + +export const DELIVERY_STATUSES = ['sent', 'skipped', 'failed'] as const; +export type DeliveryStatus = (typeof DELIVERY_STATUSES)[number]; + +export const DELIVERY_REASONS = [ + 'missing_target', + 'ambiguous_target', + 'send_failed', + 'config_unavailable', +] as const; +export type DeliveryReason = (typeof DELIVERY_REASONS)[number]; + +export type BriefingDeliveryResult = { + channel: DeliveryChannel; + status: DeliveryStatus; + target?: string; + accountId?: string; + reason?: DeliveryReason; + error?: string; +}; + +type DeliveryRoute = { + channel: DeliveryChannel; + target: string; + accountId?: string; +}; + +type DeliveryApi = CommandCapableRuntime & { + config: unknown; + logger: { warn?: (message: string) => void }; +}; + +type SkipReason = Extract; + +function asObject(value: unknown): Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function normalizeDeliveryTarget(value: unknown): string | null { + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; + } + if (typeof value === 'number' && Number.isFinite(value)) { + return String(value); + } + return null; +} + +function toEnabledObjectEntries(value: unknown): Array<[string, Record]> { + const record = asObject(value); + return Object.entries(record) + .filter((entry): entry is [string, Record] => { + const [key, raw] = entry; + if (key.trim() === '' || key === '*') { + return false; + } + return typeof raw === 'object' && raw !== null && !Array.isArray(raw); + }) + .filter(([, raw]) => raw.enabled !== false); +} + +function collectFallbackTargets( + channel: DeliveryChannel, + rawChannelConfig: Record +): string[] { + if (channel === 'telegram') { + return toEnabledObjectEntries(rawChannelConfig.groups).map(([groupId]) => groupId); + } + + if (channel === 'discord') { + const guildEntries = toEnabledObjectEntries(rawChannelConfig.guilds); + return guildEntries.flatMap(([, guildConfig]) => { + const channels = toEnabledObjectEntries(guildConfig.channels); + return channels.map(([channelId]) => `channel:${channelId}`); + }); + } + + const channels = toEnabledObjectEntries(rawChannelConfig.channels); + return channels.map(([channelId]) => `channel:${channelId}`); +} + +function resolveDeliveryRoute(params: { + channel: DeliveryChannel; + channelsConfig: Record; +}): { + configured: boolean; + route: DeliveryRoute | null; + skipReason?: SkipReason; +} { + const rawChannelConfig = asObject(params.channelsConfig[params.channel]); + if (Object.keys(rawChannelConfig).length === 0 || rawChannelConfig.enabled === false) { + return { configured: false, route: null }; + } + + const accountsConfig = asObject(rawChannelConfig.accounts); + const defaultAccount = asObject(accountsConfig.default); + const defaultAccountTarget = normalizeDeliveryTarget(defaultAccount.defaultTo); + if (defaultAccountTarget) { + return { + configured: true, + route: { + channel: params.channel, + target: defaultAccountTarget, + accountId: 'default', + }, + }; + } + + const topLevelTarget = normalizeDeliveryTarget(rawChannelConfig.defaultTo); + if (topLevelTarget) { + return { + configured: true, + route: { + channel: params.channel, + target: topLevelTarget, + }, + }; + } + + const fallbackTargets = collectFallbackTargets(params.channel, rawChannelConfig); + if (fallbackTargets.length === 1) { + return { + configured: true, + route: { + channel: params.channel, + target: fallbackTargets[0], + }, + }; + } + + return { + configured: true, + route: null, + skipReason: fallbackTargets.length > 1 ? 'ambiguous_target' : 'missing_target', + }; +} + +function readChannelsConfigFromRuntimeConfig(config: unknown): Record | null { + const rawConfig = asObject(config); + if (!Object.prototype.hasOwnProperty.call(rawConfig, 'channels')) { + return null; + } + return asObject(rawConfig.channels); +} + +async function readChannelsConfig(api: DeliveryApi): Promise> { + const fromRuntimeConfig = readChannelsConfigFromRuntimeConfig(api.config); + if (fromRuntimeConfig) { + return fromRuntimeConfig; + } + + let lastError: unknown = null; + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + const { stdout } = await runCommand( + api, + ['openclaw', 'config', 'get', 'channels', '--json'], + 60_000 + ); + return asObject(JSON.parse(stdout)); + } catch (error) { + const errorText = error instanceof Error ? error.message : String(error); + if (errorText.includes('Config path not found: channels')) { + return {}; + } + lastError = error; + } + } + + if (lastError) { + throw lastError; + } + + return {}; +} + +function createSendCommand(params: { route: DeliveryRoute; messageText: string }): string[] { + const argv = [ + 'openclaw', + 'message', + 'send', + '--channel', + params.route.channel, + '--target', + params.route.target, + '--message', + params.messageText, + ]; + if (params.route.accountId) { + argv.push('--account', params.route.accountId); + } + return argv; +} + +async function sendWithRetry(api: DeliveryApi, argv: string[]): Promise { + try { + await runCommand(api, argv, 120_000); + return; + } catch (error) { + if (!isTimeoutExecutionError(error)) { + throw error; + } + } + await runCommand(api, argv, 120_000); +} + +function failedDeliveryForAllChannels(errorText: string): BriefingDeliveryResult[] { + return DELIVERY_CHANNELS.map(channel => ({ + channel, + status: 'failed', + reason: 'config_unavailable', + error: errorText, + })); +} + +export function parseStoredDelivery(entries: unknown): BriefingDeliveryResult[] { + if (!Array.isArray(entries)) { + return []; + } + + return entries + .map(entry => asObject(entry)) + .map(entry => { + const channel = + entry.channel === 'telegram' || entry.channel === 'discord' || entry.channel === 'slack' + ? entry.channel + : null; + const status = + entry.status === 'sent' || entry.status === 'skipped' || entry.status === 'failed' + ? entry.status + : null; + if (!channel || !status) { + return null; + } + + const reason = + entry.reason === 'missing_target' || + entry.reason === 'ambiguous_target' || + entry.reason === 'send_failed' || + entry.reason === 'config_unavailable' + ? entry.reason + : undefined; + + return { + channel, + status, + target: typeof entry.target === 'string' ? entry.target : undefined, + accountId: typeof entry.accountId === 'string' ? entry.accountId : undefined, + reason, + error: typeof entry.error === 'string' ? entry.error : undefined, + } satisfies BriefingDeliveryResult; + }) + .filter((entry): entry is BriefingDeliveryResult => entry !== null); +} + +export function formatDeliverySummary(delivery: BriefingDeliveryResult[]): string[] { + if (delivery.length === 0) { + return ['- delivery: no configured messaging channels found']; + } + + return delivery.map(entry => { + const targetSuffix = entry.target ? ` (${entry.target})` : ''; + if (entry.status === 'sent') { + return `- delivery: ${entry.channel} sent${targetSuffix}`; + } + if (entry.status === 'skipped') { + return `- delivery: ${entry.channel} skipped (${entry.reason ?? 'unknown'})`; + } + return `- delivery: ${entry.channel} failed${targetSuffix}${entry.error ? `: ${entry.error}` : ''}`; + }); +} + +export async function deliverBriefingToConfiguredChannels( + api: DeliveryApi, + markdown: string +): Promise { + const messageText = formatBriefingMarkdownForMessage(markdown); + if (!messageText) { + return []; + } + + let channelsConfig: Record; + try { + channelsConfig = await readChannelsConfig(api); + } catch (error) { + const errorText = error instanceof Error ? error.message : String(error); + api.logger.warn?.(`Morning briefing delivery config read failed: ${errorText}`); + return failedDeliveryForAllChannels(errorText); + } + + const delivery: BriefingDeliveryResult[] = []; + const routes: DeliveryRoute[] = []; + + for (const channel of DELIVERY_CHANNELS) { + const resolution = resolveDeliveryRoute({ channel, channelsConfig }); + if (!resolution.configured) { + continue; + } + + if (!resolution.route) { + delivery.push({ + channel, + status: 'skipped', + reason: resolution.skipReason ?? 'missing_target', + }); + continue; + } + + routes.push(resolution.route); + } + + for (const route of routes) { + const argv = createSendCommand({ route, messageText }); + try { + await sendWithRetry(api, argv); + delivery.push({ + channel: route.channel, + status: 'sent', + target: route.target, + accountId: route.accountId, + }); + } catch (error) { + delivery.push({ + channel: route.channel, + status: 'failed', + reason: 'send_failed', + target: route.target, + accountId: route.accountId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + return delivery; +} diff --git a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/index.ts b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/index.ts index c47646148..45e34ce5a 100644 --- a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/index.ts +++ b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/index.ts @@ -2,12 +2,15 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { Type } from '@sinclair/typebox'; import { definePluginEntry } from 'openclaw/plugin-sdk/plugin-entry'; +import { buildBriefingMarkdown, offsetDateKey, resolveBriefingPath } from './briefing-utils'; import { - buildBriefingMarkdown, - formatBriefingMarkdownForMessage, - offsetDateKey, - resolveBriefingPath, -} from './briefing-utils'; + DELIVERY_CHANNELS, + type BriefingDeliveryResult, + deliverBriefingToConfiguredChannels, + formatDeliverySummary, + parseStoredDelivery, +} from './delivery-utils'; +import { CommandExecutionError, runCommand } from './command-utils'; import { filterEnabledBriefingJobs, pickCanonicalCronJobId, @@ -63,25 +66,6 @@ type SourceCollectionResult = { sectionLines: string[]; }; -type DeliveryChannel = 'telegram' | 'discord' | 'slack'; - -type BriefingDeliveryResult = { - channel: DeliveryChannel; - status: 'sent' | 'skipped' | 'failed'; - target?: string; - accountId?: string; - reason?: 'missing_target' | 'ambiguous_target' | 'send_failed' | 'config_unavailable'; - error?: string; -}; - -type DeliveryRoute = { - channel: DeliveryChannel; - target: string; - accountId?: string; -}; - -const DELIVERY_CHANNELS: DeliveryChannel[] = ['telegram', 'discord', 'slack']; - function asObject(value: unknown): Record { return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record) @@ -156,30 +140,6 @@ async function writeJsonFile(filePath: string, value: unknown): Promise { await fs.writeFile(filePath, JSON.stringify(value, null, 2), 'utf8'); } -async function runCommand( - api: { - runtime: { - system: { - runCommandWithTimeout: ( - argv: string[], - options: { timeoutMs: number; cwd?: string } - ) => Promise<{ stdout: string; stderr: string; code: number | null }>; - }; - }; - }, - argv: string[], - timeoutMs = 30_000 -): Promise<{ stdout: string; stderr: string }> { - const result = await api.runtime.system.runCommandWithTimeout(argv, { - timeoutMs, - }); - if (result.code !== 0) { - const message = result.stderr.trim() || result.stdout.trim() || 'Command failed'; - throw new Error(`${argv.join(' ')} failed: ${message}`); - } - return { stdout: result.stdout, stderr: result.stderr }; -} - async function runCronJson( api: { runtime: { @@ -194,11 +154,24 @@ async function runCronJson( argv: string[] ): Promise> { const [subcommand = ''] = argv; - const jsonUnsupported = subcommand === 'disable'; + const jsonUnsupported = subcommand === 'disable' || subcommand === 'edit'; const command = jsonUnsupported ? ['openclaw', 'cron', ...argv] : ['openclaw', 'cron', ...argv, '--json']; - const { stdout } = await runCommand(api, command, 60_000); + let stdout: string; + try { + ({ stdout } = await runCommand(api, command, 60_000)); + } catch (error) { + if ( + !jsonUnsupported && + error instanceof CommandExecutionError && + error.stderr.includes("unknown option '--json'") + ) { + ({ stdout } = await runCommand(api, ['openclaw', 'cron', ...argv], 60_000)); + } else { + throw error; + } + } try { return asObject(JSON.parse(stdout)); } catch { @@ -374,40 +347,7 @@ async function readStoredStatus(paths: { statusPath: string }): Promise typeof value === 'string') : [], - lastDelivery: Array.isArray(existing.lastDelivery) - ? existing.lastDelivery - .map(entry => asObject(entry)) - .map(entry => { - const channel = - entry.channel === 'telegram' || - entry.channel === 'discord' || - entry.channel === 'slack' - ? entry.channel - : null; - const status = - entry.status === 'sent' || entry.status === 'skipped' || entry.status === 'failed' - ? entry.status - : null; - if (!channel || !status) { - return null; - } - return { - channel, - status, - target: typeof entry.target === 'string' ? entry.target : undefined, - accountId: typeof entry.accountId === 'string' ? entry.accountId : undefined, - reason: - entry.reason === 'missing_target' || - entry.reason === 'ambiguous_target' || - entry.reason === 'send_failed' || - entry.reason === 'config_unavailable' - ? entry.reason - : undefined, - error: typeof entry.error === 'string' ? entry.error : undefined, - } satisfies BriefingDeliveryResult; - }) - .filter((entry): entry is BriefingDeliveryResult => entry !== null) - : [], + lastDelivery: parseStoredDelivery(existing.lastDelivery), observedEnabled: typeof existing.observedEnabled === 'boolean' ? existing.observedEnabled : null, reconcileState: @@ -458,312 +398,6 @@ async function patchStoredStatus( }); } -function normalizeDeliveryTarget(value: unknown): string | null { - if (typeof value === 'string') { - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; - } - if (typeof value === 'number' && Number.isFinite(value)) { - return String(value); - } - return null; -} - -function toEnabledObjectEntries(value: unknown): Array<[string, Record]> { - const record = asObject(value); - return Object.entries(record) - .filter((entry): entry is [string, Record] => { - const [key, raw] = entry; - if (key.trim() === '' || key === '*') { - return false; - } - return typeof raw === 'object' && raw !== null && !Array.isArray(raw); - }) - .filter(([, raw]) => raw.enabled !== false); -} - -function collectTelegramFallbackTargets(rawChannelConfig: Record): string[] { - const groups = toEnabledObjectEntries(rawChannelConfig.groups).map(([groupId]) => groupId); - return groups; -} - -function collectDiscordFallbackTargets(rawChannelConfig: Record): string[] { - const guildEntries = toEnabledObjectEntries(rawChannelConfig.guilds); - const targets = guildEntries.flatMap(([, guildConfig]) => { - const channels = toEnabledObjectEntries(guildConfig.channels); - return channels.map(([channelId]) => `channel:${channelId}`); - }); - return targets; -} - -function collectSlackFallbackTargets(rawChannelConfig: Record): string[] { - const channels = toEnabledObjectEntries(rawChannelConfig.channels); - return channels.map(([channelId]) => `channel:${channelId}`); -} - -function collectFallbackTargets( - channel: DeliveryChannel, - rawChannelConfig: Record -): string[] { - if (channel === 'telegram') { - return collectTelegramFallbackTargets(rawChannelConfig); - } - if (channel === 'discord') { - return collectDiscordFallbackTargets(rawChannelConfig); - } - return collectSlackFallbackTargets(rawChannelConfig); -} - -function resolveDeliveryRoute(params: { - channel: DeliveryChannel; - channelsConfig: Record; -}): { - configured: boolean; - route: DeliveryRoute | null; - skipReason?: 'missing_target' | 'ambiguous_target'; -} { - const rawChannelConfig = asObject(params.channelsConfig[params.channel]); - if (Object.keys(rawChannelConfig).length === 0) { - return { configured: false, route: null }; - } - - if (rawChannelConfig.enabled === false) { - return { configured: false, route: null }; - } - - const accountsConfig = asObject(rawChannelConfig.accounts); - const defaultAccount = asObject(accountsConfig.default); - const defaultAccountTarget = normalizeDeliveryTarget(defaultAccount.defaultTo); - if (defaultAccountTarget) { - return { - configured: true, - route: { - channel: params.channel, - target: defaultAccountTarget, - accountId: 'default', - }, - }; - } - - const topLevelTarget = normalizeDeliveryTarget(rawChannelConfig.defaultTo); - if (topLevelTarget) { - return { - configured: true, - route: { - channel: params.channel, - target: topLevelTarget, - }, - }; - } - - const fallbackTargets = collectFallbackTargets(params.channel, rawChannelConfig); - if (fallbackTargets.length === 1) { - return { - configured: true, - route: { - channel: params.channel, - target: fallbackTargets[0], - }, - }; - } - - return { - configured: true, - route: null, - skipReason: fallbackTargets.length > 1 ? 'ambiguous_target' : 'missing_target', - }; -} - -function readChannelsConfigFromRuntimeConfig(config: unknown): Record | null { - const rawConfig = asObject(config); - if (!Object.prototype.hasOwnProperty.call(rawConfig, 'channels')) { - return null; - } - return asObject(rawConfig.channels); -} - -async function readChannelsConfig(api: { - config: unknown; - runtime: { - system: { - runCommandWithTimeout: ( - argv: string[], - options: { timeoutMs: number; cwd?: string } - ) => Promise<{ stdout: string; stderr: string; code: number | null }>; - }; - }; -}): Promise> { - const fromRuntimeConfig = readChannelsConfigFromRuntimeConfig(api.config); - if (fromRuntimeConfig) { - return fromRuntimeConfig; - } - - let lastError: unknown = null; - for (let attempt = 0; attempt < 2; attempt += 1) { - try { - const { stdout } = await runCommand( - api, - ['openclaw', 'config', 'get', 'channels', '--json'], - 60_000 - ); - return asObject(JSON.parse(stdout)); - } catch (error) { - const errorText = error instanceof Error ? error.message : String(error); - if (errorText.includes('Config path not found: channels')) { - return {}; - } - lastError = error; - } - } - - if (lastError) { - throw lastError; - } - - return {}; -} - -function formatDeliverySummary(delivery: BriefingDeliveryResult[]): string[] { - if (delivery.length === 0) { - return ['- delivery: no configured messaging channels found']; - } - - return delivery.map(entry => { - const targetSuffix = entry.target ? ` (${entry.target})` : ''; - if (entry.status === 'sent') { - return `- delivery: ${entry.channel} sent${targetSuffix}`; - } - if (entry.status === 'skipped') { - return `- delivery: ${entry.channel} skipped (${entry.reason ?? 'unknown'})`; - } - return `- delivery: ${entry.channel} failed${targetSuffix}${entry.error ? `: ${entry.error}` : ''}`; - }); -} - -function isLikelyTimeoutError(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error); - return ( - message.includes('operation was aborted due to timeout') || - message.includes('timed out') || - message.includes('ETIMEDOUT') || - message.includes('Command failed') - ); -} - -async function deliverBriefingToConfiguredChannels( - api: { - config: unknown; - runtime: { - system: { - runCommandWithTimeout: ( - argv: string[], - options: { timeoutMs: number; cwd?: string } - ) => Promise<{ stdout: string; stderr: string; code: number | null }>; - }; - }; - logger: { warn?: (message: string) => void }; - }, - markdown: string -): Promise { - const messageText = formatBriefingMarkdownForMessage(markdown); - if (!messageText) { - return []; - } - - let channelsConfig: Record; - try { - channelsConfig = await readChannelsConfig(api); - } catch (error) { - const errorText = error instanceof Error ? error.message : String(error); - api.logger.warn?.(`Morning briefing delivery config read failed: ${errorText}`); - return DELIVERY_CHANNELS.map(channel => ({ - channel, - status: 'failed', - reason: 'config_unavailable', - error: errorText, - })); - } - - const delivery: BriefingDeliveryResult[] = []; - const routes: DeliveryRoute[] = []; - - for (const channel of DELIVERY_CHANNELS) { - const resolution = resolveDeliveryRoute({ channel, channelsConfig }); - if (!resolution.configured) { - continue; - } - if (!resolution.route) { - delivery.push({ - channel, - status: 'skipped', - reason: resolution.skipReason ?? 'missing_target', - }); - continue; - } - routes.push(resolution.route); - } - - for (const route of routes) { - const argv = [ - 'openclaw', - 'message', - 'send', - '--channel', - route.channel, - '--target', - route.target, - '--message', - messageText, - ]; - if (route.accountId) { - argv.push('--account', route.accountId); - } - try { - await runCommand(api, argv, 120_000); - delivery.push({ - channel: route.channel, - status: 'sent', - target: route.target, - accountId: route.accountId, - }); - } catch (error) { - if (isLikelyTimeoutError(error)) { - try { - await runCommand(api, argv, 120_000); - delivery.push({ - channel: route.channel, - status: 'sent', - target: route.target, - accountId: route.accountId, - }); - continue; - } catch (retryError) { - delivery.push({ - channel: route.channel, - status: 'failed', - reason: 'send_failed', - target: route.target, - accountId: route.accountId, - error: retryError instanceof Error ? retryError.message : String(retryError), - }); - continue; - } - } - - delivery.push({ - channel: route.channel, - status: 'failed', - reason: 'send_failed', - target: route.target, - accountId: route.accountId, - error: error instanceof Error ? error.message : String(error), - }); - } - } - - return delivery; -} - async function ensureCronJob( api: { runtime: { diff --git a/services/kiloclaw/src/durable-objects/gateway-controller-types.ts b/services/kiloclaw/src/durable-objects/gateway-controller-types.ts index c8b0e7b8c..f69c2b281 100644 --- a/services/kiloclaw/src/durable-objects/gateway-controller-types.ts +++ b/services/kiloclaw/src/durable-objects/gateway-controller-types.ts @@ -104,14 +104,21 @@ const MorningBriefingSourceReadinessSchema = z.object({ summary: z.string(), }); +const DELIVERY_CHANNELS = ['telegram', 'discord', 'slack'] as const; +const DELIVERY_STATUSES = ['sent', 'skipped', 'failed'] as const; +const DELIVERY_REASONS = [ + 'missing_target', + 'ambiguous_target', + 'send_failed', + 'config_unavailable', +] as const; + const MorningBriefingDeliverySchema = z.object({ - channel: z.enum(['telegram', 'discord', 'slack']), - status: z.enum(['sent', 'skipped', 'failed']), + channel: z.enum(DELIVERY_CHANNELS), + status: z.enum(DELIVERY_STATUSES), target: z.string().optional(), accountId: z.string().optional(), - reason: z - .enum(['missing_target', 'ambiguous_target', 'send_failed', 'config_unavailable']) - .optional(), + reason: z.enum(DELIVERY_REASONS).optional(), error: z.string().optional(), }); From f34cee875cb31d1bcd9d82ab5d907612e31b0bea Mon Sep 17 00:00:00 2001 From: joshavant <830519+joshavant@users.noreply.github.com> Date: Fri, 24 Apr 2026 19:59:13 -0500 Subject: [PATCH 03/19] fix(kiloclaw): avoid exposing delivery payloads in UI errors Render morning briefing delivery failures with reason-only text in Settings so stored command errors remain available for diagnostics without leaking message content in the dashboard. --- apps/web/src/app/(app)/claw/components/SettingsTab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/(app)/claw/components/SettingsTab.tsx b/apps/web/src/app/(app)/claw/components/SettingsTab.tsx index 1c4ff9d1b..67a1bfbb1 100644 --- a/apps/web/src/app/(app)/claw/components/SettingsTab.tsx +++ b/apps/web/src/app/(app)/claw/components/SettingsTab.tsx @@ -722,7 +722,7 @@ function MorningBriefingCard({ if (entry.status === 'skipped') { return `${entry.channel}: skipped (${entry.reason ?? 'missing_target'})`; } - return `${entry.channel}: failed${entry.error ? ` (${entry.error})` : ''}`; + return `${entry.channel}: failed (${entry.reason ?? 'send_failed'})`; }) .join(' • ')}

From cad73c24757b182971cf9406ff94fa5f2ff215ec Mon Sep 17 00:00:00 2001 From: joshavant <830519+joshavant@users.noreply.github.com> Date: Fri, 24 Apr 2026 21:51:41 -0500 Subject: [PATCH 04/19] fix(kiloclaw): stabilize morning briefing warmup transitions Treat gateway and briefing readiness as boot-session fresh data to avoid stale Disabled flaps, and clear cached gateway/morning-briefing queries on start, provision, and restarts so controls remain in warmup state until current boot data arrives. --- .../app/(app)/claw/components/SettingsTab.tsx | 55 +++++++++++++++---- apps/web/src/hooks/useKiloClaw.ts | 23 +++++++- apps/web/src/hooks/useOrgKiloClaw.ts | 25 ++++++++- 3 files changed, 88 insertions(+), 15 deletions(-) diff --git a/apps/web/src/app/(app)/claw/components/SettingsTab.tsx b/apps/web/src/app/(app)/claw/components/SettingsTab.tsx index 67a1bfbb1..b5204cbe1 100644 --- a/apps/web/src/app/(app)/claw/components/SettingsTab.tsx +++ b/apps/web/src/app/(app)/claw/components/SettingsTab.tsx @@ -15,7 +15,7 @@ import { Square, X, } from 'lucide-react'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { OpenclawImportCard } from './OpenclawImportCard'; import { usePostHog } from 'posthog-js/react'; @@ -536,11 +536,15 @@ function MorningBriefingCard({ } as const); const hasSchedule = Boolean(briefingStatus?.cron && briefingStatus?.timezone); - const desiredEnabled = briefingStatus?.desiredEnabled ?? briefingStatus?.enabled ?? false; - const observedEnabled = briefingStatus?.observedEnabled ?? briefingStatus?.enabled ?? false; + const desiredEnabledValue = briefingStatus?.desiredEnabled ?? briefingStatus?.enabled; + const observedEnabledValue = briefingStatus?.observedEnabled ?? briefingStatus?.enabled; + const hasResolvedBriefingToggleState = + typeof desiredEnabledValue === 'boolean' && typeof observedEnabledValue === 'boolean'; + const desiredEnabled = desiredEnabledValue ?? false; + const observedEnabled = observedEnabledValue ?? false; const reconcileState = briefingStatus?.reconcileState ?? 'idle'; const lastReconcileAction = briefingStatus?.lastReconcileAction ?? null; - const isWarmupState = isRunning && actionsReady === false; + const isWarmupState = isRunning && (actionsReady === false || !hasResolvedBriefingToggleState); const isTransitioning = reconcileState === 'in_progress' || mutations.enableMorningBriefing.isPending || @@ -579,8 +583,9 @@ function MorningBriefingCard({ return observedEnabled ? 'Enabled' : 'Disabled'; })(); - const statusVariant = - statusLabel === 'Instance Stopped' + const statusVariant = isWarmupState + ? 'default' + : statusLabel === 'Instance Stopped' ? 'secondary' : observedEnabled || (isTransitioning && desiredEnabled) ? 'default' @@ -1315,10 +1320,40 @@ export function SettingsTab({ isControllerVersionError, } = useClawUpdateAvailable(status); const { data: myPin } = useClawMyPin(); - const { data: morningBriefingStatus } = useClawMorningBriefingStatus(true); - const { data: gatewayReady } = useClawGatewayReady(isRunning); + const morningBriefingStatusQuery = useClawMorningBriefingStatus(isRunning); + const morningBriefingStatus = morningBriefingStatusQuery.data; + const gatewayReadyQuery = useClawGatewayReady(isRunning); + const gatewayReady = gatewayReadyQuery.data; const [confirmDestroy, setConfirmDestroy] = useState(false); const [confirmRestore, setConfirmRestore] = useState(false); + const [bootStartedAtMs, setBootStartedAtMs] = useState(null); + const previousStatusRef = useRef(status.status); + + useEffect(() => { + const previousStatus = previousStatusRef.current; + if (status.status === 'running' && previousStatus !== 'running') { + setBootStartedAtMs(Date.now()); + } + if (status.status !== 'running' && previousStatus === 'running') { + setBootStartedAtMs(null); + } + previousStatusRef.current = status.status; + }, [status.status]); + + const hasFreshGatewayReady = + isRunning && + (bootStartedAtMs === null || gatewayReadyQuery.dataUpdatedAt >= bootStartedAtMs) && + gatewayReadyQuery.dataUpdatedAt > 0; + const hasFreshMorningBriefingStatus = + isRunning && + (bootStartedAtMs === null || morningBriefingStatusQuery.dataUpdatedAt >= bootStartedAtMs) && + morningBriefingStatusQuery.dataUpdatedAt > 0; + const morningBriefingActionsReady = + isRunning && + hasFreshGatewayReady && + hasFreshMorningBriefingStatus && + gatewayReady?.ready === true && + gatewayReady?.settled === true; const hasModelSelectionError = isRunning && isControllerVersionError; const modelSelectionError = hasModelSelectionError ? 'Failed to load the running OpenClaw version. Retry before changing the default model.' @@ -1868,9 +1903,7 @@ export function SettingsTab({ mutations={mutations} briefingStatus={morningBriefingStatus} isRunning={isRunning} - actionsReady={ - isRunning && gatewayReady?.ready === true && gatewayReady?.settled === true - } + actionsReady={morningBriefingActionsReady} fallbackReadiness={{ githubConfigured: configuredSecrets.github ?? false, linearConfigured: configuredSecrets.linear ?? false, diff --git a/apps/web/src/hooks/useKiloClaw.ts b/apps/web/src/hooks/useKiloClaw.ts index 4b8f04d99..18ee66dcd 100644 --- a/apps/web/src/hooks/useKiloClaw.ts +++ b/apps/web/src/hooks/useKiloClaw.ts @@ -160,6 +160,11 @@ export function useKiloClawMutations() { ]); }; + const clearGatewayAndMorningBriefingCaches = () => { + queryClient.removeQueries({ queryKey: trpc.kiloclaw.gatewayReady.queryKey() }); + queryClient.removeQueries({ queryKey: trpc.kiloclaw.getMorningBriefingStatus.queryKey() }); + }; + // Wipe all instance-scoped caches so no stale data (e.g. gatewayReady // from the old instance) bleeds into a subsequent re-provision flow. // removeQueries drops the cached payload entirely; invalidateQueries @@ -183,13 +188,25 @@ export function useKiloClawMutations() { }; return { - start: useMutation(trpc.kiloclaw.start.mutationOptions({ onSuccess: invalidateStatus })), + start: useMutation( + trpc.kiloclaw.start.mutationOptions({ + onSuccess: async () => { + clearGatewayAndMorningBriefingCaches(); + await invalidateStatus(); + }, + }) + ), stop: useMutation(trpc.kiloclaw.stop.mutationOptions({ onSuccess: invalidateStatus })), destroy: useMutation( trpc.kiloclaw.destroy.mutationOptions({ onSuccess: resetAllInstanceState }) ), provision: useMutation( - trpc.kiloclaw.provision.mutationOptions({ onSuccess: invalidateStatusAndBilling }) + trpc.kiloclaw.provision.mutationOptions({ + onSuccess: async () => { + clearGatewayAndMorningBriefingCaches(); + await invalidateStatusAndBilling(); + }, + }) ), cycleInboundEmailAddress: useMutation( trpc.kiloclaw.cycleInboundEmailAddress.mutationOptions({ onSuccess: invalidateStatus }) @@ -222,6 +239,7 @@ export function useKiloClawMutations() { restartMachine: useMutation( trpc.kiloclaw.restartMachine.mutationOptions({ onSuccess: async () => { + clearGatewayAndMorningBriefingCaches(); await invalidateStatus(); await queryClient.invalidateQueries({ queryKey: trpc.kiloclaw.gatewayStatus.queryKey(), @@ -232,6 +250,7 @@ export function useKiloClawMutations() { restartOpenClaw: useMutation( trpc.kiloclaw.restartOpenClaw.mutationOptions({ onSuccess: async () => { + clearGatewayAndMorningBriefingCaches(); await invalidateStatus(); await queryClient.invalidateQueries({ queryKey: trpc.kiloclaw.gatewayStatus.queryKey(), diff --git a/apps/web/src/hooks/useOrgKiloClaw.ts b/apps/web/src/hooks/useOrgKiloClaw.ts index 4a5ce48e7..815338fb7 100644 --- a/apps/web/src/hooks/useOrgKiloClaw.ts +++ b/apps/web/src/hooks/useOrgKiloClaw.ts @@ -215,6 +215,15 @@ export function useOrgKiloClawMutations( }); }; + const clearGatewayAndMorningBriefingCaches = () => { + queryClient.removeQueries({ + queryKey: trpc.organizations.kiloclaw.gatewayReady.queryKey({ organizationId }), + }); + queryClient.removeQueries({ + queryKey: trpc.organizations.kiloclaw.getMorningBriefingStatus.queryKey({ organizationId }), + }); + }; + // Helper: wrap a raw org mutation so mutate/mutateAsync inject organizationId. // The `any` types are unavoidable here — we're wrapping tRPC mutations generically // to pre-bind organizationId. The final return uses `satisfies` to catch missing keys. @@ -237,7 +246,12 @@ export function useOrgKiloClawMutations( /* eslint-enable @typescript-eslint/no-explicit-any */ const rawStart = useMutation( - trpc.organizations.kiloclaw.start.mutationOptions({ onSuccess: invalidateStatus }) + trpc.organizations.kiloclaw.start.mutationOptions({ + onSuccess: async () => { + clearGatewayAndMorningBriefingCaches(); + await invalidateStatus(); + }, + }) ); const rawStop = useMutation( trpc.organizations.kiloclaw.stop.mutationOptions({ onSuccess: invalidateStatus }) @@ -246,7 +260,12 @@ export function useOrgKiloClawMutations( trpc.organizations.kiloclaw.destroy.mutationOptions({ onSuccess: resetAllInstanceState }) ); const rawProvision = useMutation( - trpc.organizations.kiloclaw.provision.mutationOptions({ onSuccess: invalidateStatus }) + trpc.organizations.kiloclaw.provision.mutationOptions({ + onSuccess: async () => { + clearGatewayAndMorningBriefingCaches(); + await invalidateStatus(); + }, + }) ); const rawCycleInboundEmailAddress = useMutation( trpc.organizations.kiloclaw.cycleInboundEmailAddress.mutationOptions({ @@ -287,6 +306,7 @@ export function useOrgKiloClawMutations( const rawRestartMachine = useMutation( trpc.organizations.kiloclaw.restartMachine.mutationOptions({ onSuccess: async () => { + clearGatewayAndMorningBriefingCaches(); await invalidateStatus(); await queryClient.invalidateQueries({ queryKey: trpc.organizations.kiloclaw.gatewayStatus.queryKey({ organizationId }), @@ -297,6 +317,7 @@ export function useOrgKiloClawMutations( const rawRestartOpenClaw = useMutation( trpc.organizations.kiloclaw.restartOpenClaw.mutationOptions({ onSuccess: async () => { + clearGatewayAndMorningBriefingCaches(); await invalidateStatus(); await queryClient.invalidateQueries({ queryKey: trpc.organizations.kiloclaw.gatewayStatus.queryKey({ organizationId }), From 31606b518e2255d545e5467e1edb3a46858701e6 Mon Sep 17 00:00:00 2001 From: joshavant <830519+joshavant@users.noreply.github.com> Date: Fri, 24 Apr 2026 23:01:15 -0500 Subject: [PATCH 05/19] fix(kiloclaw): remove warmup disabled-state flap Stop emitting synthetic enabled=false during morning-briefing warmup and treat gateway_warming_up as authoritative in the dashboard card. Keep warmup badge styling and delivery visibility gated until status fields are resolved to prevent transient Disabled and Last delivery flaps. --- .../src/app/(app)/claw/components/SettingsTab.tsx | 15 +++++++++++---- .../kiloclaw-instance/gateway.test.ts | 1 - .../durable-objects/kiloclaw-instance/gateway.ts | 1 - services/kiloclaw/src/routes/platform.ts | 1 - 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/apps/web/src/app/(app)/claw/components/SettingsTab.tsx b/apps/web/src/app/(app)/claw/components/SettingsTab.tsx index b5204cbe1..8ad275be4 100644 --- a/apps/web/src/app/(app)/claw/components/SettingsTab.tsx +++ b/apps/web/src/app/(app)/claw/components/SettingsTab.tsx @@ -538,13 +538,16 @@ function MorningBriefingCard({ const hasSchedule = Boolean(briefingStatus?.cron && briefingStatus?.timezone); const desiredEnabledValue = briefingStatus?.desiredEnabled ?? briefingStatus?.enabled; const observedEnabledValue = briefingStatus?.observedEnabled ?? briefingStatus?.enabled; + const isGatewayWarmupStatus = briefingStatus?.code === 'gateway_warming_up'; const hasResolvedBriefingToggleState = typeof desiredEnabledValue === 'boolean' && typeof observedEnabledValue === 'boolean'; const desiredEnabled = desiredEnabledValue ?? false; const observedEnabled = observedEnabledValue ?? false; const reconcileState = briefingStatus?.reconcileState ?? 'idle'; const lastReconcileAction = briefingStatus?.lastReconcileAction ?? null; - const isWarmupState = isRunning && (actionsReady === false || !hasResolvedBriefingToggleState); + const isWarmupState = + isRunning && + (actionsReady === false || isGatewayWarmupStatus || !hasResolvedBriefingToggleState); const isTransitioning = reconcileState === 'in_progress' || mutations.enableMorningBriefing.isPending || @@ -584,7 +587,7 @@ function MorningBriefingCard({ return observedEnabled ? 'Enabled' : 'Disabled'; })(); const statusVariant = isWarmupState - ? 'default' + ? 'secondary' : statusLabel === 'Instance Stopped' ? 'secondary' : observedEnabled || (isTransitioning && desiredEnabled) @@ -612,6 +615,8 @@ function MorningBriefingCard({ const controlsEnabled = actionsReady && !isWarmupState; const canUseBriefingControls = controlsEnabled && desiredEnabled; const lastDelivery = briefingStatus?.lastDelivery ?? []; + const showLastDelivery = + !isWarmupState && actionsReady && hasResolvedBriefingToggleState && lastDelivery.length > 0; return (
@@ -619,7 +624,9 @@ function MorningBriefingCard({

Morning Briefing

- {statusLabel} + + {statusLabel} +
{showScheduleDetails && briefingStatus?.cron && briefingStatus?.timezone && (

@@ -716,7 +723,7 @@ function MorningBriefingCard({

{sourceSummaryText}

- {lastDelivery.length > 0 && ( + {showLastDelivery && (

Last delivery:{' '} {lastDelivery diff --git a/services/kiloclaw/src/durable-objects/kiloclaw-instance/gateway.test.ts b/services/kiloclaw/src/durable-objects/kiloclaw-instance/gateway.test.ts index 99985dbab..ccd206fa0 100644 --- a/services/kiloclaw/src/durable-objects/kiloclaw-instance/gateway.test.ts +++ b/services/kiloclaw/src/durable-objects/kiloclaw-instance/gateway.test.ts @@ -141,7 +141,6 @@ describe('gateway controller routing', () => { expect(result).toEqual({ ok: true, - enabled: false, reconcileState: 'in_progress', error: 'Gateway warming up, retrying shortly.', code: 'gateway_warming_up', diff --git a/services/kiloclaw/src/durable-objects/kiloclaw-instance/gateway.ts b/services/kiloclaw/src/durable-objects/kiloclaw-instance/gateway.ts index 4ec9daee7..164d8208a 100644 --- a/services/kiloclaw/src/durable-objects/kiloclaw-instance/gateway.ts +++ b/services/kiloclaw/src/durable-objects/kiloclaw-instance/gateway.ts @@ -509,7 +509,6 @@ export async function getMorningBriefingStatus( if (isMorningBriefingWarmupControllerError(error)) { return { ok: true, - enabled: false, reconcileState: 'in_progress', error: 'Gateway warming up, retrying shortly.', code: 'gateway_warming_up', diff --git a/services/kiloclaw/src/routes/platform.ts b/services/kiloclaw/src/routes/platform.ts index 5652fb398..21ea61fa1 100644 --- a/services/kiloclaw/src/routes/platform.ts +++ b/services/kiloclaw/src/routes/platform.ts @@ -1984,7 +1984,6 @@ platform.get('/morning-briefing/status', async c => { return c.json( { ok: true, - enabled: false, reconcileState: 'in_progress', error: 'Gateway warming up, retrying shortly.', code: 'gateway_warming_up', From 1c6e2ca3dbea76effc9a179bc4fb1860bb06ccaf Mon Sep 17 00:00:00 2001 From: joshavant <830519+joshavant@users.noreply.github.com> Date: Fri, 24 Apr 2026 23:13:42 -0500 Subject: [PATCH 06/19] fix(kiloclaw): polish morning briefing status metadata UI Move Last delivery beneath Last generated, render channel/status labels with user-friendly capitalization, and top-align action buttons so the card layout stays consistent as metadata lines appear. --- .../app/(app)/claw/components/SettingsTab.tsx | 53 ++++++++++++------- 1 file changed, 35 insertions(+), 18 deletions(-) diff --git a/apps/web/src/app/(app)/claw/components/SettingsTab.tsx b/apps/web/src/app/(app)/claw/components/SettingsTab.tsx index 8ad275be4..3a1941d91 100644 --- a/apps/web/src/app/(app)/claw/components/SettingsTab.tsx +++ b/apps/web/src/app/(app)/claw/components/SettingsTab.tsx @@ -617,10 +617,26 @@ function MorningBriefingCard({ const lastDelivery = briefingStatus?.lastDelivery ?? []; const showLastDelivery = !isWarmupState && actionsReady && hasResolvedBriefingToggleState && lastDelivery.length > 0; + const deliveryChannelLabel = { + telegram: 'Telegram', + discord: 'Discord', + slack: 'Slack', + } as const; + const deliveryStatusLabel = { + sent: 'Sent', + skipped: 'Skipped', + failed: 'Failed', + } as const; + const deliveryReasonLabel = { + missing_target: 'Missing target', + ambiguous_target: 'Ambiguous target', + send_failed: 'Send failed', + config_unavailable: 'Config unavailable', + } as const; return (

-
+

Morning Briefing

@@ -638,6 +654,24 @@ function MorningBriefingCard({ Last generated: {briefingStatus?.lastGeneratedDate ?? '(none)'}

)} + {showLastDelivery && ( +

+ Last delivery:{' '} + {lastDelivery + .map(entry => { + const channel = deliveryChannelLabel[entry.channel] ?? entry.channel; + const status = deliveryStatusLabel[entry.status] ?? entry.status; + if (entry.status === 'sent') { + return `${channel}: ${status}`; + } + const reason = entry.reason + ? (deliveryReasonLabel[entry.reason] ?? entry.reason) + : undefined; + return reason ? `${channel}: ${status} (${reason})` : `${channel}: ${status}`; + }) + .join(' • ')} +

+ )}
@@ -742,13 +749,6 @@ function MorningBriefingCard({
- {isWarmupState && ( -

- Instance is still warming up. Morning Briefing controls will become available once the - gateway is fully ready. -

- )} - {!desiredEnabled && controlsEnabled && (

Enable Morning Briefing to get a personalized briefing everyday. diff --git a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/delivery-utils.test.ts b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/delivery-utils.test.ts new file mode 100644 index 000000000..3452fafb1 --- /dev/null +++ b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/delivery-utils.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest'; +import { parseStoredDelivery, resolveDeliveryRoute } from './delivery-utils'; + +describe('delivery-utils', () => { + it('parseStoredDelivery ignores malformed entries and keeps valid ones', () => { + const parsed = parseStoredDelivery([ + null, + 123, + { channel: 'telegram', status: 'sent', target: '-5055658641' }, + { channel: 'discord', status: 'unknown' }, + { channel: 'slack', status: 'failed', reason: 'send_failed', error: 'send failed' }, + { channel: 'email', status: 'sent' }, + { channel: 'telegram', status: 'skipped', reason: 'bogus_reason' }, + ]); + + expect(parsed).toEqual([ + { + channel: 'telegram', + status: 'sent', + target: '-5055658641', + }, + { + channel: 'slack', + status: 'failed', + reason: 'send_failed', + error: 'send failed', + }, + { + channel: 'telegram', + status: 'skipped', + }, + ]); + }); + + it('resolveDeliveryRoute infers single discord fallback channel target', () => { + const resolution = resolveDeliveryRoute({ + channel: 'discord', + channelsConfig: { + discord: { + enabled: true, + guilds: { + 'guild-1': { + channels: { + '1234567890': { enabled: true }, + }, + }, + }, + }, + }, + }); + + expect(resolution).toEqual({ + configured: true, + route: { + channel: 'discord', + target: 'channel:1234567890', + }, + }); + }); + + it('resolveDeliveryRoute infers single slack fallback channel target', () => { + const resolution = resolveDeliveryRoute({ + channel: 'slack', + channelsConfig: { + slack: { + enabled: true, + channels: { + C123456: { enabled: true }, + }, + }, + }, + }); + + expect(resolution).toEqual({ + configured: true, + route: { + channel: 'slack', + target: 'channel:C123456', + }, + }); + }); + + it('resolveDeliveryRoute marks ambiguous fallback when multiple discord channels exist', () => { + const resolution = resolveDeliveryRoute({ + channel: 'discord', + channelsConfig: { + discord: { + enabled: true, + guilds: { + 'guild-1': { + channels: { + '123': { enabled: true }, + '456': { enabled: true }, + }, + }, + }, + }, + }, + }); + + expect(resolution).toEqual({ + configured: true, + route: null, + skipReason: 'ambiguous_target', + }); + }); +}); diff --git a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/delivery-utils.ts b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/delivery-utils.ts index 965fb1b7a..c77effad0 100644 --- a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/delivery-utils.ts +++ b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/delivery-utils.ts @@ -32,11 +32,17 @@ type DeliveryRoute = { type DeliveryApi = CommandCapableRuntime & { config: unknown; - logger: { warn?: (message: string) => void }; + logger: { info?: (message: string) => void; warn?: (message: string) => void }; }; type SkipReason = Extract; +export type DeliveryRouteResolution = { + configured: boolean; + route: DeliveryRoute | null; + skipReason?: SkipReason; +}; + function asObject(value: unknown): Record { return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record) @@ -87,14 +93,10 @@ function collectFallbackTargets( return channels.map(([channelId]) => `channel:${channelId}`); } -function resolveDeliveryRoute(params: { +export function resolveDeliveryRoute(params: { channel: DeliveryChannel; channelsConfig: Record; -}): { - configured: boolean; - route: DeliveryRoute | null; - skipReason?: SkipReason; -} { +}): DeliveryRouteResolution { const rawChannelConfig = asObject(params.channelsConfig[params.channel]); if (Object.keys(rawChannelConfig).length === 0 || rawChannelConfig.enabled === false) { return { configured: false, route: null }; @@ -294,6 +296,29 @@ export function formatDeliverySummary(delivery: BriefingDeliveryResult[]): strin }); } +export function logDeliveryOutcomeEvents( + api: Pick, + delivery: BriefingDeliveryResult[] +): void { + for (const entry of delivery) { + const reason = entry.reason ?? 'none'; + const target = entry.target ?? 'none'; + const eventLine = + `event=morning_briefing_delivery_outcome` + + ` outcome=${entry.status}` + + ` channel=${entry.channel}` + + ` reason=${reason}` + + ` target=${target}`; + api.logger.info?.(eventLine); + if (entry.status === 'failed') { + const detail = entry.error ?? 'unknown_error'; + api.logger.warn?.( + `event=morning_briefing_delivery_failure channel=${entry.channel} detail=${detail}` + ); + } + } +} + export async function deliverBriefingToConfiguredChannels( api: DeliveryApi, markdown: string diff --git a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/index.lifecycle.test.ts b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/index.lifecycle.test.ts index 9d03e0f30..ffe91ed67 100644 --- a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/index.lifecycle.test.ts +++ b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/index.lifecycle.test.ts @@ -41,6 +41,8 @@ type TestHarness = { accountId?: string; message: string; }>; + loggerInfo: ReturnType; + loggerWarn: ReturnType; runCommandWithTimeout: ReturnType; }; @@ -240,6 +242,8 @@ async function createHarness(options?: { let statusHttpHandler: ((_req: unknown, res: FakeResponse) => Promise) | null = null; let enableHttpHandler: ((req: unknown, res: FakeResponse) => Promise) | null = null; let runHttpHandler: ((_req: unknown, res: FakeResponse) => Promise) | null = null; + const loggerInfo = vi.fn(); + const loggerWarn = vi.fn(); morningBriefingPlugin.register({ runtime: { @@ -254,7 +258,7 @@ async function createHarness(options?: { agents: { defaults: { userTimezone: 'America/Chicago' } }, ...(options?.omitRuntimeChannelsConfig ? {} : { channels: options?.channelsConfig ?? {} }), }, - logger: { warn: vi.fn() }, + logger: { info: loggerInfo, warn: loggerWarn }, registerCommand: (def: { handler: (ctx: { args?: string }) => Promise<{ text: string }> }) => { commandHandler = def.handler; }, @@ -286,6 +290,8 @@ async function createHarness(options?: { runHttpHandler, cronJobs, sentMessages, + loggerInfo, + loggerWarn, runCommandWithTimeout, }; } @@ -877,4 +883,63 @@ describe('morning briefing lifecycle', () => { expect(sendCalls[0]?.[1]).toMatchObject({ timeoutMs: 120_000 }); expect(sendCalls[1]?.[1]).toMatchObject({ timeoutMs: 120_000 }); }); + + it('emits delivery outcome metric logs for sent/skipped/failed results', async () => { + const harness = await createHarness({ + githubAuthReady: true, + githubIssues: [ + { + title: 'Delivery observability smoke check', + url: 'https://github.com/Kilo-Org/cloud/issues/910', + updatedAt: '2026-04-25T00:10:00Z', + }, + ], + channelsConfig: { + telegram: { + enabled: true, + defaultTo: '-5055658641', + }, + discord: { + enabled: true, + }, + slack: { + enabled: true, + defaultTo: 'channel:C123', + }, + }, + messageSendFailures: { + slack: 'slack send failed', + }, + }); + + const response = new FakeResponse(); + await harness.runHttpHandler({}, response); + expect(response.statusCode).toBe(200); + + const infoMessages = harness.loggerInfo.mock.calls.map(call => String(call[0])); + expect( + infoMessages.some(message => + message.includes('event=morning_briefing_delivery_outcome outcome=sent channel=telegram') + ) + ).toBe(true); + expect( + infoMessages.some(message => + message.includes('event=morning_briefing_delivery_outcome outcome=skipped channel=discord') + ) + ).toBe(true); + expect( + infoMessages.some(message => + message.includes('event=morning_briefing_delivery_outcome outcome=failed channel=slack') + ) + ).toBe(true); + + const warnMessages = harness.loggerWarn.mock.calls.map(call => String(call[0])); + expect( + warnMessages.some(message => + message.includes( + 'event=morning_briefing_delivery_failure channel=slack detail=slack send failed' + ) + ) + ).toBe(true); + }); }); diff --git a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/index.ts b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/index.ts index 45e34ce5a..0a209756d 100644 --- a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/index.ts +++ b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/index.ts @@ -8,6 +8,7 @@ import { type BriefingDeliveryResult, deliverBriefingToConfiguredChannels, formatDeliverySummary, + logDeliveryOutcomeEvents, parseStoredDelivery, } from './delivery-utils'; import { CommandExecutionError, runCommand } from './command-utils'; @@ -226,7 +227,7 @@ async function removeDuplicateBriefingCronJobs( ) => Promise<{ stdout: string; stderr: string; code: number | null }>; }; }; - logger: { warn?: (message: string) => void }; + logger: { info?: (message: string) => void; warn?: (message: string) => void }; }, canonicalId: string ): Promise { @@ -266,7 +267,7 @@ function resolveDefaults(api: { } function resolveEffectiveTimezone( - api: { logger: { warn?: (message: string) => void } }, + api: { logger: { info?: (message: string) => void; warn?: (message: string) => void } }, timezone: string, context: 'enable' | 'schedule' | 'date' ): string { @@ -408,7 +409,7 @@ async function ensureCronJob( ) => Promise<{ stdout: string; stderr: string; code: number | null }>; }; }; - logger: { warn?: (message: string) => void }; + logger: { info?: (message: string) => void; warn?: (message: string) => void }; }, config: StoredConfig ): Promise<{ cronJobId: string; cron: string; timezone: string }> { @@ -787,7 +788,7 @@ async function generateBriefing( }; }; config: unknown; - logger: { warn?: (message: string) => void }; + logger: { info?: (message: string) => void; warn?: (message: string) => void }; }, dateKey: string ): Promise<{ @@ -850,6 +851,7 @@ async function generateBriefing( error: errorText, })); } + logDeliveryOutcomeEvents(api, delivery); await patchStoredStatus(paths, { lastGeneratedDate: dateKey, @@ -911,7 +913,7 @@ async function resolveDateKeyForOffset( }; }; pluginConfig?: Record; - logger: { warn?: (message: string) => void }; + logger: { info?: (message: string) => void; warn?: (message: string) => void }; }, offset: number ): Promise { diff --git a/services/kiloclaw/src/routes/platform-morning-briefing.test.ts b/services/kiloclaw/src/routes/platform-morning-briefing.test.ts index ccb9cff47..86469cf81 100644 --- a/services/kiloclaw/src/routes/platform-morning-briefing.test.ts +++ b/services/kiloclaw/src/routes/platform-morning-briefing.test.ts @@ -210,7 +210,7 @@ describe('platform morning-briefing warm-up handling', () => { expect(runMorningBriefing).toHaveBeenCalledTimes(1); }); - it('returns warm-up payload for run timeout instead of generic 500', async () => { + it('returns timeout code for run timeout instead of generic 500', async () => { const runMorningBriefing = vi .fn<() => Promise>() .mockRejectedValue( From 668d62b722d70bb06e8c20e804b994462e395908 Mon Sep 17 00:00:00 2001 From: joshavant <830519+joshavant@users.noreply.github.com> Date: Sat, 25 Apr 2026 01:45:10 -0500 Subject: [PATCH 16/19] refactor(kiloclaw): share morning briefing delivery constants Extract delivery channel/status/reason enums into a shared module and reuse them in gateway response schemas and plugin delivery utilities to prevent drift. --- .../src/delivery-constants.ts | 10 ++++++++++ .../src/delivery-utils.ts | 9 +-------- .../gateway-controller-types.ts | 20 ++++++++----------- .../morning-briefing-delivery-constants.ts | 10 ++++++++++ 4 files changed, 29 insertions(+), 20 deletions(-) create mode 100644 services/kiloclaw/plugins/kiloclaw-morning-briefing/src/delivery-constants.ts create mode 100644 services/kiloclaw/src/durable-objects/morning-briefing-delivery-constants.ts diff --git a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/delivery-constants.ts b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/delivery-constants.ts new file mode 100644 index 000000000..04847c8af --- /dev/null +++ b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/delivery-constants.ts @@ -0,0 +1,10 @@ +export const DELIVERY_CHANNELS = ['telegram', 'discord', 'slack'] as const; + +export const DELIVERY_STATUSES = ['sent', 'skipped', 'failed'] as const; + +export const DELIVERY_REASONS = [ + 'missing_target', + 'ambiguous_target', + 'send_failed', + 'config_unavailable', +] as const; diff --git a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/delivery-utils.ts b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/delivery-utils.ts index c77effad0..810771396 100644 --- a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/delivery-utils.ts +++ b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/delivery-utils.ts @@ -1,18 +1,11 @@ import { formatBriefingMarkdownForMessage } from './briefing-utils'; import { type CommandCapableRuntime, isTimeoutExecutionError, runCommand } from './command-utils'; +import { DELIVERY_CHANNELS, DELIVERY_REASONS, DELIVERY_STATUSES } from './delivery-constants'; -export const DELIVERY_CHANNELS = ['telegram', 'discord', 'slack'] as const; export type DeliveryChannel = (typeof DELIVERY_CHANNELS)[number]; -export const DELIVERY_STATUSES = ['sent', 'skipped', 'failed'] as const; export type DeliveryStatus = (typeof DELIVERY_STATUSES)[number]; -export const DELIVERY_REASONS = [ - 'missing_target', - 'ambiguous_target', - 'send_failed', - 'config_unavailable', -] as const; export type DeliveryReason = (typeof DELIVERY_REASONS)[number]; export type BriefingDeliveryResult = { diff --git a/services/kiloclaw/src/durable-objects/gateway-controller-types.ts b/services/kiloclaw/src/durable-objects/gateway-controller-types.ts index f69c2b281..15fe284a1 100644 --- a/services/kiloclaw/src/durable-objects/gateway-controller-types.ts +++ b/services/kiloclaw/src/durable-objects/gateway-controller-types.ts @@ -1,4 +1,9 @@ import { z, type ZodType } from 'zod'; +import { + MORNING_BRIEFING_DELIVERY_CHANNELS, + MORNING_BRIEFING_DELIVERY_REASONS, + MORNING_BRIEFING_DELIVERY_STATUSES, +} from './morning-briefing-delivery-constants'; export type GatewayProcessStatus = { state: 'stopped' | 'starting' | 'running' | 'stopping' | 'crashed' | 'shutting_down'; @@ -104,21 +109,12 @@ const MorningBriefingSourceReadinessSchema = z.object({ summary: z.string(), }); -const DELIVERY_CHANNELS = ['telegram', 'discord', 'slack'] as const; -const DELIVERY_STATUSES = ['sent', 'skipped', 'failed'] as const; -const DELIVERY_REASONS = [ - 'missing_target', - 'ambiguous_target', - 'send_failed', - 'config_unavailable', -] as const; - const MorningBriefingDeliverySchema = z.object({ - channel: z.enum(DELIVERY_CHANNELS), - status: z.enum(DELIVERY_STATUSES), + channel: z.enum(MORNING_BRIEFING_DELIVERY_CHANNELS), + status: z.enum(MORNING_BRIEFING_DELIVERY_STATUSES), target: z.string().optional(), accountId: z.string().optional(), - reason: z.enum(DELIVERY_REASONS).optional(), + reason: z.enum(MORNING_BRIEFING_DELIVERY_REASONS).optional(), error: z.string().optional(), }); diff --git a/services/kiloclaw/src/durable-objects/morning-briefing-delivery-constants.ts b/services/kiloclaw/src/durable-objects/morning-briefing-delivery-constants.ts new file mode 100644 index 000000000..b44e27ddd --- /dev/null +++ b/services/kiloclaw/src/durable-objects/morning-briefing-delivery-constants.ts @@ -0,0 +1,10 @@ +export const MORNING_BRIEFING_DELIVERY_CHANNELS = ['telegram', 'discord', 'slack'] as const; + +export const MORNING_BRIEFING_DELIVERY_STATUSES = ['sent', 'skipped', 'failed'] as const; + +export const MORNING_BRIEFING_DELIVERY_REASONS = [ + 'missing_target', + 'ambiguous_target', + 'send_failed', + 'config_unavailable', +] as const; From 980531b704801fe16004b7e8c3013bdc3e4afa68 Mon Sep 17 00:00:00 2001 From: joshavant <830519+joshavant@users.noreply.github.com> Date: Sat, 25 Apr 2026 01:54:11 -0500 Subject: [PATCH 17/19] Revert "refactor(kiloclaw): share morning briefing delivery constants" This reverts commit 384cc0d4f18ac7744c3cce27f6effad603248bc1. --- .../src/delivery-constants.ts | 10 ---------- .../src/delivery-utils.ts | 9 ++++++++- .../gateway-controller-types.ts | 20 +++++++++++-------- .../morning-briefing-delivery-constants.ts | 10 ---------- 4 files changed, 20 insertions(+), 29 deletions(-) delete mode 100644 services/kiloclaw/plugins/kiloclaw-morning-briefing/src/delivery-constants.ts delete mode 100644 services/kiloclaw/src/durable-objects/morning-briefing-delivery-constants.ts diff --git a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/delivery-constants.ts b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/delivery-constants.ts deleted file mode 100644 index 04847c8af..000000000 --- a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/delivery-constants.ts +++ /dev/null @@ -1,10 +0,0 @@ -export const DELIVERY_CHANNELS = ['telegram', 'discord', 'slack'] as const; - -export const DELIVERY_STATUSES = ['sent', 'skipped', 'failed'] as const; - -export const DELIVERY_REASONS = [ - 'missing_target', - 'ambiguous_target', - 'send_failed', - 'config_unavailable', -] as const; diff --git a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/delivery-utils.ts b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/delivery-utils.ts index 810771396..c77effad0 100644 --- a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/delivery-utils.ts +++ b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/delivery-utils.ts @@ -1,11 +1,18 @@ import { formatBriefingMarkdownForMessage } from './briefing-utils'; import { type CommandCapableRuntime, isTimeoutExecutionError, runCommand } from './command-utils'; -import { DELIVERY_CHANNELS, DELIVERY_REASONS, DELIVERY_STATUSES } from './delivery-constants'; +export const DELIVERY_CHANNELS = ['telegram', 'discord', 'slack'] as const; export type DeliveryChannel = (typeof DELIVERY_CHANNELS)[number]; +export const DELIVERY_STATUSES = ['sent', 'skipped', 'failed'] as const; export type DeliveryStatus = (typeof DELIVERY_STATUSES)[number]; +export const DELIVERY_REASONS = [ + 'missing_target', + 'ambiguous_target', + 'send_failed', + 'config_unavailable', +] as const; export type DeliveryReason = (typeof DELIVERY_REASONS)[number]; export type BriefingDeliveryResult = { diff --git a/services/kiloclaw/src/durable-objects/gateway-controller-types.ts b/services/kiloclaw/src/durable-objects/gateway-controller-types.ts index 15fe284a1..f69c2b281 100644 --- a/services/kiloclaw/src/durable-objects/gateway-controller-types.ts +++ b/services/kiloclaw/src/durable-objects/gateway-controller-types.ts @@ -1,9 +1,4 @@ import { z, type ZodType } from 'zod'; -import { - MORNING_BRIEFING_DELIVERY_CHANNELS, - MORNING_BRIEFING_DELIVERY_REASONS, - MORNING_BRIEFING_DELIVERY_STATUSES, -} from './morning-briefing-delivery-constants'; export type GatewayProcessStatus = { state: 'stopped' | 'starting' | 'running' | 'stopping' | 'crashed' | 'shutting_down'; @@ -109,12 +104,21 @@ const MorningBriefingSourceReadinessSchema = z.object({ summary: z.string(), }); +const DELIVERY_CHANNELS = ['telegram', 'discord', 'slack'] as const; +const DELIVERY_STATUSES = ['sent', 'skipped', 'failed'] as const; +const DELIVERY_REASONS = [ + 'missing_target', + 'ambiguous_target', + 'send_failed', + 'config_unavailable', +] as const; + const MorningBriefingDeliverySchema = z.object({ - channel: z.enum(MORNING_BRIEFING_DELIVERY_CHANNELS), - status: z.enum(MORNING_BRIEFING_DELIVERY_STATUSES), + channel: z.enum(DELIVERY_CHANNELS), + status: z.enum(DELIVERY_STATUSES), target: z.string().optional(), accountId: z.string().optional(), - reason: z.enum(MORNING_BRIEFING_DELIVERY_REASONS).optional(), + reason: z.enum(DELIVERY_REASONS).optional(), error: z.string().optional(), }); diff --git a/services/kiloclaw/src/durable-objects/morning-briefing-delivery-constants.ts b/services/kiloclaw/src/durable-objects/morning-briefing-delivery-constants.ts deleted file mode 100644 index b44e27ddd..000000000 --- a/services/kiloclaw/src/durable-objects/morning-briefing-delivery-constants.ts +++ /dev/null @@ -1,10 +0,0 @@ -export const MORNING_BRIEFING_DELIVERY_CHANNELS = ['telegram', 'discord', 'slack'] as const; - -export const MORNING_BRIEFING_DELIVERY_STATUSES = ['sent', 'skipped', 'failed'] as const; - -export const MORNING_BRIEFING_DELIVERY_REASONS = [ - 'missing_target', - 'ambiguous_target', - 'send_failed', - 'config_unavailable', -] as const; From dfedfa9be78d6bc05f2da02afc05f87a304885ca Mon Sep 17 00:00:00 2001 From: joshavant <830519+joshavant@users.noreply.github.com> Date: Mon, 27 Apr 2026 10:26:35 -0500 Subject: [PATCH 18/19] refactor(kiloclaw): dedupe delivery enums via shared constants Define morning briefing delivery channel/status/reason enums once and reuse them in both plugin delivery logic and gateway controller response schemas to prevent drift. --- .../src/delivery-constants.ts | 10 ++++++++++ .../src/delivery-utils.ts | 9 +-------- .../durable-objects/gateway-controller-types.ts | 14 +++++--------- 3 files changed, 16 insertions(+), 17 deletions(-) create mode 100644 services/kiloclaw/plugins/kiloclaw-morning-briefing/src/delivery-constants.ts diff --git a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/delivery-constants.ts b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/delivery-constants.ts new file mode 100644 index 000000000..04847c8af --- /dev/null +++ b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/delivery-constants.ts @@ -0,0 +1,10 @@ +export const DELIVERY_CHANNELS = ['telegram', 'discord', 'slack'] as const; + +export const DELIVERY_STATUSES = ['sent', 'skipped', 'failed'] as const; + +export const DELIVERY_REASONS = [ + 'missing_target', + 'ambiguous_target', + 'send_failed', + 'config_unavailable', +] as const; diff --git a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/delivery-utils.ts b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/delivery-utils.ts index c77effad0..810771396 100644 --- a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/delivery-utils.ts +++ b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/delivery-utils.ts @@ -1,18 +1,11 @@ import { formatBriefingMarkdownForMessage } from './briefing-utils'; import { type CommandCapableRuntime, isTimeoutExecutionError, runCommand } from './command-utils'; +import { DELIVERY_CHANNELS, DELIVERY_REASONS, DELIVERY_STATUSES } from './delivery-constants'; -export const DELIVERY_CHANNELS = ['telegram', 'discord', 'slack'] as const; export type DeliveryChannel = (typeof DELIVERY_CHANNELS)[number]; -export const DELIVERY_STATUSES = ['sent', 'skipped', 'failed'] as const; export type DeliveryStatus = (typeof DELIVERY_STATUSES)[number]; -export const DELIVERY_REASONS = [ - 'missing_target', - 'ambiguous_target', - 'send_failed', - 'config_unavailable', -] as const; export type DeliveryReason = (typeof DELIVERY_REASONS)[number]; export type BriefingDeliveryResult = { diff --git a/services/kiloclaw/src/durable-objects/gateway-controller-types.ts b/services/kiloclaw/src/durable-objects/gateway-controller-types.ts index f69c2b281..74eb6c496 100644 --- a/services/kiloclaw/src/durable-objects/gateway-controller-types.ts +++ b/services/kiloclaw/src/durable-objects/gateway-controller-types.ts @@ -1,4 +1,9 @@ import { z, type ZodType } from 'zod'; +import { + DELIVERY_CHANNELS, + DELIVERY_REASONS, + DELIVERY_STATUSES, +} from '../../plugins/kiloclaw-morning-briefing/src/delivery-constants'; export type GatewayProcessStatus = { state: 'stopped' | 'starting' | 'running' | 'stopping' | 'crashed' | 'shutting_down'; @@ -104,15 +109,6 @@ const MorningBriefingSourceReadinessSchema = z.object({ summary: z.string(), }); -const DELIVERY_CHANNELS = ['telegram', 'discord', 'slack'] as const; -const DELIVERY_STATUSES = ['sent', 'skipped', 'failed'] as const; -const DELIVERY_REASONS = [ - 'missing_target', - 'ambiguous_target', - 'send_failed', - 'config_unavailable', -] as const; - const MorningBriefingDeliverySchema = z.object({ channel: z.enum(DELIVERY_CHANNELS), status: z.enum(DELIVERY_STATUSES), From 572c9dce61407a869f6bcb4065089330b751cffd Mon Sep 17 00:00:00 2001 From: joshavant <830519+joshavant@users.noreply.github.com> Date: Mon, 27 Apr 2026 11:23:35 -0500 Subject: [PATCH 19/19] fix(kiloclaw): import delivery channels from constants module Resolve plugin pack build failure by importing DELIVERY_CHANNELS directly from delivery-constants instead of delivery-utils, keeping a single source for delivery enum declarations. --- .../kiloclaw/plugins/kiloclaw-morning-briefing/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/index.ts b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/index.ts index 0a209756d..4c1fed24d 100644 --- a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/index.ts +++ b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/index.ts @@ -4,13 +4,13 @@ import { Type } from '@sinclair/typebox'; import { definePluginEntry } from 'openclaw/plugin-sdk/plugin-entry'; import { buildBriefingMarkdown, offsetDateKey, resolveBriefingPath } from './briefing-utils'; import { - DELIVERY_CHANNELS, type BriefingDeliveryResult, deliverBriefingToConfiguredChannels, formatDeliverySummary, logDeliveryOutcomeEvents, parseStoredDelivery, } from './delivery-utils'; +import { DELIVERY_CHANNELS } from './delivery-constants'; import { CommandExecutionError, runCommand } from './command-utils'; import { filterEnabledBriefingJobs,