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
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ Each layer returns `{ handled: true }` to stop propagation, or `{ handled: false
- `tools.ts` — 40+ tool definitions (the Claude API schema).
- `tool-executor.ts` — dispatches tool name → handler function.
- `tool-handlers/` — one file per domain: `events`, `reminders`, `sharing`, `secretary`, `proposals`, `slots`, `settings`, `meta`, `feedback`, `history`.
- Picture tools (`render_day_image`, `render_week_image`, `render_month_image`, `render_table` in `tool-handlers/render.ts`) never wait for the render: they enqueue through `ctx.imageDelivery` (`src/services/image/image-delivery.ts`) and return "queued, NOT sent" at once; the picture arrives later as its own message, a failure sends one note and reaches the alert queue (runbook "Asynchronous pictures"). Never put a render wait back into a tool call and never tell the model a picture "was sent".
- `system-prompt.ts` — builds the system prompt as `SystemPromptParts` in prefix-cache order. Providers cache by leading bytes (z.ai implicit cache, Anthropic `cache_control`), so a per-request value must never be added ahead of static text; `test/services/ai/prompt-prefix-stability.test.ts` enforces it and `scripts/measure-ai-payload.ts` reports the shared-prefix tokens.
- `stable` — identical for every request (role, Context, static Rules, Proactive, Shared Events, Stall).
- `modeRules` — static text that depends only on the mode (group chat rules, voice/live-call instructions), never on user data.
Expand Down Expand Up @@ -111,7 +112,7 @@ If you add a new `FeatureKey`, add it to `FEATURE_KEYS` in `src/database/reposit
For periodic/scheduled tasks always use BullMQ repeating jobs — never `setInterval` or `setTimeout`. Repeating jobs survive restarts and are observable in the queue.

BullMQ on Redis; the main queues:
- **image-render** — Playwright renders weekly/monthly calendar images.
- **image-render** — Playwright renders the calendar images. EVERY picture (AI tools, `/today` `/tomorrow` `/week` `/month`, agenda buttons, the conflict picture sent to an inviter) goes through `ImageDelivery` (`queuePicture` for non-AI callers): the handler sends its text, queues the picture and returns; the picture arrives later, bounded at 150 s, one note on failure, a timeout reaches the alert queue as `reportTimeout({kind: 'render'})`. Nothing in the bot awaits `RenderService.renderDirect`; do not add a render wait to a handler.
- **call-reminders** — voice call reminders via the Python bridge; created only when the MTProto service tier is enabled.
- **bot-tasks** — periodic jobs: secretary expiry (daily), sharing cleanup (10min), proposal expiry (hourly), per-user session keepalive (14 days), AI usage ledger cleanup (daily), birthday sync (daily, only when the service tier is enabled).

Expand Down
12 changes: 12 additions & 0 deletions docs/intents/response-quality.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,18 @@ integration is never an empty result.
**Proportion.** A short question does not need a long formal report. Necessary clarifications
stay; internal identifiers are shown only when they help choose or confirm an object.

**Pictures are announced as coming, never as attached.** A day, week, month, table or schedule-comparison
picture is rendered in the background and arrives as its own message after the text answer, whether it
comes from the AI, a command (`/today`, `/tomorrow`, `/week`, `/month`), an agenda button or an
invitation notice (see the deploy runbook, "Asynchronous pictures"). An answer or workflow reply therefore
never says the picture "is attached", "is below" or "was sent": the tool result reads "is being prepared
and will arrive in the chat as a separate message" (ru: «готовится и придёт в чат отдельным сообщением»),
i.e. the picture is on the way. When the AI answers, the facts a picture shows belong in its text as
before; a command's text already carries them. When a picture cannot be made the user gets exactly one
short note saying so (it timed out, it failed, or the bot restarted), naming the picture by its date,
week, month or title and offering to try again; the note never contains an internal error and never claims
the text already says everything the picture showed.

## Example

Request: "Что завтра?" ("What is tomorrow?")
Expand Down
55 changes: 55 additions & 0 deletions docs/reference/deploy-runbook.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/reference/migrations/069_chat_history_turn_order.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ as before. No existing column or row is read, changed or deleted.
## Order in the file

Appended after `067_ai_usage_role_preset_cost`, the last entry. Nothing is moved or renumbered. The number
069 leaves 068 to the other open changes that claim it (the AI speed metrics and the image deliveries).
069 leaves 068 to the other open change that claims it (the AI speed metrics); the image deliveries took 070.

## Why it is safe to deploy automatically

Expand Down
61 changes: 61 additions & 0 deletions docs/reference/migrations/070_image_deliveries.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
---
migration: 070_image_deliveries
rollback-compatible: yes
data-deletion: no
---

