diff --git a/README.md b/README.md index b14e846..bf4b3f7 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ Other similar tools: - Ready for self-hosting, has docker image - Work locally, without white IP and domain name - Only Telegram token required +- Daily summary of release notifications (optional) ## Commands @@ -71,6 +72,10 @@ services: - TELEGRAM_BOT_TOKEN= #- GITHUB_TOKEN= # optional #- SITE_URL=https:// # optional + #- DAILY_SUMMARY_TIME=21:00 # optional, HH:MM (24h) + #- DAILY_SUMMARY_TIMEZONE=Europe/Rome # optional + #- OPENROUTER_API_KEY= # optional + #- OPENROUTER_MODEL=openrouter/auto # optional ports: - 5000:5000 volumes: @@ -93,6 +98,18 @@ Look at Development section `SITE_URL` - (optional) URL used for listening for incoming requests from the Telegram servers. When not specified uses polling instead webhooks. More info at [Marvin's Marvellous Guide to All Things Webhook](https://core.telegram.org/bots/webhooks). +`DAILY_SUMMARY_TIME` - (optional) Time to send a daily summary of release notifications, format HH:MM (24h). Default 21:00 (Europe/Rome). + +`DAILY_SUMMARY_TIMEZONE` - (optional) Timezone for `DAILY_SUMMARY_TIME` in IANA format, default Europe/Rome. + +`OPENROUTER_API_KEY` - (optional) OpenRouter API key for AI-generated daily summaries. + +`OPENROUTER_MODEL` - (optional) OpenRouter model name, default `openrouter/auto`. + +`OPENROUTER_APP_NAME` - (optional) OpenRouter app name header value. + +`OPENROUTER_SITE_URL` - (optional) OpenRouter referer header value. + `CHAT_ID` - (optional) Only messages from the specified chat ID are accepted. Can be a comma separated list. You can get your chat ID with [@getmyid_bot](https://t.me/getmyid_bot). If not specified, all messages are accepted. `DATABASE_URI` - (optional) When not specified local SQLite uses. @@ -101,6 +118,8 @@ Look at Development section `LOG_LEVEL` - (optional) Default INFO. +If `OPENROUTER_API_KEY` is not set, daily summaries are skipped. + ## Development Setup env vars and run: diff --git a/app/models.py b/app/models.py index 4997491..1e89fb0 100644 --- a/app/models.py +++ b/app/models.py @@ -58,3 +58,20 @@ class Release(db.Model): repo_id = db.Column(db.ForeignKey('repo.id')) repos = db.relationship('Repo', back_populates='releases') + + +class ReleaseNotification(db.Model): + id = db.Column(db.Integer, primary_key=True) + chat_id = db.Column(db.Integer, db.ForeignKey('chat.id'), index=True) + repo_id = db.Column(db.Integer, db.ForeignKey('repo.id'), index=True) + release_id = db.Column(db.Integer) + release_tag = db.Column(db.String) + release_title = db.Column(db.String) + release_url = db.Column(db.String) + release_body = db.Column(db.Text) + repo_full_name = db.Column(db.String) + repo_link = db.Column(db.String) + pre_release = db.Column(db.Boolean, default=False) + updated = db.Column(db.Boolean, default=False) + sent_at = db.Column(db.DateTime, default=aware_utcnow) + summarized_at = db.Column(db.DateTime) diff --git a/app/tasks.py b/app/tasks.py index b07edfc..ba781bb 100644 --- a/app/tasks.py +++ b/app/tasks.py @@ -1,18 +1,200 @@ import asyncio +import json +from datetime import datetime, timedelta, timezone +from zoneinfo import ZoneInfo import github import telegram +import urllib3 from github.GitRelease import GitRelease from github.Tag import Tag from telegram import LinkPreviewOptions -from telegram.constants import ParseMode +from telegram.constants import MessageLimit, ParseMode from app import models from app import github_obj, db, telegram_bot, scheduler -from app.models import ChatRepo +from app.models import ChatRepo, ReleaseNotification from app.repo_engine import store_latest_release, format_release_message +OPENROUTER_API_URL = "https://openrouter.ai/api/v1/chat/completions" +SUMMARY_MAX_RELEASE_BODY_CHARS = 8000 +SUMMARY_FALLBACK_SNIPPET_CHARS = 240 +SUMMARY_TRUNCATION_SUFFIX = "\n...[truncated]" + + +def _parse_daily_summary_time(value): + if not value: + return None + + parts = value.strip().split(":") + if len(parts) != 2: + raise ValueError("DAILY_SUMMARY_TIME must be in HH:MM format") + + hour = int(parts[0]) + minute = int(parts[1]) + if hour < 0 or hour > 23 or minute < 0 or minute > 59: + raise ValueError("DAILY_SUMMARY_TIME must be a valid 24h time") + + return hour, minute + + +def _normalize_release_body(body): + if not body: + return "" + body = body.strip() + if len(body) > SUMMARY_MAX_RELEASE_BODY_CHARS: + body = f"{body[:SUMMARY_MAX_RELEASE_BODY_CHARS]}{SUMMARY_TRUNCATION_SUFFIX}" + return body + + +def _day_window_utc(now_utc, tz_name): + tz = ZoneInfo(tz_name) + local_now = now_utc.astimezone(tz) + day_start_local = local_now.replace(hour=0, minute=0, second=0, microsecond=0) + day_end_local = day_start_local + timedelta(days=1) + return (day_start_local.astimezone(timezone.utc), + day_end_local.astimezone(timezone.utc), + day_start_local.date().isoformat()) + + +def _trim_message(text, max_len=MessageLimit.MAX_TEXT_LENGTH): + if len(text) <= max_len: + return text + return f"{text[:max_len - len(SUMMARY_TRUNCATION_SUFFIX)]}{SUMMARY_TRUNCATION_SUFFIX}" + + +def _build_summary_prompt(notifications, day_label, tz_name): + items = [] + for note in notifications: + body = _normalize_release_body(note.release_body) + if not body: + body = "No changelog provided." + items.append( + "Repo: {repo}\nTag: {tag}\nTitle: {title}\nURL: {url}\nChangelog:\n{body}".format( + repo=note.repo_full_name or "Unknown", + tag=note.release_tag or "", + title=note.release_title or "", + url=note.release_url or note.repo_link or "", + body=body, + ) + ) + + header = ( + "You are a release notes assistant. Summarize today's release notifications.\n" + "Day: {day} ({tz})\n" + "Rules:\n" + "- Include every item; do not merge or omit items.\n" + "- For each item, provide repo name, version/tag, and 1-3 bullet highlights.\n" + "- Output must be Telegram Markdown (not MarkdownV2).\n" + "- Use *bold* for headings, use '-' for bullets.\n" + "- Avoid underscores, backticks, tables, and code blocks.\n" + "- Use plain URLs (no Markdown links).\n" + "- Keep the output under 3000 characters.\n\n" + "Items:\n" + ).format(day=day_label, tz=tz_name) + + items_text = "\n---\n".join(items) + return f"{header}{items_text}" + + +def _fallback_summary(notifications, day_label, tz_name): + lines = [f"*Release Summary* - {day_label} ({tz_name})", ""] + for note in notifications: + title = note.release_title or "" + tag = note.release_tag or "" + line = f"- *Repo:* {note.repo_full_name or 'Unknown'}".strip() + if tag or title: + detail = " ".join(part for part in [tag, title] if part) + line = f"{line} ({detail})" + lines.append(line) + if note.release_body: + snippet = " ".join(note.release_body.split()) + if len(snippet) > SUMMARY_FALLBACK_SNIPPET_CHARS: + snippet = f"{snippet[:SUMMARY_FALLBACK_SNIPPET_CHARS]}{SUMMARY_TRUNCATION_SUFFIX}" + lines.append(f" - Highlights: {snippet}") + else: + lines.append(" - Highlights: No changelog provided.") + + return "\n".join(lines).strip() + + +def _openrouter_summary(api_key, model, prompt, app_name, site_url, logger): + if not api_key: + return None + + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + if site_url: + headers["HTTP-Referer"] = site_url + if app_name: + headers["X-Title"] = app_name + + payload = { + "model": model or "openrouter/auto", + "messages": [ + { + "role": "system", + "content": "Summarize software release notifications for Telegram using Markdown (not MarkdownV2).", + }, + {"role": "user", "content": prompt}, + ], + "temperature": 0.2, + "max_tokens": 800, + } + + http = urllib3.PoolManager() + try: + response = http.request( + "POST", + OPENROUTER_API_URL, + body=json.dumps(payload).encode("utf-8"), + headers=headers, + timeout=urllib3.Timeout(connect=5.0, read=60.0), + ) + except Exception as exc: + logger.error(f"OpenRouter request failed: {exc}") + return None + + if response.status >= 400: + logger.error(f"OpenRouter response error {response.status}: {response.data}") + return None + + try: + data = json.loads(response.data.decode("utf-8")) + except json.JSONDecodeError: + logger.error("OpenRouter response was not valid JSON") + return None + + choices = data.get("choices", []) + if not choices: + logger.error("OpenRouter response missing choices") + return None + + message = choices[0].get("message", {}) + return message.get("content") + + +def _record_release_notification(session, chat, repo_obj, release, release_body, release_url, release_tag, release_title): + notification = ReleaseNotification( + chat_id=chat.id, + repo_id=repo_obj.id, + release_id=getattr(release, "id", None), + release_tag=release_tag, + release_title=release_title, + release_url=release_url, + release_body=_normalize_release_body(release_body), + repo_full_name=repo_obj.full_name, + repo_link=repo_obj.link, + pre_release=getattr(release, "prerelease", False), + updated=getattr(release, "updated", False), + ) + session.add(notification) + session.commit() + + @scheduler.task('cron', id='poll_github', hour='*') def poll_github(): with scheduler.app.app_context(): @@ -98,6 +280,17 @@ def poll_github(): scheduler.app.logger.info('Bot was blocked by the user') db.session.delete(chat) db.session.commit() + else: + _record_release_notification( + db.session, + chat, + repo_obj, + release, + release.body, + release.html_url, + release.tag_name, + release.title, + ) elif isinstance(release_or_tag, Tag): tag = release_or_tag scheduler.app.logger.info(f"Process new tag {tag.name}") @@ -119,6 +312,17 @@ def poll_github(): scheduler.app.logger.info('Bot was blocked by the user') db.session.delete(chat) db.session.commit() + else: + _record_release_notification( + db.session, + chat, + repo_obj, + tag, + None, + f"{repo.html_url}/releases/tag/{tag.name}", + tag.name, + tag.name, + ) if isinstance(prerelease, GitRelease): release = prerelease scheduler.app.logger.info(f"Process new prerelease {release.title}") @@ -145,6 +349,17 @@ def poll_github(): scheduler.app.logger.info('Bot was blocked by the user') db.session.delete(chat) db.session.commit() + else: + _record_release_notification( + db.session, + chat, + repo_obj, + release, + release.body, + release.html_url, + release.tag_name, + release.title, + ) @scheduler.task('cron', id='poll_github_user', hour='*/8') @@ -194,3 +409,96 @@ def clear_db(): db.session.delete(repo_obj) db.session.commit() continue + + +def daily_release_summary(): + if not telegram_bot: + return + + with scheduler.app.app_context(): + config = scheduler.app.config + tz_name = config.get('DAILY_SUMMARY_TIMEZONE', 'UTC') + now_utc = datetime.now(timezone.utc) + day_start, day_end, day_label = _day_window_utc(now_utc, tz_name) + + api_key = config.get('OPENROUTER_API_KEY') + model = config.get('OPENROUTER_MODEL') + app_name = config.get('OPENROUTER_APP_NAME') + site_url = config.get('OPENROUTER_SITE_URL') or config.get('SITE_URL') + + if not api_key: + scheduler.app.logger.info('Daily summary skipped: OPENROUTER_API_KEY not set') + return + + for chat in models.Chat.query.all(): + notifications = db.session.query(ReleaseNotification) \ + .filter(ReleaseNotification.chat_id == chat.id) \ + .filter(ReleaseNotification.summarized_at.is_(None)) \ + .filter(ReleaseNotification.sent_at >= day_start) \ + .filter(ReleaseNotification.sent_at < day_end) \ + .order_by(ReleaseNotification.sent_at.asc()) \ + .all() + + if not notifications: + continue + + prompt = _build_summary_prompt(notifications, day_label, tz_name) + summary = _openrouter_summary(api_key, model, prompt, app_name, site_url, scheduler.app.logger) + if not summary: + summary = _fallback_summary(notifications, day_label, tz_name) + + summary = _trim_message(summary) + + try: + asyncio.run(telegram_bot.send_message(chat_id=chat.id, + text=summary, + parse_mode=ParseMode.MARKDOWN, + disable_web_page_preview=True)) + except telegram.error.BadRequest: + asyncio.run(telegram_bot.send_message(chat_id=chat.id, + text=summary, + disable_web_page_preview=True)) + except telegram.error.Forbidden as e: + scheduler.app.logger.info('Bot was blocked by the user') + db.session.delete(chat) + db.session.commit() + continue + + summarized_at = datetime.now(timezone.utc) + for note in notifications: + note.summarized_at = summarized_at + db.session.commit() + + +def _register_daily_summary_job(): + config = scheduler.app.config + summary_time = config.get('DAILY_SUMMARY_TIME') + if not summary_time: + scheduler.app.logger.info('Daily summary disabled: DAILY_SUMMARY_TIME not set') + return + + try: + hour, minute = _parse_daily_summary_time(summary_time) + except ValueError as exc: + scheduler.app.logger.error(f"Daily summary disabled: {exc}") + return + + tz_name = config.get('DAILY_SUMMARY_TIMEZONE', 'UTC') + try: + ZoneInfo(tz_name) + except Exception: + scheduler.app.logger.error(f"Invalid DAILY_SUMMARY_TIMEZONE '{tz_name}', using UTC") + tz_name = 'UTC' + + scheduler.add_job( + id='daily_release_summary', + func=daily_release_summary, + trigger='cron', + hour=hour, + minute=minute, + timezone=tz_name, + replace_existing=True, + ) + + +_register_daily_summary_job() diff --git a/config.py b/config.py index af21aa0..34aca90 100644 --- a/config.py +++ b/config.py @@ -8,6 +8,12 @@ class Config: TELEGRAM_BOT_TOKEN = os.environ.get('TELEGRAM_BOT_TOKEN') GITHUB_TOKEN = os.environ.get('GITHUB_TOKEN') SITE_URL = os.environ.get('SITE_URL') + DAILY_SUMMARY_TIME = os.environ.get('DAILY_SUMMARY_TIME', '21:00') + DAILY_SUMMARY_TIMEZONE = os.environ.get('DAILY_SUMMARY_TIMEZONE', 'Europe/Rome') + OPENROUTER_API_KEY = os.environ.get('OPENROUTER_API_KEY') + OPENROUTER_MODEL = os.environ.get('OPENROUTER_MODEL', 'openrouter/auto') + OPENROUTER_APP_NAME = os.environ.get('OPENROUTER_APP_NAME', 'release-bot') + OPENROUTER_SITE_URL = os.environ.get('OPENROUTER_SITE_URL') SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URI', f'sqlite:///{basedir}/data/db.sqlite') SQLALCHEMY_ECHO = os.environ.get('SQL_DEBUG', '').lower() in ('true', '1', 't') LOG_LEVEL = os.environ.get('LOG_LEVEL', 'INFO').upper() diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..19e16b8 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,16 @@ +services: + release-bot: + container_name: release-bot + image: ghcr.io/janisv/release-bot:latest + restart: unless-stopped + environment: + - TELEGRAM_BOT_TOKEN= # required + #- GITHUB_TOKEN= # optional + #- SITE_URL=https:// # optional + - OPENROUTER_API_KEY=sk-or-v1-xxx # required if you want use AI, get it from https://openrouter.ai/dashboard/api-keys + - OPENROUTER_MODEL=openrouter/free # optional, you can use any openrouter model, e.g. gpt-4o, gpt-4o-mini, etc. + - DAILY_SUMMARY_TIME=21:00 # optional, time for daily summary in 24h format, default is 20:00 + ports: + - 5000:5000 + volumes: + - ./data:/app/data \ No newline at end of file diff --git a/migrations/versions/b5e2d8f1c0f4_add_release_notification_table.py b/migrations/versions/b5e2d8f1c0f4_add_release_notification_table.py new file mode 100644 index 0000000..c4399bb --- /dev/null +++ b/migrations/versions/b5e2d8f1c0f4_add_release_notification_table.py @@ -0,0 +1,50 @@ +"""Add release_notification table + +Revision ID: b5e2d8f1c0f4 +Revises: 6c2a7c346237 +Create Date: 2026-04-28 19:45:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'b5e2d8f1c0f4' +down_revision = '6c2a7c346237' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'release_notification', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('chat_id', sa.Integer(), nullable=True), + sa.Column('repo_id', sa.Integer(), nullable=True), + sa.Column('release_id', sa.Integer(), nullable=True), + sa.Column('release_tag', sa.String(), nullable=True), + sa.Column('release_title', sa.String(), nullable=True), + sa.Column('release_url', sa.String(), nullable=True), + sa.Column('release_body', sa.Text(), nullable=True), + sa.Column('repo_full_name', sa.String(), nullable=True), + sa.Column('repo_link', sa.String(), nullable=True), + sa.Column('pre_release', sa.Boolean(), nullable=True), + sa.Column('updated', sa.Boolean(), nullable=True), + sa.Column('sent_at', sa.DateTime(), nullable=True), + sa.Column('summarized_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['chat_id'], ['chat.id'], ), + sa.ForeignKeyConstraint(['repo_id'], ['repo.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('ix_release_notification_chat_id', 'release_notification', ['chat_id'], unique=False) + op.create_index('ix_release_notification_sent_at', 'release_notification', ['sent_at'], unique=False) + op.create_index('ix_release_notification_summarized_at', 'release_notification', ['summarized_at'], unique=False) + + + +def downgrade(): + op.drop_index('ix_release_notification_summarized_at', table_name='release_notification') + op.drop_index('ix_release_notification_sent_at', table_name='release_notification') + op.drop_index('ix_release_notification_chat_id', table_name='release_notification') + op.drop_table('release_notification')