Android Repository Bot turns a public GitHub or GitLab repository into a reviewed Telegram channel post. Staff remain in control: the bot loads and normalizes repository evidence, generates constrained English copy, renders a 1920 × 1080 PNG banner, and requires explicit confirmation before publication.
The project is one installable Python 3.14 application. Runtime code lives in
src/androidrepo_bot, tests live in tests, and the androidrepo-bot console
script and python -m androidrepo_bot use the same entry point.
- Public GitHub and GitLab repository-root URLs, including nested GitLab namespaces.
- Provider-stable repository identity and observed aliases in PostgreSQL.
- A three-calendar-month publication cooldown, based on PostgreSQL calendar arithmetic rather than a fixed number of days.
- Evidence-grounded structured generation with verified destination mapping.
- Staff review, regeneration, cancellation, final confirmation, and safe publication retry behavior.
- Full HD banners with attributed NASA artwork or a bundled offline fallback.
- Best-effort staff audit messages and structured, secret-redacted process logs.
Requirements:
- Docker with Compose
- a Telegram bot with access to the configured staff chat, topics, and channel
- an OpenCode Zen API key
Copy the environment template and replace every required placeholder:
cp .env.example .envBuild and start the bot:
docker compose up --build botCompose starts PostgreSQL, waits for its health check, runs the dedicated
migrate service through alembic upgrade head, and starts the bot only after
migrations succeed. Database data remains in the postgres-data named volume.
The bot and migration containers run with all Linux capabilities dropped and
no-new-privileges enabled; the application image runs as an unprivileged
androidrepo user.
To apply migrations without starting the bot:
docker compose up --build migrateThe Compose PostgreSQL service is intentionally not published to the host. Use the complete Compose stack above, or provide a separately reachable PostgreSQL server for a host-run bot.
Requirements:
- Python 3.14
uv- PostgreSQL reachable from the host
Install the locked application and all development groups:
uv sync --locked --all-groupsCopy .env.example to .env, replace the placeholders, and change
AR_DATABASE_URL to the host-reachable PostgreSQL URL. Alembic reads
AR_DATABASE_URL from the process environment rather than loading .env
itself, so apply migrations explicitly:
AR_DATABASE_URL='postgresql+asyncpg://user:password@localhost:5432/androidrepo' \
uv run alembic upgrade headRun either supported entry point:
uv run androidrepo-bot
uv run python -m androidrepo_botAt startup, the application validates settings, opens one shared
aiohttp.ClientSession, verifies PostgreSQL connectivity, enters the
PydanticAI agent, registers Telegram commands, and begins polling. Shutdown
stops admission of new work, drains admitted update handlers, emits a
best-effort stop audit, and closes resources through one central
AsyncExitStack.
Settings come from .env and process environment variables prefixed with
AR_; process environment values take precedence. Empty optional token values
are ignored. See .env.example for annotated placeholders.
| Variable | Required | Default | Purpose |
|---|---|---|---|
AR_BOT_TOKEN |
yes | — | Telegram bot token |
AR_STAFF_CHAT_ID |
yes | — | Staff chat admitted to the post workflow |
AR_POST_TOPIC_ID |
yes | — | Staff topic for /post, /cancel, and draft callbacks |
AR_LOG_TOPIC_ID |
yes | — | Staff topic receiving best-effort operational audits |
AR_CHANNEL_ID |
yes | — | Channel receiving confirmed posts |
AR_OPENCODE_ZEN_API_KEY |
yes | — | OpenCode Zen generation credential |
AR_DATABASE_URL |
yes | — | postgresql+asyncpg:// runtime database URL |
AR_LOG_LEVEL |
no | INFO |
CRITICAL, ERROR, WARNING, INFO, or DEBUG |
AR_OPENCODE_ZEN_MODEL |
no | deepseek-v4-flash |
OpenCode Zen model identifier |
AR_GITHUB_TOKEN |
no | — | Token for authenticated GitHub API requests |
AR_GITLAB_TOKEN |
no | — | Token for authenticated GitLab API requests |
The staff chat and channel IDs must be non-zero, and both topic IDs must be
positive. The database URL must use SQLAlchemy's postgresql+asyncpg driver.
Never commit .env. Do not put credentials, authorization headers, database
passwords, private chat content, or secret-bearing URLs in logs, screenshots,
issues, or pull requests.
/start is admitted in any chat and links to the source repository, Android
Repository channel, and community. Every other handled update must belong to
the configured staff chat and post topic. Updates outside that scope are
ignored.
Draft state uses aiogram's in-memory storage with per-user-in-chat isolation. An active draft therefore does not survive a process restart, while repository identity and publication history remain persistent in PostgreSQL.
-
Send
/postfollowed by a public repository-root URL:/post https://github.com/owner/repository /post https://gitlab.com/group/subgroup/repository -
With no argument,
/postshows the expected URL form. An invalid URL gets a specific rejection without replacing an active draft. -
The parser accepts HTTPS GitHub and GitLab roots only. It rejects embedded credentials, custom ports, query strings, fragments, encoded or malformed paths, GitHub subpaths, and GitLab
/-/subresources. -
After a new URL is valid, the bot deactivates the previous owned draft controls and replaces its active session.
-
The bot loads normalized provider metadata, README content when available, latest-release metadata, languages, license, topics, homepage, and verified links. Missing README content is allowed; the resulting draft uses metadata only and the bot sends a warning.
-
The provider-stable identity and requested/canonical aliases are upserted. The cooldown is checked before generation and banner rendering. A blocked request is stored as an audited attempt.
-
The bot generates, validates, renders, and sends a review message with Publish, Regenerate, and Cancel controls.
Generation treats every repository string, including README and release text, as untrusted evidence. Only the first 50,000 README characters are supplied. The structured result requires a canonical name, one summary, three to five distinct features, one to three supported tags, and at most four optional link selections. Free text, duplication, link IDs, semantic labels, project naming, and the 950-character generation budget are validated. The agent may make at most three model requests: the initial request and two output-correction retries. Each complete generation run has a 120-second deadline.
The model never supplies a destination URL to the final post. It selects stable IDs from inspected evidence; the service resolves those IDs through the same repository snapshot and always adds the mandatory repository destination. Unknown, mandatory-as-optional, and unresolved selections are not mapped.
The final Telegram caption contains the title, italic summary, key features, verified links, and hashtags, and is checked against Telegram's 1,024-character photo-caption limit.
- Regenerate uses the already loaded repository snapshot. The existing usable draft remains active if generation, rendering, or Telegram delivery fails. A successful replacement is stored before deletion of the old draft is attempted.
- Cancel and
/cancelclear the session and delete the draft and optional missing-README notice on a best-effort basis. - Publish first switches to an explicit confirmation screen. Back returns to the draft without publishing; Publish now performs the channel copy.
- Callbacks must belong to the session owner and originate from the active draft message. Stale, foreign, and malformed callbacks receive an alert and cannot mutate or publish the session.
Publication is serialized per repository in the running process, and the cooldown is checked again immediately before the channel copy. After a successful copy, the channel message receipt is stored in the FSM before the database write. If the database write fails, pressing Publish now again retries only the idempotent publication record; it does not copy the channel post twice. Until that receipt is saved, back, cancel, and replacement actions are blocked so the retry data cannot be discarded. The database also enforces uniqueness on channel ID and message ID. If Telegram rejects the copy, the draft remains available for another attempt.
The staff log topic receives best-effort lifecycle, draft creation/failure, replacement/cancellation, and publication success/failure events. Failure to deliver an audit message does not fail the underlying workflow.
src/androidrepo_bot/
├── app.py # composition root, Telegram middleware, lifecycle
├── config.py # validated AR_* settings
├── start.py # global /start presentation
├── posts/ # commands, callbacks, FSM, UI, workflow service
├── repositories/ # URL parsing, shared HTTP policy, GitHub/GitLab
├── generation/ # evidence prompt, schema, validation, AI service
├── media/ # NASA artwork and packaged banner renderer assets
└── db/ # SQLAlchemy models, operations, packaged migrations
app.py directly composes concrete services. Repository and NASA access share
one application-owned HTTP session; PostgreSQL uses operation-scoped async
sessions; the application owns the bot, database engine, and generation-agent
lifecycle. There are no internal workspace distributions or compatibility
layers.
Repository provider responses are bounded, parsed as untrusted JSON, and validated before normalization. Transient provider failures use bounded retries, and safe user messages distinguish not-found, rate-limit, timeout, and temporary-provider failures.
Alembic configuration is in alembic.ini. Migration scripts are
packaged under androidrepo_bot.db.migrations, alongside the installed
application.
The schema stores:
- provider-stable repository identities and observed aliases
- successful channel publications
- cooldown-blocked attempts
The current migrations preserve first-seen identity data, normalize aliases, enforce blocked-attempt shape, deduplicate legacy publication receipts, and make channel publication receipts unique. Do not stamp or edit a production schema manually; apply committed revisions with:
AR_DATABASE_URL='postgresql+asyncpg://user:password@host:5432/androidrepo' \
uv run alembic upgrade headBannerService attempts to load a curated image from the NASA Image and Video
Library. Remote image downloads are restricted to NASA's HTTPS asset host,
bounded to 12 MiB, and time-limited. Invalid, unavailable, or undecodable
artwork falls back to the packaged black-hole image. Every result is a
1920 × 1080 RGB PNG and includes visible artwork credit and identifier text.
Bundled assets and their requirements are listed in the asset inventory:
- The Android robot artwork is based on work created and shared by Google and is used under the Creative Commons 3.0 Attribution License terms referenced by the Android brand guidelines. The renderer includes the attribution.
- Figtree is redistributed under the SIL Open Font License; its complete license text is packaged beside the font.
black-hole-fallback.webpwas generated with OpenAI for this repository.
Keep the asset inventory and license files with redistributed builds.
Run checks from the repository root:
uv lock --check
uv sync --locked --all-groups
uv run ruff format --check .
uv run ruff check .
uv run pyright
uv run pre-commit run --all-files
uv buildPyright runs in strict mode.
The built wheel must contain the complete package, database migrations, banner
assets, and androidrepo-bot console-script metadata. The package is marked
Private :: Do Not Upload.
Copyright (C) 2026 Hitalo M.
This project is licensed under the GNU Affero General Public License, version 3 or later (AGPL-3.0-or-later). Modified versions offered for remote network interaction must provide corresponding source as required by AGPL section 13.
Use the repository's GitHub issues for reproducible bugs and scoped improvements. Never include secrets or private Telegram content in a report.