From ddcaf357deba43f12e9b4a5320ab6d7025bae783 Mon Sep 17 00:00:00 2001 From: MoTechSys Date: Tue, 6 Oct 2026 01:28:11 +0000 Subject: [PATCH] feat(deploy): run the Telegram bot from the VPS; /check?text= deep link (E-063) - deploy/vps/docker-compose.prod.yml: telegram-bot env_file -> /etc/basira/telegram-bot.env (!override), BASIRA_API_URL=http://basira:8000, BASIRA_WEB_URL=https://basirapp.site; basira env_file /etc/basira/basira.env (BASIRA_EVAL_KEY, optional); json-file log rotation 10m x 3 on all services - deploy/vps/autodeploy.sh: export COMPOSE_PROFILES from autodeploy.conf; after a deploy log 'bot: healthy|unhealthy|missing' from docker inspect; autodeploy.conf.example documents the profile - frontend: /check reads ?text= (pre-fill only, cap MAX_CHARS, never checks on load, param dropped); initialTextFrom exported; 2 vitest cases - bot: rich.check_link(site, text) builds /check?text= when the URL is <= 2000 chars, else /check; used by the rich footer and the classic-HTML fallback button; text handler passes the user's text and deletes it after rendering; 5 new tests (110 total), ruff + mypy strict clean - docs: DEPLOYMENT 3.1 (secrets, shared eval key, verification), deploy/vps/README bot section, STATE 3, README line, bot README; DECISIONS E-063; duplicate E-057/E-058 from PR #1 renumbered E-061/E-062 with all references (CHANGELOG, anchor.py, tests, smoke, index.css, README) --- CHANGELOG.md | 5 +-- README.md | 4 +-- backend/app/extract/anchor.py | 10 +++--- backend/tests/test_as_people_write.py | 8 ++--- deploy/vps/README.md | 24 ++++++++++++-- deploy/vps/autodeploy.conf.example | 3 ++ deploy/vps/autodeploy.sh | 8 +++++ deploy/vps/docker-compose.prod.yml | 17 ++++++++++ docs/DECISIONS.md | 5 +-- docs/DEPLOYMENT.md | 28 +++++++++++++++- docs/STATE.md | 4 +++ frontend/src/Check.test.tsx | 25 ++++++++++++-- frontend/src/Check.tsx | 13 ++++++-- frontend/src/index.css | 2 +- integrations/telegram/README.md | 1 + integrations/telegram/bot/handlers.py | 27 +++++++++++----- integrations/telegram/bot/rich.py | 34 +++++++++++++++----- integrations/telegram/tests/test_handlers.py | 31 ++++++++++++++++++ integrations/telegram/tests/test_rich.py | 26 +++++++++++++++ scripts/smoke.py | 2 +- 20 files changed, 236 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a208f26..e77e071 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,11 @@ Format follows [Keep a Changelog](https://keepachangelog.com/); versions are git ### Added - **D-015 rasm-uniqueness proof (I18/I19)**: bare-typed quotes («قل هو الله احد») are `found` with notice `rasm_completed` and the completed letters when the corpus spells the span one way; competing spellings («إن/أن الله على كل شيء قدير») → `needs_review/rasm_ambiguous` with alternatives + counts. Validator **V7** re-derives the proof. `QuoteResult.rasm` exposes it. 25 new tests. -- **E-057 whole-ayah anchor**: a complete ayah typed with no marker is detected whatever its words' frequency. -- **E-058 phrase-rarity anchor**: short famous texts typed with no marker («إنما الأعمال بالنيات», «لا ضرر ولا ضرار», «إن الله مع الصابرين») are detected; runs extend backwards over leading particles; isnad fragments never anchor. Composer hint: no marker, no hamza needed. 15 tests + 7 smoke cases «as people write». +- **E-061 whole-ayah anchor**: a complete ayah typed with no marker is detected whatever its words' frequency. +- **E-062 phrase-rarity anchor**: short famous texts typed with no marker («إنما الأعمال بالنيات», «لا ضرر ولا ضرار», «إن الله مع الصابرين») are detected; runs extend backwards over leading particles; isnad fragments never anchor. Composer hint: no marker, no hamza needed. 15 tests + 7 smoke cases «as people write». - Evaluation: category B expectations are now resolved *by construction* from the corpus (`eval/materialize.rasm_expectation`). - Telegram bot `integrations/telegram` (@BasiraCheckBot): thin client over `/v1/check` and `/v1/check/image`; no model, no storage, texts from `messages/*.json`; optional compose profile `bot`; CI job `bot` (E-059). Replies are Telegram Rich Messages — headings, verbatim pull-quotes, marked differences, tables, collapsible sections, buttons — with a classic-HTML fallback (E-060). +- Bot in production on the VPS (profile `bot`, secrets in `/etc/basira`, shared `BASIRA_EVAL_KEY`, `bot: healthy` deploy log line); `/check?text=` deep link from the bot's «open in Basira» button (E-063). Decision IDs E-057/E-058 of PR #1 renumbered E-061/E-062. ### Verified pytest 344 · ruff + mypy strict · eval-full **150/150 · unsafe 0 · 0/500** (fixture and full index) · IslamicEval 1B unchanged **78.54 %, false confirmations 2**. diff --git a/README.md b/README.md index 6d33790..f2a107d 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ Full invariants I1–I17 and validator passes V1–V6: [`SAFETY.md`](SAFETY.md). | **Verification receipt** — stateless, replayable | `POST /v1/receipt` · `GET /v/{token}?h=` | live | | **MCP server** — 6 tools for AI assistants | `/mcp` (`BASIRA_MCP=1`) | live | | Developer gate: sources, grounding rules, measured model catalog | `/v1/sources` · `/v1/rules` · `/v1/models` | live | -| **Telegram bot** — forward a text or image, get the source verbatim | [@BasiraCheckBot](https://t.me/BasiraCheckBot) · `integrations/telegram/` | live | +| **Telegram bot** — forward a text or image, get the source verbatim; «open in Basira» pre-fills the workspace | [@BasiraCheckBot](https://t.me/BasiraCheckBot) · `integrations/telegram/` · runs on the VPS (DEPLOYMENT §3.1) | live | | Model settings — Genspark key saved once on the server | `/settings` · `PUT /v1/models/config` | live | ## Measured, not claimed @@ -132,7 +132,7 @@ eval/ 150 cases, false-alarm generator, English gate eval, IslamicEval messages/ ar.json / en.json — the ONLY source of user-facing prose scripts/ bootstrap · smoke · mcp_demo · bench_models · lexicon gate · generators docs/ ARCHITECTURE · API · SAFETY-adjacent docs · DECISIONS (E-001…E-054) · STATE · adr/ · KNOWLEDGE -.github/ GitHub Actions workflow (ci.yml, active — E-058) +.github/ GitHub Actions workflow (ci.yml, active — E-057) ``` ## Documentation map diff --git a/backend/app/extract/anchor.py b/backend/app/extract/anchor.py index e8d0569..08cd720 100644 --- a/backend/app/extract/anchor.py +++ b/backend/app/extract/anchor.py @@ -125,7 +125,7 @@ def _occurs(store: Store, ids: list[int]) -> np.ndarray: return cand -# E-058 — phrase-rarity acceptance for short unmarked runs. A run of ≥ RARE_MIN_TOKENS tokens that occurs +# E-062 — phrase-rarity acceptance for short unmarked runs. A run of ≥ RARE_MIN_TOKENS tokens that occurs # verbatim at most RARE_MAX_OCC times in the whole corpus is a quotation, however common its words are # taken one by one: «إنما الأعمال بالنيات» occurs 7×, «إن الله مع الصابرين» 2×, «لا ضرر ولا ضرار» 9× — while # formulaic prose is three orders of magnitude more frequent («صلى الله عليه وسلم» 92 083×, «حدثنا عبد الله بن» @@ -172,7 +172,7 @@ def detect(store: Store, text: str, *, seed: int = 4, max_cand: int = 4000) -> l i = 0 min_seed = min(seed, RARE_MIN_TOKENS) while i + min_seed <= n: - # the seed window is `seed` tokens when available, else the shorter rare-phrase seed (E-058) + # the seed window is `seed` tokens when available, else the shorter rare-phrase seed (E-062) s = seed if i + seed <= n else min_seed win = ids[i : i + s] if any(x < 0 for x in win) or all(toks[i + k].loose in _STOP for k in range(s)): @@ -200,7 +200,7 @@ def detect(store: Store, text: str, *, seed: int = 4, max_cand: int = 4000) -> l # extend BACKWARDS as well: the seed may have started one or more tokens late because the # leading words were all stop-words («ان الله علي كل …» seeds at «الله»); the quote still # begins where the corpus agreement begins, and a verdict on a truncated quote is a worse - # verdict (E-058). + # verdict (E-062). i0 = i while i0 > 0 and ids[i0 - 1] >= 0 and live.size: prev = live - 1 @@ -220,10 +220,10 @@ def detect(store: Store, text: str, *, seed: int = 4, max_cand: int = 4000) -> l need = 4 if corpus == "tanzil" else 6 # hadith prose is far more formulaic → longer seed if ( (j - i >= need and len(content) >= 3) - # E-057: a run that IS a complete ayah («قل هو الله أحد») is a quote however common its + # E-061: a run that IS a complete ayah («قل هو الله أحد») is a quote however common its # words are — the Mushaf's own ayah boundary is the evidence, not word rarity. or _covers_whole_ayah(store, live, j - i) - # E-058: a short run that is RARE as a phrase («إنما الأعمال بالنيات», «لا ضرر ولا ضرار») and not isnad-shaped + # E-062: a short run that is RARE as a phrase («إنما الأعمال بالنيات», «لا ضرر ولا ضرار») and not isnad-shaped or ( j - i >= RARE_MIN_TOKENS and len(content) >= 1 diff --git a/backend/tests/test_as_people_write.py b/backend/tests/test_as_people_write.py index df4fc68..fe8ec5c 100644 --- a/backend/tests/test_as_people_write.py +++ b/backend/tests/test_as_people_write.py @@ -1,7 +1,7 @@ """«كما يكتب الناس» — unmarked, hamza-less input must be understood (supervisor feedback, 2026-10-05). A supervisor typed «إنما الأعمال بالنيات» alone and got "zero matches", then added «قال رسول الله …» -and it matched — and warned that judges would conclude «الموقع لا يعمل». E-057/E-058 fix the detector; +and it matched — and warned that judges would conclude «الموقع لا يعمل». E-061/E-062 fix the detector; D-015 fixes the hamza. These tests pin both, on the fixture (records chosen from it). Phrases that must NOT be treated as quotations are pinned too (false-alarm side). """ @@ -26,9 +26,9 @@ async def _run(pipeline: Pipeline, text: str) -> CheckResponse: @pytest.mark.parametrize( "text", [ - "قل هو الله أحد", # whole ayah, 3 stop-words (E-057) + "قل هو الله أحد", # whole ayah, 3 stop-words (E-061) "قل هو الله احد", # + unwritten hamza (D-015) - "إن الله مع الصابرين", # ayah fragment, rare phrase (E-058) + "إن الله مع الصابرين", # ayah fragment, rare phrase (E-062) "ان الله مع الصابرين", "إنما الأعمال بالنيات", # the supervisor's exact input "انما الاعمال بالنيات", @@ -80,7 +80,7 @@ async def test_prose_and_non_corpus_sayings_are_not_detected(pipeline: Pipeline, def test_isnad_fragment_does_not_anchor_as_a_rare_phrase(store: Store) -> None: - """«نافع عن ابن عمر» is rare as a phrase but is a narrator chain, not a matn (E-058 guard).""" + """«نافع عن ابن عمر» is rare as a phrase but is a narrator chain, not a matn (E-062 guard).""" spans = detect(store, "حدثنا عبد الله بن يوسف قال أخبرنا مالك عن نافع عن ابن عمر") assert all( not (s.n_tokens <= 5 and "نافع" in "x") for s in spans diff --git a/deploy/vps/README.md b/deploy/vps/README.md index 46ca934..d9bd330 100644 --- a/deploy/vps/README.md +++ b/deploy/vps/README.md @@ -10,7 +10,7 @@ site untouched (Compose replaces the container only after a successful build). | `docker-compose.prod.yml` | `/opt/basira/docker-compose.prod.yml` | Overlay on `docker-compose.yml`: no public app port, CORS for the site, trusted proxy range, Caddy service | | `Caddyfile.example` | `/opt/basira/Caddyfile` | Automatic HTTPS, `www` → apex, basic auth on writes to `/v1/models/config` | | `autodeploy.sh` | `/opt/basira/autodeploy.sh` | Compare server commit with `$BRANCH` of `$REPO`; fetch, rebuild, health-check, log | -| `autodeploy.conf.example` | `/opt/basira/autodeploy.conf` | `REPO` and `BRANCH` to follow | +| `autodeploy.conf.example` | `/opt/basira/autodeploy.conf` | `REPO`, `BRANCH` to follow, `COMPOSE_PROFILES` (`bot` = Telegram bot) | | `deploy.sh` | `/opt/basira/deploy.sh` | Manual forced redeploy | | `basira-autodeploy.service` / `.timer` | `/etc/systemd/system/` | Run `autodeploy.sh` every 60 s | | `basira-deploy.logrotate` | `/etc/logrotate.d/basira-deploy` | Weekly rotation of `/var/log/basira-deploy.log` | @@ -44,5 +44,23 @@ curl -s https://basirapp.site/health # build_sha = deployed commi **Rollback:** `git -C /opt/basira reset --hard && BUILD_SHA= docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build`, and set `BRANCH` to a branch pinned at that commit (or stop the timer) so the next check does not move it forward. -Secrets (admin password, model key) never live in this repository: the hash is only in the server's -`Caddyfile`, and the model key is stored by the app in the `/data` volume. +Secrets (admin password, model key, bot token) never live in this repository: the hash is only in the server's +`Caddyfile`, the model key is stored by the app in the `/data` volume, and the bot token / eval key live in +`/etc/basira/*.env` (600 root). + +## Telegram bot (profile `bot`) + +```bash +install -d -m 700 /etc/basira +key=$(openssl rand -hex 24) +printf 'BASIRA_EVAL_KEY=%s\n' "$key" > /etc/basira/basira.env +printf 'TELEGRAM_BOT_TOKEN=%s\nBASIRA_EVAL_KEY=%s\n' '' "$key" > /etc/basira/telegram-bot.env +chmod 600 /etc/basira/*.env +sed -i 's/^COMPOSE_PROFILES=.*/COMPOSE_PROFILES=bot/' /opt/basira/autodeploy.conf +/opt/basira/deploy.sh # or wait for the next commit on main +``` + +`autodeploy.sh` exports `COMPOSE_PROFILES` before `docker compose up`, and after a successful deploy appends +`bot: healthy|unhealthy|missing` (from `docker inspect`) to the log. Without the profile the stack is the site +only — `docker compose up` in a fresh clone is unchanged for judges. Details and the verification list: +`docs/DEPLOYMENT.md` §3.1. diff --git a/deploy/vps/autodeploy.conf.example b/deploy/vps/autodeploy.conf.example index f6c5735..0bcc618 100644 --- a/deploy/vps/autodeploy.conf.example +++ b/deploy/vps/autodeploy.conf.example @@ -1,3 +1,6 @@ # Auto-deploy settings. Edit and save — takes effect on the next check (≤ 1 min). REPO=https://github.com/MoTechSys/Project-Basira.git BRANCH=main +# Compose profiles to start in addition to the site. "bot" runs the Telegram bot +# (token in /etc/basira/telegram-bot.env, see deploy/vps/README.md). Leave empty for site only. +COMPOSE_PROFILES=bot diff --git a/deploy/vps/autodeploy.sh b/deploy/vps/autodeploy.sh index 0929dad..95bab86 100755 --- a/deploy/vps/autodeploy.sh +++ b/deploy/vps/autodeploy.sh @@ -6,6 +6,7 @@ set -uo pipefail cd /opt/basira source /opt/basira/autodeploy.conf +export COMPOSE_PROFILES="${COMPOSE_PROFILES:-}" # e.g. "bot" → also build/run the Telegram bot service LOG=/var/log/basira-deploy.log exec 9>/run/basira-deploy.lock flock -n 9 || exit 0 # a deploy is already running @@ -31,6 +32,13 @@ if docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build done live=$(curl -s -m 10 "${HEALTH_URL:-https://basirapp.site/health}" | grep -o '"build_sha":"[^"]*"') echo " ✅ DEPLOYED $BUILD_SHA in $(( $(date +%s) - start ))s · health $code · $live" + if [[ ",$COMPOSE_PROFILES," == *,bot,* ]]; then # bot container health (HEALTHCHECK = polling heartbeat < 90 s) + for _ in $(seq 1 30); do + bot=$(docker inspect --format '{{.State.Health.Status}}' basira-telegram-bot-1 2>/dev/null || echo missing) + [ "$bot" = healthy ] && break; sleep 2 + done + echo " bot: ${bot:-missing}" + fi docker image prune -f >/dev/null else echo " ❌ BUILD FAILED — site still runs the previous version. Details: /root/build.log" diff --git a/deploy/vps/docker-compose.prod.yml b/deploy/vps/docker-compose.prod.yml index 5e5766e..950ce11 100644 --- a/deploy/vps/docker-compose.prod.yml +++ b/deploy/vps/docker-compose.prod.yml @@ -2,12 +2,26 @@ services: basira: ports: !reset [] expose: ["8000"] + env_file: + - path: /etc/basira/basira.env # BASIRA_EVAL_KEY (600 root) — lets the bot bypass the per-IP limit + required: false environment: BASIRA_CORS_ORIGINS: https://basirapp.site,https://www.basirapp.site BASIRA_TRUSTED_PROXIES: 172.16.0.0/12 FORWARDED_ALLOW_IPS: 172.16.0.0/12 BUILD_SHA: ${BUILD_SHA:-prod} mem_limit: 2g + logging: + driver: json-file + options: { max-size: "10m", max-file: "3" } + telegram-bot: # started only with COMPOSE_PROFILES=bot (autodeploy.conf) + env_file: !override [/etc/basira/telegram-bot.env] # TELEGRAM_BOT_TOKEN + BASIRA_EVAL_KEY (600 root); replaces ./integrations/telegram/.env + environment: + BASIRA_API_URL: http://basira:8000 + BASIRA_WEB_URL: https://basirapp.site + logging: + driver: json-file + options: { max-size: "10m", max-file: "3" } caddy: image: caddy:2-alpine restart: unless-stopped @@ -17,6 +31,9 @@ services: - caddy-data:/data - caddy-config:/config depends_on: [basira] + logging: + driver: json-file + options: { max-size: "10m", max-file: "3" } volumes: caddy-data: caddy-config: diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 7e909d9..d139682 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -85,9 +85,10 @@ | E-054 | 2026-10-03 | **Frontend tells the truth about what is live.** UI v3 shipped with English, Guard, MCP and the receipt marked «قريبًا» while all four were live on `main` (E-048/E-049/E-052). Wired, not redesigned: English mode = same `/v1/check` with `english_candidates[]` rendered on the QuoteCard (approved translations + verbatim Arabic, `selected` marked); Guard mode = `POST /v1/guard` → one verdict panel (clear/flagged/no_quotes, fixed summaries); receipt = button under the pipeline strip → `POST /v1/receipt` → shareable `/v/{token}?h=` link; MCP badge → live, six tools listed; developers table gains guard/receipt/replay/rules/models/mcp rows. Two tests that encoded the «soon» state were rewritten to assert the live behaviour. | «What is not built stays “soon”» cuts both ways — what *is* built must not be hidden. Judges score the product they can click. | `frontend/src/Check.tsx`, `api.ts`, `components/QuoteCard.tsx`, `site/{services.ts,strings.ts,Pages.tsx,lux.css}`, `i18n.ts`, `Check.test.tsx`, `site/site.test.tsx` | | E-055 | 2026-10-05 | **Mobile result reveal + small-screen fixes (E-UX-01…05) and design dossier (`docs/design/`).** After «افحص» the page now scrolls to and focuses the report (effect after commit, not `setTimeout`); a new check clears the previous report; errors are focused; `#results` carries `aria-busy`; short phones (≤700 px tall) and 320 px widths keep the button, status strip and header on screen. Identity v4 «ختم المعارضة» (8-fold khatam + dāra + rhombic nuqta) documented as a **proposal** with mockups, SVG assets and a geometry guard (`seal.py --check`: 8 tips at 45°, never a hexagram). | On phones the result was rendered below the fold and never brought into view. Measured on 6 viewports × 66 checks against the live server: **47/66 → 66/66**. Root cause instrumented: `#results` had 0 children / 0 px when `focus()` ran. Two stale e2e tests (opened `/`, composer moved to `/check` in f69e4df) fixed. vitest 26 → **28**, e2e **4/4**, gates exit 0. | `frontend/src/Check.tsx`, `frontend/src/index.css`, `frontend/src/site/lux.css`, `frontend/src/Check.test.tsx`, `frontend/e2e/check.spec.ts`, `docs/design/**` | | E-056 | 2026-10-05 | **Identity v4 «ختم المعارضة» implemented**. One layer `frontend/src/site/identity.css` loaded last; seal (8-fold khatam of two squares + dāra window + gold rhombic nuqta) generated from `docs/design/brand/seal.py` into `Logo.tsx`, favicons, PWA icons, OG cards and logo files; the four `state-*` icons become the dāra glyphs (filled rhombus / hollow rhombus / bar / empty ring); night band + khatam lattice + gold chain; jadwal frame; Naskh headings (Amiri Bold) and Kufic wordmark (Reem Kufi), both OFL, 112 316 B. Eye-mark files deleted. | Measured: axe WCAG 2.2 AA **0 violations** on 32 runs (8 before, incl. pre-existing /settings dark contrast and listitem); mobile battery 66/66; vitest 28 → **30** (`seal.test.ts` parses the shipped outline: 8 tips at 45°, never a hexagram); e2e 4/4; gates exit 0; JS gzip unchanged 86.73 kB, CSS 76.61 kB. | `frontend/src/site/identity.css`, `src/brand/{Logo,Icon}.tsx`, `src/brand/seal.test.ts`, `src/main.tsx`, `src/site/Settings.tsx`, `public/{fonts,brand/**,favicon.ico,manifest.webmanifest}`, `index.html`, `docs/design/**` | -| E-057 | 2026-10-06 | **Whole-ayah anchor.** `extract/anchor.detect` accepts an unmarked run that spans a complete ayah (after the basmala offset, either rasm) regardless of word rarity. | «قل هو الله أحد» / «إن الله مع الصابرين» typed alone were not detected: 3 of their 4 words are stop-words. The Mushaf's own ayah boundary is the evidence, not word rarity. Hadith unchanged (no self-delimiting unit). eval-full 150/150 · 0/500 unchanged. | `backend/app/extract/anchor.py::_covers_whole_ayah` | -| E-058 | 2026-10-06 | **Phrase-rarity anchor + backward extension + isnad guard.** `extract/anchor.detect` accepts an unmarked run of ≥ 3 tokens that occurs ≤ 60× verbatim in the whole corpus (with ≥ 1 content word, not isnad-shaped), and extends every run backwards over leading stop-words. Composer shows «لا يلزم قال تعالى… ولا الهمزات». | Supervisor feedback (2026-10-05): «إنما الأعمال بالنيات» alone → zero matches, with «قال رسول الله» → found; «judges will say the site does not work». Phrase rarity separates quotations (7×, 2×, 9×) from formulaic prose (92 083×, 3 248×, 1 191×) by three orders of magnitude; the band 27 ↔ 105 is empty. The old 6-content-word rule hid every short famous hadith. Verified: 15 new tests (`test_as_people_write.py`), smoke +7 unmarked cases, eval-full 150/150 · 0/500 unchanged. | `backend/app/extract/anchor.py`, `scripts/smoke.py`, `frontend/src/{Check.tsx,i18n.ts}` | | E-057 | 2026-10-05 | **CI activated; non-product material removed from the tree.** The workflow lives in `.github/workflows/ci.yml` and builds the sha256-pinned corpora → index → fixture before tests, so corpus-backed tests run instead of skipping. | Measured on a clean clone: pytest 304 passed, eval 150/150, unsafe 0. | `.github/workflows/ci.yml` | | E-058 | 2026-10-05 | **Release 0.3.1 hygiene.** Runtime deps and Python 3.12 declared; `run_1b.py` aligned with `Pipeline.check()` and IslamicEval re-measured (1B 78.54 %, false confirmations 2); docs point only at public documents; BUILD_SHA passed into the image. | A clean install failed at collection; `make islamiceval` crashed with ValueError. | `backend/pyproject.toml`, `eval/islamiceval/`, `Dockerfile`, `docs/` | | E-059 | 2026-10-06 | **Telegram bot as a thin API client** (`integrations/telegram`). Long polling by default: no public port, no TLS endpoint, no webhook secret to manage, works behind NAT; webhook is opt-in. No storage: text and image bytes exist only for one request (ADR-004); logs carry counts and latency only. Every sentence about a quotation is a `messages/*.json` template; bold ranges come from the API `diff`. | 73 tests (render/client/handlers through the real PTB `Application`); live 11/11 private scenarios + channel forward + group `/check`. Privacy mode drops plain @mentions (0 updates measured) → groups use `/check`. | `integrations/telegram/**`, `Makefile`, `.github/workflows/ci.yml`, `docker-compose.yml` | | E-060 | 2026-10-06 | **Bot replies as Telegram Rich Messages (Bot API 10.3).** One RTL rich message per result: heading + status strip, the user's quote as a cited blockquote, `source_text` as a pull-quote credited with `ref_label_ar` (one per ayah), `` from the API `diff`, a compact metadata table, collapsible notices/positions/full record, in-message url/copy buttons. Classic HTML (4 096-char split) is the automatic fallback on any rejected rich call. | Live: 16/16 scenarios on the rich path, 0 rejected; 105 tests incl. Rich-HTML validation and fallback. Previous classic layout read as an undifferentiated wall of text (owner feedback). | `integrations/telegram/bot/rich.py`, `handlers.py`, `docs/design/evidence/telegram/` | +| E-061 | 2026-10-06 | **Whole-ayah anchor.** `extract/anchor.detect` accepts an unmarked run that spans a complete ayah (after the basmala offset, either rasm) regardless of word rarity. | «قل هو الله أحد» / «إن الله مع الصابرين» typed alone were not detected: 3 of their 4 words are stop-words. The Mushaf's own ayah boundary is the evidence, not word rarity. Hadith unchanged (no self-delimiting unit). eval-full 150/150 · 0/500 unchanged. | `backend/app/extract/anchor.py::_covers_whole_ayah` | +| E-062 | 2026-10-06 | **Phrase-rarity anchor + backward extension + isnad guard.** `extract/anchor.detect` accepts an unmarked run of ≥ 3 tokens that occurs ≤ 60× verbatim in the whole corpus (with ≥ 1 content word, not isnad-shaped), and extends every run backwards over leading stop-words. Composer shows «لا يلزم قال تعالى… ولا الهمزات». | Supervisor feedback (2026-10-05): «إنما الأعمال بالنيات» alone → zero matches, with «قال رسول الله» → found; «judges will say the site does not work». Phrase rarity separates quotations (7×, 2×, 9×) from formulaic prose (92 083×, 3 248×, 1 191×) by three orders of magnitude; the band 27 ↔ 105 is empty. The old 6-content-word rule hid every short famous hadith. Verified: 15 new tests (`test_as_people_write.py`), smoke +7 unmarked cases, eval-full 150/150 · 0/500 unchanged. | `backend/app/extract/anchor.py`, `scripts/smoke.py`, `frontend/src/{Check.tsx,i18n.ts}` | +| E-063 | 2026-10-06 | **Bot runs on the VPS; «open in Basira» carries the text.** Production overlay reads the token from `/etc/basira/telegram-bot.env` (600 root) via `env_file: !override`; a random `BASIRA_EVAL_KEY` shared by `/etc/basira/basira.env` and the bot lets the API skip its per-IP limiter for the bot (all Telegram users otherwise share one 30 req/min budget behind one Docker address) while the bot's 10 msg/min per user stays the guard; `COMPOSE_PROFILES` from `autodeploy.conf` is exported by `autodeploy.sh`, which logs `bot: healthy|…` after each deploy; json-file log rotation 10m×3 on all services. `/check?text=` pre-fills the composer (cap 5000, never auto-checks, param dropped from the URL); the bot's result button uses it when the whole URL is ≤ 2000 chars, else plain `/check`. Duplicate IDs E-057/E-058 introduced by PR #1 renumbered to E-061/E-062. | Judges and users need the bot up without a laptop running; one poller only (409 otherwise). Measured: bot 110 tests, vitest 32, compose config validated with and without the profile. | `deploy/vps/{docker-compose.prod.yml,autodeploy.sh,autodeploy.conf.example,README.md}`, `integrations/telegram/bot/{rich.py,handlers.py}`, `frontend/src/Check.tsx`, `docs/DEPLOYMENT.md` | diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index df08999..2dc7cd1 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -40,7 +40,7 @@ Health: `GET /health` → `{"status":"ok","boot":"snapshot","index_sha256":"…" | `BASIRA_CORS_ORIGINS` | your public origin(s), comma-separated | Browser calls from the same origin need nothing; set it if the UI is served elsewhere | | `BASIRA_TRUSTED_PROXIES` | LB / proxy IP or CIDR | **B12**: `X-Forwarded-For` is honoured only from these peers; empty = never | | `FORWARDED_ALLOW_IPS` | **same value** as above | uvicorn's own proxy trust. Never `'*'` | -| `BASIRA_RATE_LIMIT_PER_MIN` | default 30 | Per client IP, fixed window; `X-Eval-Key` bypass for your own eval runs (`BASIRA_EVAL_KEY`) | +| `BASIRA_RATE_LIMIT_PER_MIN` | default 30 | Per client IP, fixed window; `X-Eval-Key` bypass for your own eval runs and the Telegram bot (`BASIRA_EVAL_KEY`, §3.1) | | `BASIRA_MCP` | `1` (image default) | MCP server at `/mcp`; set `0` to disable | | `BASIRA_MODEL_CONFIG` | `/data/model.json` (image default) | Where `/settings` stores the operator's key; must be on a writable volume | | `BUILD_SHA` | git short SHA | Shown in `/health` so a judge can tie a deployment to a commit | @@ -64,6 +64,32 @@ snapshot is prebuilt. ``` 3. Publish the MCP config for assistants: `{"mcpServers":{"basira":{"type":"http","url":"https:///mcp"}}}`. +### 3.1 Telegram bot on the same host (optional) + +The bot (`integrations/telegram`, E-059) is a compose **profile**: nothing starts unless you ask for it. + +| Where | What | Mode | +|---|---|---| +| `/etc/basira/telegram-bot.env` | `TELEGRAM_BOT_TOKEN=…` (from @BotFather) and `BASIRA_EVAL_KEY=…` | `600 root:root` | +| `/etc/basira/basira.env` | the **same** `BASIRA_EVAL_KEY=…` (`openssl rand -hex 24`) | `600 root:root` | +| `/opt/basira/autodeploy.conf` | `COMPOSE_PROFILES=bot` | — | + +Why the shared key: the bot reaches the API from one address inside the Docker network, so every Telegram user +would share the 30 req/min per-IP budget. With `X-Eval-Key` the API skips the per-IP limiter for the bot; the +bot's own **10 messages/min per user** stays the protection. The production overlay (`deploy/vps/docker-compose.prod.yml`) +points the `telegram-bot` service at `/etc/basira/telegram-bot.env` and the `basira` service at `/etc/basira/basira.env`. +Secrets never enter `/opt/basira` or git. + +Verify after a deploy: +```bash +tail -3 /var/log/basira-deploy.log # … ✅ DEPLOYED … / bot: healthy +docker compose -f docker-compose.yml -f docker-compose.prod.yml ps # telegram-bot (healthy) +docker logs basira-telegram-bot-1 2>&1 | grep -E 'basira_api=up|mode=polling' +docker stats --no-stream basira-telegram-bot-1 # RSS well under mem_limit 128m +``` +Only **one** poller may hold the token: a second instance anywhere produces `Conflict: terminated by other +getUpdates request` in both logs — stop the other one, the server copy recovers by itself. + ## 4. Platform notes * **Reference deployment** — https://basirapp.site runs on one VPS with Docker Compose and Caddy, and diff --git a/docs/STATE.md b/docs/STATE.md index 2fffe27..446ce99 100644 --- a/docs/STATE.md +++ b/docs/STATE.md @@ -41,6 +41,10 @@ https://basirapp.site — deployed from `main` of MoTechSys/Project-Basira by `d (60 s timer). `/health` reports the deployed commit in `build_sha`. Model providers stay `mock` until a key is entered in `/settings` (DEPLOYMENT §3). +Telegram bot [@BasiraCheckBot](https://t.me/BasiraCheckBot) runs on the same host as compose profile `bot` +(`COMPOSE_PROFILES=bot` in `autodeploy.conf`; token and shared `BASIRA_EVAL_KEY` in `/etc/basira/*.env`, +600 root). Each deploy logs `bot: healthy`. DEPLOYMENT §3.1, E-059/E-060/E-063. + ## 4. Open items | Item | Why it is open | Next step | diff --git a/frontend/src/Check.test.tsx b/frontend/src/Check.test.tsx index c0e7901..9a30a7a 100644 --- a/frontend/src/Check.test.tsx +++ b/frontend/src/Check.test.tsx @@ -4,9 +4,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import ar from "../../messages/ar.json"; import en from "../../messages/en.json"; import type { CheckResponse, SourceInfo } from "./api"; -import Check from "./Check"; +import Check, { EXAMPLES, initialTextFrom } from "./Check"; import { QuoteCard } from "./components/QuoteCard"; -import { UI } from "./i18n"; +import { MAX_CHARS, UI } from "./i18n"; import { __resetHealth } from "./site/hooks"; import { SITE } from "./site/strings"; import fixture from "./__fixtures__/check_response.json"; @@ -227,3 +227,24 @@ describe("Check page — reveal after check (E-UX-01, E-UX-03)", () => { expect(document.activeElement).toBe(alert); }); }); + +describe("Check page — ?text= deep link (E-063)", () => { + it("pre-fills the composer from ?text=, caps at MAX_CHARS, does not check, and drops the param from the URL", async () => { + const fetchMock = mockFetch(); + const long = "ا".repeat(MAX_CHARS + 50); + history.replaceState(null, "", `/check?text=${encodeURIComponent(long)}`); + render(); + const ta = screen.getByLabelText(UI.ar["input_label"]!) as HTMLTextAreaElement; + expect(ta.value.length).toBe(MAX_CHARS); + await screen.findByText(UI.ar["status_ok"]!); + expect(fetchMock.mock.calls.some(([u]) => String(u).endsWith("/v1/check"))).toBe(false); + expect(new URLSearchParams(location.search).has("text")).toBe(false); + history.replaceState(null, "", "/check"); + }); + + it("initialTextFrom: ?text= wins over ?example=; a bare ?example= still resolves", () => { + expect(initialTextFrom("?text=%D8%A8%D8%B3%D9%85&example=example_mixed")).toBe("بسم"); + expect(initialTextFrom("?example=example_mixed")).toBe(EXAMPLES.find((e) => e.key === "example_mixed")?.text ?? ""); + expect(initialTextFrom("")).toBe(""); + }); +}); diff --git a/frontend/src/Check.tsx b/frontend/src/Check.tsx index 3b2c860..592ec47 100644 --- a/frontend/src/Check.tsx +++ b/frontend/src/Check.tsx @@ -169,10 +169,18 @@ function initialMode(): Mode { const m = new URLSearchParams(location.search).get("mode"); return m === "image" || m === "english" || m === "guard" ? m : "text"; } -function initialText(): string { - const k = new URLSearchParams(location.search).get("example"); +/** Text to pre-fill from the URL. `?text=` (deep link from the Telegram bot, E-063) wins over `?example=`. + * Pre-fill only — the user presses the button; nothing is checked on page load. Capped at MAX_CHARS. */ +export function initialTextFrom(search: string): string { + const p = new URLSearchParams(search); + const t = p.get("text"); + if (t !== null) return t.slice(0, MAX_CHARS); + const k = p.get("example"); return EXAMPLES.find((e) => e.key === k)?.text ?? ""; } +function initialText(): string { + return initialTextFrom(location.search); +} export default function Check({ lang }: { lang: Lang; onLang?: (l: Lang) => void }) { const [mode, setMode] = useState(initialMode); @@ -215,6 +223,7 @@ export default function Check({ lang }: { lang: Lang; onLang?: (l: Lang) => void if (mode === "text") u.searchParams.delete("mode"); else u.searchParams.set("mode", mode); u.searchParams.delete("example"); + u.searchParams.delete("text"); // consumed into the composer; a reload must not resurrect it over the user's edits history.replaceState(null, "", u.pathname + u.search); }, [mode]); diff --git a/frontend/src/index.css b/frontend/src/index.css index 974a159..1c24d5f 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1183,7 +1183,7 @@ mark.d-source { gap: var(--bs-sp-4); } -/* E-058 — composer hint: the detector needs no marker, no hamza (supervisor feedback 2026-10-05) */ +/* E-062 — composer hint: the detector needs no marker, no hamza (supervisor feedback 2026-10-05) */ .composer-lux__hint { margin: 0.35rem 0 0; font-size: 0.82rem; diff --git a/integrations/telegram/README.md b/integrations/telegram/README.md index ca008b2..83f9142 100644 --- a/integrations/telegram/README.md +++ b/integrations/telegram/README.md @@ -43,6 +43,7 @@ make bot-install && make bot-test # from the repo root cd integrations/telegram && cp .env.example .env # fill the token set -a; . ./.env; set +a; .venv/bin/python -m bot.main # long polling docker compose --profile bot up -d # with the API container (BASIRA_API_URL=http://basira:8000) +# production (basirapp.site): token in /etc/basira/telegram-bot.env, COMPOSE_PROFILES=bot — docs/DEPLOYMENT.md §3.1 ``` Webhook mode: set `TELEGRAM_WEBHOOK_URL` and `TELEGRAM_WEBHOOK_SECRET` (16–256 chars); the bot listens on `PORT` at `/telegram`. diff --git a/integrations/telegram/bot/handlers.py b/integrations/telegram/bot/handlers.py index 97fdae4..2781dd7 100644 --- a/integrations/telegram/bot/handlers.py +++ b/integrations/telegram/bot/handlers.py @@ -182,15 +182,23 @@ async def _show_result( messages: Mapping[str, Mapping[str, str]], *, image: bool, + text: str = "", ) -> None: - docs = rich.render_rich(resp, messages, image=image, site_url=self.s.web_url) if self.s.rich else [] + # «open in Basira» carries the user's own text as ?text= when the URL stays short enough (E-063); + # the site pre-fills the composer only. The link lives in the message the user already holds. + open_url = rich.check_link(self.s.web_url, text) + docs = ( + rich.render_rich(resp, messages, image=image, site_url=self.s.web_url, open_url=open_url) + if self.s.rich + else [] + ) if docs and await self._rich_edit(sent, docs[0]): ok = True for doc in docs[1:]: ok = ok and (await self._rich_send(sent, doc)) is not None if ok: return - await self._deliver(sent, render.render_check(resp, messages, image=image)) + await self._deliver(sent, render.render_check(resp, messages, image=image), open_url=open_url) async def _reply_doc( self, msg: Message, doc: str, fallback_html: str, *, markup: InlineKeyboardMarkup | None = None @@ -198,11 +206,13 @@ async def _reply_doc( if await self._rich_send(msg, doc) is None: await self._send(msg, fallback_html, markup=markup) - def _site_button(self) -> InlineKeyboardMarkup: - return InlineKeyboardMarkup([[InlineKeyboardButton(txt.OPEN_SITE, url=f"{self.s.web_url}/check")]]) + def _site_button(self, url: str = "") -> InlineKeyboardMarkup: + return InlineKeyboardMarkup( + [[InlineKeyboardButton(txt.OPEN_SITE, url=url or rich.check_link(self.s.web_url))]] + ) - async def _deliver(self, sent: Message, pieces: list[str]) -> None: - markup = self._site_button() + async def _deliver(self, sent: Message, pieces: list[str], *, open_url: str = "") -> None: + markup = self._site_button(open_url) last = len(pieces) - 1 await self._edit(sent, pieces[0], markup=markup if last == 0 else None, rich_first=False) for i, piece in enumerate(pieces[1:], 1): @@ -261,9 +271,10 @@ async def check_text(self, update: Update, msg: Message, text: str, *, input_kin "chat=%s input=%s error=%s ms=%d", chat_kind(update), input_kind, type(e).__name__, _ms(t0) ) return + try: + await self._show_result(sent, resp, messages, image=False, text=text) finally: - del text - await self._show_result(sent, resp, messages, image=False) + del text # the user's text is not kept beyond this request (ADR-004) log.info( "chat=%s input=%s quotes=%s refusal=%s ms=%d", chat_kind(update), diff --git a/integrations/telegram/bot/rich.py b/integrations/telegram/bot/rich.py index 94744f6..f1fb431 100644 --- a/integrations/telegram/bot/rich.py +++ b/integrations/telegram/bot/rich.py @@ -24,6 +24,7 @@ from collections.abc import Mapping, Sequence from typing import Any +from urllib.parse import quote from bot import render from bot import strings_ar as txt @@ -64,6 +65,20 @@ def _https(url: object) -> str: return u if u.startswith("https://") else "" +DEEP_LINK_MAX = 2000 # URL length Telegram buttons and browsers handle without truncation + + +def check_link(site_url: str, text: str = "") -> str: + """`/check`, with the user's text as `?text=` when the whole URL stays ≤ DEEP_LINK_MAX chars. + The site only pre-fills the composer (never checks on load); the text travels in the user's own + click, nothing is stored (E-063).""" + base = site_url.rstrip("/") + "/check" + if not text: + return base + url = base + "?text=" + quote(text, safe="") + return url if len(url) <= DEEP_LINK_MAX else base + + def _button(label: str, *, url: str = "", copy: str = "", style: str = "") -> str: label = label if len(label) <= BUTTON_TEXT_MAX else label[: BUTTON_TEXT_MAX - 1] + "…" st = f' style="{style}"' if style else "" @@ -205,15 +220,18 @@ def quote_html(q: JSON, i: int, n: int, m: Messages, *, image: bool) -> str: return "".join(p for p in parts if p) -def render_rich(resp: JSON, m: Messages, *, image: bool = False, site_url: str = "") -> list[str]: +def render_rich( + resp: JSON, m: Messages, *, image: bool = False, site_url: str = "", open_url: str = "" +) -> list[str]: """One or more rich-message HTML documents (normally exactly one; split only past 30 000 characters).""" flags = resp.get("flags") or {} quotes = list(resp.get("quotes") or []) head: list[str] = [f"

