Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,14 @@
"check:templates": "node scripts/check-vue-templates.mjs",
"check:template-bindings": "node scripts/check-template-bindings.mjs",
"check:modal-race": "node scripts/check-modal-race.mjs",
"check": "npm run check:templates && npm run check:template-bindings && npm run check:modal-race && npm run check:host-access-races && npm run check:socket-identity && npm run check:schedule-time && npm run check:config-health && npm run check:config-center-ui2 && npm run check:config-save-boundaries && npm run build",
"check": "npm run check:templates && npm run check:template-bindings && npm run check:modal-race && npm run check:host-access-races && npm run check:socket-identity && npm run check:schedule-time && npm run check:schedule-report-format && npm run check:config-health && npm run check:config-center-ui2 && npm run check:config-save-boundaries && npm run build",
"check:host-access-races": "node scripts/check-host-access-races.mjs",
"check:socket-identity": "node scripts/check-socket-identity.mjs",
"check:schedule-time": "node scripts/check-schedule-time.mjs",
"check:config-health": "node scripts/check-config-health.mjs",
"check:config-center-ui2": "node scripts/check-config-center-ui2.mjs",
"check:config-save-boundaries": "node scripts/check-config-save-boundaries.mjs"
"check:config-save-boundaries": "node scripts/check-config-save-boundaries.mjs",
"check:schedule-report-format": "node scripts/check-schedule-report-format.mjs"
},
"dependencies": {
"dompurify": "^3.2.0",
Expand Down
19 changes: 19 additions & 0 deletions scripts/check-schedule-report-format.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import fs from 'node:fs';
import process from 'node:process';

const source = fs.readFileSync(new URL('../ui/js/pages/schedules.js', import.meta.url), 'utf8');
const assertions = [
['format selector exists', source.includes('v-model="form.report_format"')],
['generic v1 option exists', source.includes('value="paginated_embed_v1"')],
['form state owns field', source.includes("report_format: ''")],
['create payload submits field', source.includes('payload.report_format = f.report_format')],
['list readback renders field', source.includes("s.report_format || ''")],
['update surface submits field', source.includes('report_format: reportFormat')],
['update surface refreshes authoritative state', source.includes('await fetchSchedules()')],
];
const failures = assertions.filter(([, passed]) => !passed);
for (const [name, passed] of assertions) {
console.log(`${passed ? 'ok' : 'not ok'} - ${name}`);
}
if (failures.length) process.exit(1);
console.log(`schedule-report-format: ${assertions.length} assertions passed`);
2 changes: 2 additions & 0 deletions src/discord/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@ def __init__(self, config: Config) -> None:
self.tool_loop = components.tool_loop
self.turn_recorder = components.turn_recorder
self.scheduled_events = components.scheduled_events
self.scheduled_report_renderers = components.scheduled_report_renderers
self.scheduled_reports = components.scheduled_reports
self.agent_task_tools = components.agent_task_tools
self.intake = components.intake
self.pipeline = components.pipeline
Expand Down
22 changes: 17 additions & 5 deletions src/discord/cogs/reaction_triggers.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,15 @@
from __future__ import annotations

import logging
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any, cast

from discord.ext import commands

import discord

if TYPE_CHECKING:
from src.config.schema import ReactionTriggerConfig
from src.discord.scheduled_report import ScheduledReportPaginationService
from src.scheduler.scheduler import Scheduler

logger = logging.getLogger("odin.reaction_triggers")
Expand All @@ -36,10 +37,12 @@ def __init__(
*,
config: ReactionTriggerConfig | None = None,
scheduler: Scheduler | None = None,
pagination: ScheduledReportPaginationService | None = None,
) -> None:
self.bot = bot
self._config = config
self._scheduler = scheduler
self._pagination = pagination

@property
def enabled(self) -> bool:
Expand Down Expand Up @@ -82,11 +85,19 @@ def _is_user_allowed(self, user_id: int) -> bool:
@commands.Cog.listener()
async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent) -> None:
"""Handle a reaction being added to a message."""
if not self.enabled:
# Ignore the bot's own control reactions before either subsystem.
if self.bot.user and payload.user_id == self.bot.user.id:
return

