diff --git a/src/agent/index.ts b/src/agent/index.ts index 53d8a4d..8389275 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -178,22 +178,24 @@ export class WorkCoordinator { return curateTrending(newRepos, feedback) } - // Step 4: deliver the digest via Telegram. Returns whether the send succeeded. - private static async sendTelegram(repos: CuratedRepo[]): Promise { + // Step 4: deliver the digest via Telegram. Returns the repo names that went out. + private static async sendTelegram(repos: CuratedRepo[]): Promise> { logger.info('⚡️ Sending trending digest via Telegram...') try { - await trendingTelegramTool.invoke({ repos }) - return true + const raw = await trendingTelegramTool.invoke({ repos }) + const { delivered } = parseJson<{ delivered?: string[] }>(raw, {}) + + return new Set(delivered ?? []) } catch (err) { logger.error({ err }, '❌ Telegram delivery failed') await notifyError('Trending Telegram delivery', err) - return false + return new Set() } } - // Step 5: persist the curated repos, tagging whether delivery succeeded. - private static async saveTrending(repos: CuratedRepo[], sent: boolean, now: string): Promise { + // Step 5: persist the curated repos, tagging each with whether it was delivered. + private static async saveTrending(repos: CuratedRepo[], delivered: Set, now: string): Promise { try { await saveTrendingRepos( repos.map((r) => ({ @@ -205,12 +207,12 @@ export class WorkCoordinator { today_stars: r.today_stars, summary: r.summary, tags: r.tags, - sent, + sent: delivered.has(r.repo_name), created_at: now, updated_at: now, })) ) - logger.info(`✅ Saved ${repos.length} trending repos to database.`) + logger.info(`✅ Saved ${repos.length} trending repos to database (${delivered.size} marked sent).`) } catch (err) { logger.error({ err }, '❌ Failed to save trending repos') await notifyError('saveTrendingRepos', err) @@ -366,10 +368,10 @@ export class WorkCoordinator { if (!curated) return // ── Step 4: Send via Telegram ─────────────────────────────────────────── - const sent = await WorkCoordinator.sendTelegram(curated) + const delivered = await WorkCoordinator.sendTelegram(curated) // ── Step 5: Save to DB ────────────────────────────────────────────────── - await WorkCoordinator.saveTrending(curated, sent, now) + await WorkCoordinator.saveTrending(curated, delivered, now) sectionLogger(`✅ GitHub Trending job complete for ${today}`) } diff --git a/src/tools/news-telegram.test.ts b/src/tools/news-telegram.test.ts index f50135d..1057e64 100644 --- a/src/tools/news-telegram.test.ts +++ b/src/tools/news-telegram.test.ts @@ -1,6 +1,6 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { buildTrendingMessage } from './news-telegram.tool.ts' +import { buildTrendingMessage, fitRepos } from './news-telegram.tool.ts' import { TELEGRAM_MAX_CHARS, TRENDING_SUMMARY_MAX, TRENDING_TAG_MAX, TRENDING_TAGS_MAX, TRENDING_TOP_N } from '../constants/index.ts' import type { CuratedRepo } from '../schemas/index.ts' @@ -90,3 +90,31 @@ test('drops trailing repos rather than emitting a message Telegram will reject', assert.ok(message.length <= TELEGRAM_MAX_CHARS, `message was ${message.length} chars`) }) + +// A dropped repo is never delivered, so the caller has to be able to tell which +// ones went out — otherwise they get recorded as sent. +test('reports which repos survived the drop, not just the message', () => { + const bomb = Array.from({ length: TRENDING_TOP_N }, (_, i) => + repo({ repo_name: `owner${i}/project-${i}`, summary: '&'.repeat(TRENDING_SUMMARY_MAX) }) + ) + + const kept = fitRepos(bomb, '2026-08-01') + + assert.ok(kept.length < bomb.length, 'this input must actually trigger the drop') + assert.equal(buildTrendingMessage(bomb, '2026-08-01'), assembleOf(kept)) + + // Every kept repo appears in the message; every dropped one does not. + const message = buildTrendingMessage(bomb, '2026-08-01') + const dropped = bomb.slice(kept.length) + + for (const r of kept) assert.ok(message.includes(r.repo_name), `${r.repo_name} should be in the digest`) + for (const r of dropped) assert.ok(!message.includes(r.repo_name), `${r.repo_name} was dropped and must not appear`) +}) + +test('keeps every repo when the digest fits', () => { + const fits = [repo(), repo({ repo_name: 'vuejs/core' })] + + assert.equal(fitRepos(fits, '2026-08-01').length, 2) +}) + +const assembleOf = (kept: CuratedRepo[]) => buildTrendingMessage(kept, '2026-08-01') diff --git a/src/tools/news-telegram.tool.ts b/src/tools/news-telegram.tool.ts index 0b43b35..4596f78 100644 --- a/src/tools/news-telegram.tool.ts +++ b/src/tools/news-telegram.tool.ts @@ -1,5 +1,6 @@ import { DynamicStructuredTool } from '@langchain/core/tools' import { z } from 'zod' +import { logger } from '../utils/logger.js' import { escapeHtml, truncate } from '../agent/utils.js' import { sendTelegramMessage } from './telegram.js' import { TELEGRAM_MAX_CHARS, TRENDING_SUMMARY_MAX, TRENDING_TAG_MAX, TRENDING_TAGS_MAX } from '../constants/index.js' @@ -42,12 +43,16 @@ function assemble(repos: CuratedRepo[], today: string): string { ].join('\n') } -export function buildTrendingMessage(repos: CuratedRepo[], today: string): string { +export function fitRepos(repos: CuratedRepo[], today: string): CuratedRepo[] { let kept = repos while (kept.length > 1 && assemble(kept, today).length > TELEGRAM_MAX_CHARS) kept = kept.slice(0, -1) - return assemble(kept, today) + return kept +} + +export function buildTrendingMessage(repos: CuratedRepo[], today: string): string { + return assemble(fitRepos(repos, today), today) } export const trendingTelegramTool = new DynamicStructuredTool({ @@ -71,8 +76,19 @@ export const trendingTelegramTool = new DynamicStructuredTool({ }), func: async ({ repos }) => { const today = new Date().toISOString().split('T')[0] - const chatId = await sendTelegramMessage(buildTrendingMessage(repos, today), 'Trending repos') + const kept = fitRepos(repos, today) + + if (kept.length < repos.length) { + logger.warn( + `✂️ Digest too long for Telegram — dropped ${repos.length - kept.length} of ${repos.length} repos: ${repos + .slice(kept.length) + .map((r) => r.repo_name) + .join(', ')}` + ) + } + + const chatId = await sendTelegramMessage(assemble(kept, today), 'Trending repos') - return JSON.stringify({ success: true, chat_id: chatId, date: today }) + return JSON.stringify({ success: true, chat_id: chatId, date: today, delivered: kept.map((r) => r.repo_name) }) }, })