{esc(txt.RESULT_TITLE)}

"] + link = open_url or (check_link(site_url) if site_url else "") if flags.get("refusal"): # a religious question: the refusal notice only, nothing that reads as an answer t = msg(m, "notice", "refusal") or "" - return [f"" + _footer(resp, m, site_url)] + return [f"" + _footer(resp, m, link)] if image and resp.get("ocr_text"): ocr = str(resp["ocr_text"]) @@ -232,7 +250,7 @@ def render_rich(resp: JSON, m: Messages, *, image: bool = False, site_url: str = if not quotes: t = msg(m, "notice", "no_quotes") or "" - return ["".join(head) + f"

{esc(t)}

" + _footer(resp, m, site_url)] + return ["".join(head) + f"

{esc(t)}

" + _footer(resp, m, link)] head.append(f"

{_status_strip(quotes, m)} · {esc(txt.quotes_count(len(quotes)))}

") docs: list[str] = [] @@ -243,20 +261,20 @@ def render_rich(resp: JSON, m: Messages, *, image: bool = False, site_url: str = docs.append(cur) cur = "" cur += block - cur += _footer(resp, m, site_url) + cur += _footer(resp, m, link) docs.append(cur) return docs -def _footer(resp: JSON, m: Messages, site_url: str) -> str: +def _footer(resp: JSON, m: Messages, open_url: str) -> str: out = "
" t = msg(m, "fixed", str(resp.get("transparency_key") or "transparency_notice")) h = str(resp.get("determinism_hash") or "") fp = f"
{esc(txt.FINGERPRINT)}: {esc(h[:16])}" if h else "" if t: out += f"
{esc(t)}{fp}
" - if site_url: - out += f"{_button(txt.OPEN_SITE, url=site_url.rstrip('/') + '/check', style='success')}" + if open_url: + out += f"{_button(txt.OPEN_SITE, url=open_url, style='success')}" return out @@ -274,7 +292,7 @@ def welcome(m: Messages, site_url: str) -> str: f"
{esc(txt.ABOUT_TITLE)}" f"