# Migration 070: durable picture deliveries

`src/database/migrations.ts`, migration `070_image_deliveries`. Asynchronous AI pictures, tracked in #799 and #936.

## What it does

```sql
CREATE TABLE image_deliveries (
id TEXT PRIMARY KEY, -- deterministic delivery id (also the BullMQ job id suffix)
chat_id INTEGER NOT NULL,
language TEXT NOT NULL,
kind TEXT NOT NULL, -- day | week | month | table | conflict
label TEXT NOT NULL, -- what the user asked for; only ever shown back to that chat
job_type TEXT NOT NULL,
slot TEXT NOT NULL, -- what the picture shows (kind, target, whose calendar; an AI picture's slot also ends with the user's language): a later request for the same slot is deduplicated or supersedes it, also after a restart
payload_hash TEXT NOT NULL, -- hash of the render data of job_json: the same slot with the same hash is the same picture
rebuilds INTEGER NOT NULL DEFAULT 0, -- how many times the job was rebuilt after a calendar write; each rebuild is its own BullMQ job id
job_json TEXT, -- the current render job (replaced by a rebuild), kept until the delivery is terminal, then NULL
state TEXT NOT NULL CHECK (state IN ('queued','rendered','sending','delivered','failed','expired','unknown','superseded')),
message_id INTEGER, -- Telegram message id once delivered
note_sent INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
);
CREATE INDEX idx_image_deliveries_state ON image_deliveries(state);
```

A new table that only this feature reads and writes. Terminal rows (delivered, failed, expired, unknown, superseded) carry no job payload and are pruned once they are 7 days old, at the next start of the bot (the start-up reconciliation is the only caller; a process that stays up keeps them until then).

## Order in the file

Appended after `069_chat_history_turn_order`, the last entry at the time of writing. It was written as 068 on its branch and renumbered to 070 before it was ever released, because main took 069 meanwhile; nothing has recorded the old name. If another branch appends first, this one is renamed before it ships, never after.

## Why it is safe to deploy automatically

Additive DDL: one new table and one index. The previous image does not know the table and ignores it.

## Rollback

An image rollback needs no schema step. Pending rows are then not recovered by the old image (the pictures are lost, as they were before this feature); the next start of this feature ends each of them as `expired`: recent ones (within 10 minutes of their bound) with the one "the bot was restarting, the picture did not arrive" note and no alert (the cause was downtime, not a slow render), older ones silently (their note is claimed unsent) and without an alert, because the user stopped waiting long ago. A row stuck in `sending` ends `unknown` and raises its one `UncertainSend` alert only when its last update is less than 10 minutes old; an older one ends `unknown` silently. Keep the table and the `migrations` row. If the table ever has to go, it holds only delivery bookkeeping:

```sql
BEGIN IMMEDIATE;
DROP TABLE image_deliveries;
DELETE FROM migrations WHERE name = '070_image_deliveries';
COMMIT;
```

## Verifying after deploy

```sql
SELECT name FROM migrations WHERE name = '070_image_deliveries';
SELECT state, count(*) FROM image_deliveries GROUP BY state;
```