# Pagination is message-local and independent of the optional generic
# reaction-trigger feature and its channel/user allowlists.
if self._pagination and self._pagination.handles(
payload.message_id, payload.emoji
):
await self._pagination.handle_reaction(payload)
return

# Ignore bot's own reactions
if payload.user_id == self.bot.user.id: # type: ignore[union-attr] # raw events fire post-READY
if not self.enabled:
return

# Check channel allowlist
Expand Down Expand Up @@ -133,4 +144,5 @@ async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent) ->

async def setup(bot: commands.Bot) -> None:
"""Standard discord.py cog setup (no-op scheduler/config — wired later)."""
await bot.add_cog(ReactionTriggers(bot))
components = cast(Any, bot).components
await bot.add_cog(ReactionTriggers(bot, pagination=components.scheduled_reports))
2 changes: 2 additions & 0 deletions src/discord/native_tools/scheduling.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ async def _handle_schedule_task(self, message, inp: dict) -> str:
trigger=inp.get("trigger"),
cron_timezone=inp.get("cron_timezone"),
requester_id=str(message.author.id),
report_format=inp.get("report_format"),
)
if schedule.get("trigger"):
trigger_desc = ", ".join(f"{k}={v}" for k, v in schedule["trigger"].items())
Expand Down Expand Up @@ -171,6 +172,7 @@ async def _handle_update_schedule(self, inp: dict) -> str:
"steps",
"channel_id",
"cron_timezone",
"report_format",
):
if key in inp:
kwargs[key] = inp[key]
Expand Down
33 changes: 29 additions & 4 deletions src/discord/scheduled_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
from ..tools.executor import ToolExecutor
from .llm_gateway import LLMGateway
from .native_tools.agents_tasks import AgentTaskTools
from .scheduled_report import ScheduledReportPaginationService
from .tool_loop import ToolLoopRunner

log = get_logger("discord")
Expand All @@ -48,6 +49,7 @@ class ScheduledEventsDeps:
llm_gateway: LLMGateway # owns the swappable provider clients
tool_loop: ToolLoopRunner # shared dispatch path
agent_task_tools: AgentTaskTools # agent result collection in workflows
scheduled_reports: ScheduledReportPaginationService | None = None


class ScheduledEventHandlers:
Expand All @@ -60,6 +62,7 @@ def __init__(self, deps: ScheduledEventsDeps) -> None:
self._llm_gateway = deps.llm_gateway
self._tool_loop = deps.tool_loop
self._agent_task_tools = deps.agent_task_tools
self._scheduled_reports = deps.scheduled_reports

async def _on_scheduled_digest(self, schedule: dict) -> None:
"""Run the daily infrastructure digest and post results."""
Expand Down Expand Up @@ -415,10 +418,32 @@ async def _on_scheduled_task(self, schedule: dict) -> None:
pass
raise RuntimeError(f"Scheduled check failed: {str(result)[:200]}")
else:
text = (
f"**Scheduled: {schedule['description']}**\n```\n{str(result)[:1800]}\n```"
)
await channel.send(scrub_response_secrets(text))
report_format = schedule.get("report_format")
if report_format:
if self._scheduled_reports is None:
raise RuntimeError("Scheduled report service is unavailable")
try:
# The pagination service parses JSON first and scrubs
# only validated strings that can reach Discord.
await self._scheduled_reports.post(channel, report_format, str(result))
except Exception as e:
text = (
f"**Scheduled report failed:** {schedule['description']}\n"
f"Error: {e}"
)
try:
await channel.send(scrub_response_secrets(text))
except Exception:
pass
raise RuntimeError(
f"Failed to render scheduled report {report_format}: {e}"
) from e
else:
text = (
f"**Scheduled: {schedule['description']}**\n```\n"
f"{str(result)[:1800]}\n```"
)
await channel.send(scrub_response_secrets(text))
except RuntimeError:
raise
except Exception as e:
Expand Down
Loading
Loading