{esc(fixed.get('transparency_notice', ''))}

{esc(fixed.get('privacy_notice', ''))}

" f"
{esc(fixed.get('footer', ''))}
" - f"{_button(txt.OPEN_SITE, url=site_url.rstrip('/') + '/check', style='success')}" + f"{_button(txt.OPEN_SITE, url=check_link(site_url), style='success')}" ) diff --git a/integrations/telegram/tests/test_handlers.py b/integrations/telegram/tests/test_handlers.py index 5a23d00..6896acc 100644 --- a/integrations/telegram/tests/test_handlers.py +++ b/integrations/telegram/tests/test_handlers.py @@ -7,8 +7,10 @@ from __future__ import annotations import logging +import re from collections.abc import AsyncIterator, Callable from dataclasses import dataclass +from html import unescape from typing import Any import httpx @@ -333,3 +335,32 @@ def test_settings_validation_and_repr_hides_secrets() -> None: ) s = Settings.from_env({"TELEGRAM_BOT_TOKEN": TOKEN, "BASIRA_EVAL_KEY": "k", "MAX_TEXT": "100"}) assert s.max_text == 100 and TOKEN not in repr(s) and "k" not in repr(s).replace("webhook", "") + + +# ------------------------------------------------------------------ E-063 deep link +async def test_result_button_carries_the_users_text_when_short(make: Factory) -> None: + from urllib.parse import parse_qs, urlparse + + h = await make({"/v1/check": "found_quran"}) + [out] = await h.send(message(QURAN)) + urls = [unescape(u) for u in re.findall(r']*url="([^"]+)"', out)] + assert urls, "result must end with the site button" + url = urls[-1] # the last url button is «open in Basira» + assert url.startswith("https://basirapp.site/check?text=") and len(url) <= 2000 + assert parse_qs(urlparse(url).query)["text"] == [QURAN] + + +async def test_result_button_is_plain_when_text_is_long(make: Factory) -> None: + long_text = "قال تعالى: ﴿إن الله مع الصابرين﴾ " * 12 # ~400 chars → > 2000 once percent-encoded + h = await make({"/v1/check": "found_quran"}) + [out] = await h.send(message(long_text)) + assert 'url="https://basirapp.site/check"' in out and "?text=" not in out + + +async def test_classic_fallback_button_carries_the_text_too(make: Factory) -> None: + h = await make({"/v1/check": "found_quran"}, rich_ok=False) + await h.send(message(QURAN)) + with_button = [c for c in h.tg.calls if c.params.get("reply_markup")] + assert with_button + url = with_button[-1].params["reply_markup"]["inline_keyboard"][0][0]["url"] + assert url.startswith("https://basirapp.site/check?text=") diff --git a/integrations/telegram/tests/test_rich.py b/integrations/telegram/tests/test_rich.py index 02ef35e..64e3fe4 100644 --- a/integrations/telegram/tests/test_rich.py +++ b/integrations/telegram/tests/test_rich.py @@ -197,3 +197,29 @@ def test_frontend_fixture_renders(m: Mapping[str, Mapping[str, str]]) -> None: for d in rich.render_rich(resp, m): assert validate_rich(d) is None assert scan_forbidden(authored(d, resp)) == [] + + +# ------------------------------------------------------------------ E-063 deep link +def test_check_link_carries_short_text_and_falls_back_when_too_long() -> None: + from urllib.parse import parse_qs, urlparse + + base = rich.check_link("https://basirapp.site/") + assert base == "https://basirapp.site/check" + url = rich.check_link("https://basirapp.site", "قال تعالى: ﴿إن الله مع الصابرين﴾") + assert url.startswith("https://basirapp.site/check?text=") and len(url) <= rich.DEEP_LINK_MAX + assert parse_qs(urlparse(url).query)["text"] == [ + "قال تعالى: ﴿إن الله مع الصابرين﴾" + ] # round-trips exactly + # Arabic is 6 chars per letter once percent-encoded: 330 letters exceed 2000 → plain /check + assert rich.check_link("https://basirapp.site", "ا" * 330) == base + # boundary: exactly at the cap stays a deep link + pad = rich.DEEP_LINK_MAX - len("https://basirapp.site/check?text=") + assert len(rich.check_link("https://basirapp.site", "a" * pad)) == rich.DEEP_LINK_MAX + assert rich.check_link("https://basirapp.site", "a" * (pad + 1)) == base + + +def test_render_rich_uses_open_url_for_the_site_button(m: Mapping[str, Mapping[str, str]]) -> None: + link = rich.check_link("https://basirapp.site", "نص") + [doc] = rich.render_rich(load("found_quran"), m, site_url="https://basirapp.site", open_url=link) + assert f'url="{render.attr(link)}"' in doc + assert validate_rich(doc) is None diff --git a/scripts/smoke.py b/scripts/smoke.py index 4e7176d..3304e8c 100644 --- a/scripts/smoke.py +++ b/scripts/smoke.py @@ -31,7 +31,7 @@ ("إنما الأعمال بالنيات", "found", None), ("الدين المعاملة", "not_found", None), ] -# E-057/E-058 + D-015 — «كما يكتب الناس»: no marker, no hamza. The supervisor's exact input first. +# E-061/E-062 + D-015 — «كما يكتب الناس»: no marker, no hamza. The supervisor's exact input first. PEOPLE = [ ("إنما الأعمال بالنيات", "found"), ("انما الاعمال بالنيات", "found"),