Expect one migration row; `queued`/`rendered`/`sending` rows older than the 150 s bound mean a delivery that was never reconciled.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "hypercalendarbot"
version = "0.3.5"
version = "0.3.6"
requires-python = ">=3.12"
dependencies = [
"pyrofork>=2.3",
Expand Down
6 changes: 3 additions & 3 deletions scripts/intent-benchmark/world.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import type { AgentContext, SharingCapability, TelegramSender } from '../../src/
import { ConversationLogger } from '../../src/services/conversation-logger.ts';
import { EventService } from '../../src/services/event/event-service.ts';
import { HolidayService } from '../../src/services/holiday/holiday-service.ts';
import type { ImageRenderer } from '../../src/services/image/render-service.ts';
import { ImageDelivery } from '../../src/services/image/image-delivery.ts';
import { IntentExecutor } from '../../src/services/intent/intent-executor.ts';
import type { IntentMatcher } from '../../src/services/intent/intent-matcher.ts';
import {
Expand Down Expand Up @@ -272,7 +272,7 @@ export function buildFixture(
privacyService,
editProposalRepo: new EditProposalRepository(db),
};
const renderService: ImageRenderer = { renderDirect: async () => pngStub() };
const imageDelivery = new ImageDelivery({ renderer: { renderDirect: async () => pngStub() } });
const sender: TelegramSender = {
sendMessage: async () => ({ message_id: 3 }),
editMessageText: async () => {},
Expand All @@ -296,7 +296,7 @@ export function buildFixture(
actionLogRepo: new ActionLogRepository(db),
toolOrigin: 'intent_workflow',
sharing: sharing,
renderService,
imageDelivery,
sender,
});
const callTool = async (name: string, input: unknown, origin?: { text: string; actorId: number; chatId: number }) => {
Expand Down
9 changes: 6 additions & 3 deletions scripts/verify-agenda-three-transports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type { AgentContext } from '../src/services/ai/types.ts';
import { ConversationLogger } from '../src/services/conversation-logger.ts';
import { EventService } from '../src/services/event/event-service.ts';
import { HolidayService } from '../src/services/holiday/holiday-service.ts';
import { ImageDelivery } from '../src/services/image/image-delivery.ts';

const bytes = Buffer.from(await Bun.file('logs/agenda-three/synthetic100.png').arrayBuffer());
const db = new Database(':memory:');
Expand All @@ -40,14 +41,15 @@ try {
};
bot.api.sendMessage = async () => payload;
bot.api.pinChatMessage = async () => true;
const renderer = { renderDirect: async () => bytes };
const delivery = new ImageDelivery({ renderer: { renderDirect: async () => bytes } });
const command = Object.assign(new MessageContext({ bot, payload }), {
dbUser: user,
userTimezone: 'UTC',
lang: 'en' as const,
scene: { enter: async () => {} },
});
await handleToday(command, events, undefined, renderer);
await handleToday(command, events, undefined, delivery);
await delivery.whenIdle();
assert.equal(documents, 1);
const history = new ChatHistoryRepository(db);
const ctx: AgentContext = {
Expand All @@ -62,10 +64,11 @@ try {
conversationLogger: new ConversationLogger(history),
eventReminderRepo: new EventReminderRepository(db),
sender: createTelegramSender(bot),
renderService: renderer,
imageDelivery: delivery,
};
const result = await handleRenderDayImage(ctx, { date: new Date().toISOString().slice(0, 10) });
assert.equal(result.success, true);
await delivery.whenIdle();
assert.equal(documents, 2);
console.log(
JSON.stringify({
Expand Down
17 changes: 17 additions & 0 deletions src/bootstrap/domain-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
// - TriggerService subscribes later, in scheduled-ai.ts, once the AI queue exists.

import { AgendaRepository } from '../database/repositories/agenda.repository.ts';
import type { ImageDelivery } from '../services/image/image-delivery.ts';
import { DomainEventBus } from '../services/scheduled/domain-event-bus.ts';
import { InvitationCardRefresher } from '../services/sharing/invitation-cards.ts';
import type { WeatherService } from '../services/weather/weather-service.ts';
Expand Down Expand Up @@ -77,9 +78,24 @@ function subscribeInvitationCardRefresh(
});
}

/**
* A picture still rendering when the calendar changes would show the old state next to a text that
* already states the new one: pending pictures of the changed data are rebuilt once.
*/
export function subscribeImageStaleness(
bus: DomainEventBus,
imageDelivery: Pick<ImageDelivery, 'eventsChanged'>,
): void {
bus.on('myCalendar.newEvent', ({ userId }) => imageDelivery.eventsChanged(userId));
bus.on('myCalendar.updatedEvent', ({ userId }) => imageDelivery.eventsChanged(userId));
bus.on('myCalendar.deletedEvent', ({ userId }) => imageDelivery.eventsChanged(userId));
bus.on('myGroup.newEvent', ({ userId }) => imageDelivery.eventsChanged(userId));
}

export interface DomainEventInputs {
participantPushScheduler: ParticipantPushScheduler | undefined;
weatherService: WeatherService | undefined;
imageDelivery: ImageDelivery | undefined;
}

export function createDomainEventBus(ctx: StartupContext, inputs: DomainEventInputs): DomainEventBus {
Expand All @@ -88,5 +104,6 @@ export function createDomainEventBus(ctx: StartupContext, inputs: DomainEventInp
subscribeParticipantGooglePush(bus, inputs.participantPushScheduler, ctx.db.groupMembers);
}
subscribeInvitationCardRefresh(bus, ctx, inputs.weatherService);
if (inputs.imageDelivery) subscribeImageStaleness(bus, inputs.imageDelivery);
return bus;
}
69 changes: 63 additions & 6 deletions src/bootstrap/image-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,66 @@
//
// Ordering invariants:
// - Only with REDIS_URL. The Playwright pool starts first; if it cannot initialise, image
// rendering is disabled (renderService stays undefined) and startup continues.
// rendering is disabled (imageDelivery stays undefined: commands send text only) and startup continues.
// - The queue/worker modules are imported lazily so a deployment without rendering never loads
// Playwright.
// - The pool is shut down last when the queue is closed (see shutdown.ts: imageQueue).
// - ImageDelivery (the background delivery of EVERY picture: AI tools, commands, buttons, invitation
// notices) sits on top of the render service and
// is closed BEFORE the queue (shutdown.ts: imageDelivery): its pending renders are cancelled but their
// BullMQ jobs and database rows are kept, so the next start delivers them (a delivery without a store
// tells its user instead), and the held render-failure summary is sent. It learns of calendar writes from the domain event
// bus (domain-events.ts), which is created later.
// - Deliveries are durable (image_deliveries, migration 070). Once the bot exists, index.ts attaches
// the Telegram transport and calls `reconcile()`: what the previous process left unfinished is
// delivered, expired with one note, or marked unknown (never re-sent).

import type { Queue } from 'bullmq';
import type { RenderService } from '../services/image/render-service.ts';
import type { TelegramSender } from '../services/ai/types.ts';
import { ImageDelivery } from '../services/image/image-delivery.ts';
import { RenderFailureReporter } from '../services/image/render-failure-reporter.ts';
import { sendAgendaImage } from '../utils/agenda-image.ts';
import { botLogger } from '../utils/logger.ts';
import type { ImageRenderJob } from '../worker/image-render.queue.ts';
import type { StartupContext } from './runtime.ts';

export async function initImageRender(ctx: StartupContext): Promise<RenderService | undefined> {
export interface ImageRender {
/** Background delivery of every calendar picture; nothing in the bot awaits a render. */
imageDelivery: ImageDelivery;
}

export async function initImageRender(ctx: StartupContext): Promise<ImageRender | undefined> {
const { config } = ctx;
if (!config.REDIS_URL) return undefined;

const { createImageRenderQueue } = await import('../worker/image-render.queue.ts');
const { RenderService: RenderServiceClass } = await import('../services/image/render-service.ts');
const { playwrightPool } = await import('../worker/playwright-pool.ts');

let renderService: RenderService | undefined;
let imageRender: ImageRender | undefined;
try {
await playwrightPool.initialize();

const { queue: imageQueue, worker, queueEvents } = createImageRenderQueue(config.REDIS_URL);
worker.on('failed', ctx.onWorkerFailed('image-render'));
renderService = new RenderServiceClass(imageQueue as Queue<ImageRenderJob>, queueEvents);
const renderService = new RenderServiceClass(imageQueue as Queue<ImageRenderJob>, queueEvents);
const failures = new RenderFailureReporter((text, source) => ctx.db.alerts.push(text, source));
const imageDelivery = new ImageDelivery({
renderer: renderService,
store: ctx.db.imageDeliveries,
reportFailure: (report) => failures.report(report),
});
imageRender = { imageDelivery };

ctx.resources.imageDelivery = {
close: async () => {
try {
await imageDelivery.close();
} finally {
failures.flush(); // the summary held for the alert window must not die with the process
}
},
};

ctx.resources.imageQueue = {
close: async () => {
Expand All @@ -44,5 +78,28 @@ export async function initImageRender(ctx: StartupContext): Promise<RenderServic
} catch (err) {
botLogger.error({ err }, 'Playwright initialization failed — image rendering disabled');
}
return renderService;
return imageRender;
}

/**
* Once the bot exists: gives deliveries the Telegram transport for recovery and reconciles what the
* previous process left unfinished. Fire-and-forget: startup must not wait on Telegram, and a failure
* here is logged, never fatal.
*/
export function recoverImageDeliveries(imageDelivery: ImageDelivery, sender: TelegramSender): void {
imageDelivery.attachTransport({
notify: (chatId, text) => sender.sendMessage(chatId, text),
sendImage: async (chatId, png, language) => {
const file = new File([png], 'picture.png', { type: 'image/png' });
const sent = await sendAgendaImage(file, {
language,
sendPhoto: (photo) => sender.sendPhoto!(chatId, photo),
sendDocument: sender.sendDocument
? (document, options) => sender.sendDocument!(chatId, document, options.caption)
: undefined,
});
return sent.message_id;
},
});
imageDelivery.reconcile().catch((err: unknown) => botLogger.error({ err }, 'Image delivery reconciliation failed'));
}
6 changes: 6 additions & 0 deletions src/bootstrap/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ export interface ShutdownResources {
notificationQueue?: ClosableResource;
botTasksQueue?: ClosableResource;
syncQueue?: ClosableResource;
/**
* The picture deliveries still pending, closed before the image queue. With the store (always in production) no note
* is sent: the rows stay queued and the BullMQ jobs are kept, so the next start resumes them. Also sends the
* render-failure summary still held back for its alert window.
*/
imageDelivery?: ClosableResource;
imageQueue?: ClosableResource;
broadcastQueue?: ClosableResource;
callQueue?: ClosableResource;
Expand Down
Loading
Loading