diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..540d1e0
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,25 @@
+.git
+vendor
+data
+runtime
+dist
+.phpunit.cache
+.phpstan.cache
+.env
+.env.*
+!.env.example
+*.madeline*
+bot.shabbat*
+*.sqlite*
+*.db*
+*.log
+backups
+.work
+shabbat-*.zip
+SHA256SUMS*
+coverage
+.cache
+artifacts
+checks
+release-manifest*.json
+publication-manifest*.json
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..ad00117
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,12 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+insert_final_newline = true
+indent_style = space
+indent_size = 4
+trim_trailing_whitespace = true
+
+[*.{json,yml,yaml}]
+indent_size = 2
\ No newline at end of file
diff --git a/.env.example b/.env.example
index f9120d7..506dcc3 100644
--- a/.env.example
+++ b/.env.example
@@ -1,4 +1,14 @@
-API_ID=your_api_id
-API_HASH=your_api_hash
-BOT_TOKEN=your_bot_token
-ADMIN=your_username_or_id
+API_ID=123456
+API_HASH=replace_with_32_hex_characters
+BOT_TOKEN=replace_with_BotFather_token
+ADMIN=123456789
+APP_DB_PATH=data/shabbat.sqlite
+SESSION_PATH=data/session/bot.shabbat
+DATA_DIR=data
+LEGACY_DATA_PATH=.
+HEARTBEAT_PATH=data/scheduler-heartbeat.json
+GEONAMES_USERNAME=
+GEONAMES_ENDPOINT=https://secure.geonames.org
+SOURCE_URL=
+
+CUSTOM_EMOJI_ENABLED=1
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..d3ec35b
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,22 @@
+* text=auto
+*.php text eol=lf
+*.json text eol=lf
+*.md text eol=lf
+*.sh text eol=lf
+*.yml text eol=lf
+*.patch text eol=lf
+*.diff text eol=lf
+Dockerfile text eol=lf
+*.sql text eol=lf
+
+# Preserve exact reviewed upstream, diff, and manifest bytes across clones.
+patches/** -text
+patches/**/baseline/** whitespace=-blank-at-eol,-blank-at-eof,-space-before-tab,cr-at-eol
+
+# Distributed publisher license text hashes must remain stable too.
+LICENSES/** -text
+
+# Database checksums and bundled licensed/provider text use the original bytes.
+migrations/** -text
+resources/templates/** -text
+tests/Fixtures/** -text
diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md
index 58f9d5a..7323052 100644
--- a/.github/CODE_OF_CONDUCT.md
+++ b/.github/CODE_OF_CONDUCT.md
@@ -1,39 +1,39 @@
-# Contributor Covenant Code of Conduct
-
-## Our Pledge
-
-We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
-
-## Our Standards
-
-Examples of behavior that contributes to a positive environment for our community include:
-- Using welcoming and inclusive language
-- Being respectful of differing viewpoints and experiences
-- Gracefully accepting constructive criticism
-- Showing empathy towards other community members
-
-Examples of unacceptable behavior include:
-- The use of sexualized language or imagery and unwelcome sexual attention or advances
-- Trolling, insulting or derogatory comments, and personal or political attacks
-- Public or private harassment
-- Publishing othersโ private information, such as a physical or electronic address, without explicit permission
-
-## Our Responsibilities
-
-Project maintainers are responsible for clarifying the standards of acceptable behavior and will take appropriate and fair corrective action in response to any instances of unacceptable behavior.
-
-## Scope
-
-This Code of Conduct applies within all project spaces and in public spaces when an individual is representing the project or its community.
-
-## Enforcement
-
-Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at [@WizardLoop on Telegram](https://t.me/WizardLoop) or contact@wizardloop.com.
-
-All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances.
-
-## Attribution
-
-This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
-
-[homepage]: https://www.contributor-covenant.org
+# Contributor Covenant Code of Conduct
+
+## Our Pledge
+
+We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
+
+## Our Standards
+
+Examples of behavior that contributes to a positive environment for our community include:
+- Using welcoming and inclusive language
+- Being respectful of differing viewpoints and experiences
+- Gracefully accepting constructive criticism
+- Showing empathy towards other community members
+
+Examples of unacceptable behavior include:
+- The use of sexualized language or imagery and unwelcome sexual attention or advances
+- Trolling, insulting or derogatory comments, and personal or political attacks
+- Public or private harassment
+- Publishing othersโ private information, such as a physical or electronic address, without explicit permission
+
+## Our Responsibilities
+
+Project maintainers are responsible for clarifying the standards of acceptable behavior and will take appropriate and fair corrective action in response to any instances of unacceptable behavior.
+
+## Scope
+
+This Code of Conduct applies within all project spaces and in public spaces when an individual is representing the project or its community.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at [@WizardLoop on Telegram](https://t.me/WizardLoop) or contact@wizardloop.com.
+
+All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
+
+[homepage]: https://www.contributor-covenant.org
diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index a502c2e..cf693c6 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -1,19 +1,15 @@
# Contributing to WizardLoop Shabbat
-Thank you for your interest!
+Work on a separate branch and preserve the existing MadelineProto bot identity and session. Use the issue templates for reports; never include tokens, API credentials, session files, real databases or private group/user lists.
-## How to Contribute
+Install the real Composer lock, then run `composer validate --strict --no-check-all`, `composer check-platform-reqs`, `composer lint`, `composer style`, `composer test`, `composer analyse`, `composer check-locales`, `composer check-docs`, `composer dependencies` and `composer self-check`. PSR-12 errors are mandatory for new services/scripts/tests; `composer style-review` also reports advisory line-length warnings. The original legacy handler layout is retained during incremental migration.
-- **Bug Reports**: Use the issue template (Bug Report) and provide details and steps to reproduce.
-- **Feature Requests**: Suggest features with as much detail as possible.
-- **Pull Requests**: Fork the repo, create a branch, submit a PR. Please describe the change, link to issues if relevant, and follow PSR-12 code style.
+Tests use isolated SQLite, injected clocks, fake Telegram and provider fixtures. Live tests are explicitly opt-in in a separate staging bot/group. Do not run production migrations, broadcast to real subscribers, or publish releases as part of tests. Review dependency scripts/plugins and patch provenance before updating the lock. Unknown or partial MadelineProto patch sources must fail verification.
-## Guidelines
+Translations must include every key and identical placeholders in all six catalogs: he, en, ru, fr, es and pt-br. Command/callback IDs remain stable across languages. Every sensitive group write must recheck current admin authority and consume a user/group/revision-bound token.
-- Follow [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)
-- Ensure your code is tested.
-- Use clear commit messages.
+Follow [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md). Explain the problem, resulting behavior, actual validation and remaining limits in a pull request. Preserve SPDX and third-party attribution, including historical MIT grants. Corresponding source for deployed changes must match the actual build.
----
+Existing project contact: [@WizardLoop on Telegram](https://t.me/WizardLoop).
-Questions? Contact [@WizardLoop on Telegram](https://t.me/WizardLoop)
+Composer validation uses --strict --no-check-all because MadelineProto is intentionally pinned to the exact reviewed source version. This retains schema and lock validation; only generic version-range recommendations are omitted.
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
index 1c634ae..6d83f6e 100644
--- a/.github/FUNDING.yml
+++ b/.github/FUNDING.yml
@@ -1,2 +1,2 @@
-github: ['WizardLoop']
-custom: ['https://WizardLoop.t.me']
+github: ['WizardLoop']
+custom: ['https://WizardLoop.t.me']
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index 9fb6c52..9ca37fa 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -1,34 +1,39 @@
-name: "๐ Bug report"
-description: "Report a reproducible bug"
-labels: [bug]
-body:
+name: "๐ Bug report"
+description: "Report a reproducible bug"
+labels: [bug]
+body:
+ - type: textarea
+ attributes:
+ label: "Describe the bug"
+ description: "A clear and concise description of what the bug is."
+ placeholder: "Bug description"
+ - type: textarea
+ attributes:
+ label: "Steps to Reproduce"
+ description: "What steps did you take that led to the bug?"
+ placeholder: "1. Go to ...\n2. Click ...\n3. See error"
+ - type: textarea
+ attributes:
+ label: "Expected behavior"
+ description: "What did you expect to happen?"
+ placeholder: "Expected behavior"
+ - type: textarea
+ attributes:
+ label: "Screenshots/Logs"
+ description: "Use synthetic examples and redact tokens, sessions, private group/user IDs, payment data and live databases."
+ - type: input
+ attributes:
+ label: "Bot Version / Commit"
+ description: "What version or commit are you using?"
+ - type: dropdown
+ attributes:
+ label: "Environment"
+ options:
+ - Self-hosted Linux
+ - Windows
+ - Other
+
- type: textarea
attributes:
- label: "Describe the bug"
- description: "A clear and concise description of what the bug is."
- placeholder: "Bug description"
- - type: textarea
- attributes:
- label: "Steps to Reproduce"
- description: "What steps did you take that led to the bug?"
- placeholder: "1. Go to ...\n2. Click ...\n3. See error"
- - type: textarea
- attributes:
- label: "Expected behavior"
- description: "What did you expect to happen?"
- placeholder: "Expected behavior"
- - type: textarea
- attributes:
- label: "Screenshots/Logs"
- description: "Add any screenshots or logs to help explain your problem."
- - type: input
- attributes:
- label: "Bot Version / Commit"
- description: "What version or commit are you using?"
- - type: dropdown
- attributes:
- label: "Environment"
- options:
- - Self-hosted Linux
- - Windows
- - Other
+ label: "Runtime and verification"
+ description: "PHP version, SQLite loaded by PHP, patch verification, failing offline test and whether this is isolated staging. Do not attach secrets."
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..133a777
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,5 @@
+blank_issues_enabled: false
+contact_links:
+ - name: Security report
+ url: https://t.me/WizardLoop
+ about: Report vulnerabilities privately to the existing WizardLoop contact; redact credentials and private data.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
index 78ee1fb..2fe4d12 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.yml
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -1,18 +1,18 @@
-name: "โจ Feature request"
-description: "Suggest an idea or improvement"
-labels: [enhancement]
-body:
- - type: textarea
- attributes:
- label: "Feature description"
- description: "What would you like to see added?"
- placeholder: "Feature details"
- - type: textarea
- attributes:
- label: "Use case"
- description: "Describe a real scenario where this feature would help."
- placeholder: "Use case"
- - type: textarea
- attributes:
- label: "Additional context"
- description: "Anything else?"
+name: "โจ Feature request"
+description: "Suggest an idea or improvement"
+labels: [enhancement]
+body:
+ - type: textarea
+ attributes:
+ label: "Feature description"
+ description: "What would you like to see added?"
+ placeholder: "Feature details"
+ - type: textarea
+ attributes:
+ label: "Use case"
+ description: "Describe a real scenario where this feature would help."
+ placeholder: "Use case"
+ - type: textarea
+ attributes:
+ label: "Additional context"
+ description: "Anything else?"
diff --git a/.github/ISSUE_TEMPLATE/improvement_question.yml b/.github/ISSUE_TEMPLATE/improvement_question.yml
index 39209e8..6c1b496 100644
--- a/.github/ISSUE_TEMPLATE/improvement_question.yml
+++ b/.github/ISSUE_TEMPLATE/improvement_question.yml
@@ -1,14 +1,14 @@
-name: "โ Improvement / Question"
-description: "Suggest an improvement or ask a question"
-labels: [question]
-body:
- - type: textarea
- attributes:
- label: "Improvement or Question"
- description: "Describe your suggestion or question."
- placeholder: "Your suggestion/question"
- - type: textarea
- attributes:
- label: "Context"
- description: "Where in the project do you want to see this?"
- placeholder: "Relevant files, code, or context"
+name: "โ Improvement / Question"
+description: "Suggest an improvement or ask a question"
+labels: [question]
+body:
+ - type: textarea
+ attributes:
+ label: "Improvement or Question"
+ description: "Describe your suggestion or question."
+ placeholder: "Your suggestion/question"
+ - type: textarea
+ attributes:
+ label: "Context"
+ description: "Where in the project do you want to see this?"
+ placeholder: "Relevant files, code, or context"
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index 34e46f5..94a92ba 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -1,21 +1,16 @@
-# Pull Request
+## Problem and resulting behavior
-## What does this PR do?
+
-- [ ] Bug fix
-- [ ] Feature
-- [ ] Code style/Refactoring
-- [ ] Documentation
+## Validation
-## Description
+
-
+- [ ] Composer lock, patch hashes and dependencies verified
+- [ ] Lint, mandatory style, tests and static analysis pass
+- [ ] Six locale catalogs and local documentation links pass
+- [ ] Migration/backup/recovery implications documented when applicable
+- [ ] Third-party notices and corresponding-source inputs preserved
+- [ ] No production data, session or credentials included
-## Checklist
-
-- [ ] Code builds and passes tests
-- [ ] PR follows project guidelines
-
----
-
-Related Issues: #
+Related issue:
diff --git a/.github/SECURITY.md b/.github/SECURITY.md
index 2f6710d..33fac37 100644
--- a/.github/SECURITY.md
+++ b/.github/SECURITY.md
@@ -1,10 +1,16 @@
-# Security Policy
+# Security Policy
+
+## Reporting a Vulnerability
+
+If you find a security issue, **do not open an issue on GitHub**.
+Please report privately to [@WizardLoop on Telegram](https://t.me/WizardLoop) or via email: contact@wizardloop.com
+
+---
+
+We will address all reports promptly and responsibly.
-## Reporting a Vulnerability
+## Safe reproduction information
-If you find a security issue, **do not open an issue on GitHub**.
-Please report privately to [@WizardLoop on Telegram](https://t.me/WizardLoop) or via email: contact@wizardloop.com
+Include the affected build version/commit, PHP-loaded SQLite version and a minimal synthetic reproduction. Redact bot tokens, API credentials, GeoNames account details, session files, private user/group identifiers, payment payloads/charges and real database/backups. Use a staging bot or the offline fixture tests; do not send test messages to real groups.
----
-
-We will address all reports promptly and responsibly.
+Dependency advisory checks and license/source review are separate. Report unknown patch hashes or partial vendor changes; do not bypass verification or edit a running container. The 3.0.0-dev snapshot is not a stable release. See [acceptance status](../docs/IMPLEMENTATION_STATUS.md) for checks not yet performed.
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..53a72e6
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,14 @@
+version: 2
+updates:
+ - package-ecosystem: composer
+ directory: "/"
+ schedule:
+ interval: weekly
+ - package-ecosystem: github-actions
+ directory: "/"
+ schedule:
+ interval: monthly
+ - package-ecosystem: docker
+ directory: "/"
+ schedule:
+ interval: monthly
\ No newline at end of file
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..c48e1d2
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,42 @@
+name: CI
+on:
+ push:
+ pull_request:
+permissions:
+ contents: read
+jobs:
+ php:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ php: ["8.3", "8.4", "8.5"]
+ steps:
+ - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+ - uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1
+ with:
+ php-version: ${{ matrix.php }}
+ extensions: pdo_sqlite, mbstring, xml, dom, fileinfo, zlib, zip
+ tools: composer:2.10.3
+ coverage: none
+ - run: composer validate --strict --no-check-all
+ - run: composer install --no-interaction --prefer-dist
+ - run: composer check-platform-reqs
+ - run: composer show --locked --format=json
+ - run: composer licenses --format=json
+ - run: composer audit --locked --format=json
+ - run: composer lint
+ - run: composer style
+ - run: composer test
+ - run: composer analyse
+ - run: composer check-locales
+ - run: composer check-docs
+ - run: composer dependencies
+ - run: composer self-check
+ docker:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+ - run: docker build --build-arg BUILD_COMMIT="${{ github.sha }}" -t shabbat-ci .
+ - run: docker run --rm shabbat-ci php bin/runtime-permissions.php --require-non-root
+ - run: docker run --rm shabbat-ci php bin/self-check.php --release
\ No newline at end of file
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..3b58a8b
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,38 @@
+name: Prepare release artifacts
+on:
+ workflow_dispatch:
+permissions:
+ contents: read
+jobs:
+ package:
+ runs-on: ubuntu-latest
+ environment: release-review
+ steps:
+ - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+ - uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1
+ with:
+ php-version: "8.4"
+ extensions: pdo_sqlite, mbstring, xml, dom, fileinfo, zlib, zip
+ tools: composer:2.10.3
+ coverage: none
+ - run: composer validate --strict --no-check-all
+ - run: composer install --no-interaction --prefer-dist
+ - run: composer check-platform-reqs
+ - run: composer audit --locked --format=json
+ - run: composer lint
+ - run: composer style
+ - run: composer test
+ - run: composer analyse
+ - run: composer check-docs
+ - run: composer check-locales
+ - run: composer dependencies
+ - run: php bin/self-check.php
+ - run: docker build --build-arg BUILD_COMMIT="${{ github.sha }}" -t shabbat-release .
+ - run: docker run --rm shabbat-release php bin/runtime-permissions.php --require-non-root
+ - run: docker run --rm shabbat-release php bin/self-check.php --release
+ - run: php scripts/package.php
+ - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
+ with:
+ name: reviewed-release-archives
+ path: dist/
+ if-no-files-found: error
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index a0f4a2a..c85ee49 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,11 +1,27 @@
-*.madeline.*
-*.madeline
-*.session*
-.git/
-vendor/
+/vendor/
+/data/
+/runtime/
+/dist/
+/.phpunit.cache/
+/.phpstan.cache/
+.env
+.env.*
+!.env.example
+*.madeline*
+bot.shabbat*
+*.sqlite*
+*.db
+*.db-wal
+*.db-shm
*.log
-*.env
-*.tmp
-.idea/
-*.iml
-*.txt
+/build-info.json
+/backups/
+/.work/
+/coverage/
+/.cache/
+/artifacts/
+/checks/
+/shabbat-*.zip
+/SHA256SUMS*
+/release-manifest*.json
+/publication-manifest*.json
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..c087130
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,169 @@
+# Changelog
+
+PHP 8.3 compatibility: module-delivery bookkeeping now uses the database-owned `BEGIN IMMEDIATE` transaction state and exact connection identity; native callback/message fixtures initialize inherited readonly properties from their declaring class. Regression tests cover rejected foreign/outside transactions, rollback and state reset.
+
+## 3.0.0-dev.9 - GitHub prerelease
+
+- Pin Composer 2.10.3 in CI and Docker to address GitHub Actions token compatibility and the upstream token-disclosure advisory; retain authentication, all quality gates, the application lock and MadelineProto 8.7.0.
+- Preserve migration, bundled text and captured fixture bytes across Git line-ending settings, with a regression covering both autocrlf modes. Existing SQL/resource/patch bytes remain unchanged.
+- Document the reviewed legacy Docker cutover into `/root/shabat`, stopped-writer backup/import, permission recovery and rollback.
+- Publish matching deployment and scoped runtime corresponding-source archives with the actual merge commit, source inventory and SHA-256 sums. This remains a prerelease; no server deployment is performed by publication.
+
+## Local publication preparation โ 3.0.0-dev.9
+
+- Prepare an isolated clean repository copy and separate application/runtime corresponding-source artifacts for manual review. The application version and runtime behavior remain unchanged.
+- Rewrite the English and Hebrew README files, update installation/operations references and use the single Hebrew filename `readme-he.md`.
+- Consolidate third-party notices, preserve technical provenance and hashes in dedicated documentation/manifests, and state the runtime source archive's scope explicitly.
+- Remove workstation-specific path prefixes from historical reference metadata while retaining project/file identifiers, source hashes and unresolved grant findings. Baselines, diffs, reviewed dependency source and license texts remain unchanged.
+- Record fresh isolated installation/quality/archive results and outstanding target-platform/live acceptance in the publication report. No commit, tag, push, PR, merge, release or deployment is part of this preparation.
+
+## 3.0.0-dev.9 - delivery safety and independent scheduling
+
+- Re-evaluate current calendar state after provider I/O; recheck bounded delivery evidence after topic, media and custom-emoji preflight, fallback, and local dispatch persistence.
+- Enforce actual quiet hours, Tefillin spacing/daily caps and Omer's actual restoration gap. Coalesce never-dispatched reminder backlogs while preserving uncertain random IDs/payloads and delivery evidence.
+- Add migration 016 with conservative legacy attempt backfill, indexed delivery evidence, queue metrics and bounded routine retention.
+- Separate permission, notification and scheduled broadcast callbacks. Bound permission concurrency, use fair planning batches, skip disabled-module work and completed permission no-op reads.
+- Add optional reviewed BroadcastManager lifecycle hooks for creator authorization, cancellation-aware waits and joined shutdown. Preserve all patch baselines/notices and previous approved output. Return native flood waits rather than hide them in an extended SDK wait.
+- Add provider failure cooldowns below persistent cache, independent health timestamps, boundary/uncertainty/DST regressions and simulated load/retention tests. Keep MadelineProto 8.7.0 and Composer lock unchanged.
+
+## 3.0.0-dev.8 - verified permission snapshots
+
+- Read default restrictions from the verified matching raw full-peer response instead of combining fresh full info with cached Chat permissions. Reject incomplete, mismatched and malformed data before any write.
+- Retain MadelineProto 8.7.0 and add a separately hash-guarded fresh-peer compatibility patch with immutable original source, diff, reviewed output and behavior/install regressions.
+- Add snapshot provenance in migration 015. Preserve older unresolved evidence and block unverified restoration; allow an administrator to review intended normal Telegram permissions and explicitly adopt a verified current state outside protected periods.
+- Update recovery/diagnostic/manual-result messages in six languages, migration coverage and isolated test-server deployment instructions. All earlier features remain included.
+
+## 3.0.0-dev.7 - permission recovery and paged diagnostics
+
+- Add guarded automatic reconciliation, bounded permission attempt evidence and explicit group-admin recovery with preserved snapshot history and one-use confirmation.
+- Add the group panel choices to adopt current normal permissions or restore the original permissions, with fresh authority and calendar/manual-lock/shutdown protection.
+- Replace the `/testzmanim` JSON message dump with one localized editable panel, paging, quick page entry, search, filters and a guarded JSON document export.
+- Add migrations 013-014; retain all earlier migration checksums, locked dependencies and library patches. Update six locales, regression coverage and direct test-server deployment instructions.
+## 3.0.0-dev.6 - permission equivalence and callback integration
+
+- Correct the public statistics callback allowlist at the actual SDK update-handler boundary, including callbacks emitted by the home menu and statistics tabs. Unavailable original panels return a localized callback response without new-message flooding.
+- Compare reviewed Telegram-equivalent default permission representations consistently for manual and automatic transitions, retaining original snapshots and genuine-conflict protection. See the dev.6 feedback report for evidence and limits.
+- Shabbat messages across public, inline and scheduled defaults show the nearest Shabbat-containing period and current/upcoming holidays, including verified entrance/final-exit times for weekday Yom Tov before Shabbat, without later Shabbat summaries, numeric UTC suffixes or visible source/update footers. Actual configured methods and validated source provenance remain intact.
+- Recognize both captured provider spellings of Chol Hamoed for reminder policy, while retaining its non-Yom-Tov classification. Keep a verified active festival visible when its final exit is after civil midnight.
+- Direct test-folder upload is documented; deployment ZIP/checksum files are excluded from Docker context. No dependency, patch or migration update is required.
+
+## 3.0.0-dev.5 - test feedback corrections
+
+- Manual lock/open uses a still-valid explicit override without depending on the calendar provider; each group reports a bounded actionable result, with pending retries distinguished from confirmed mutations.
+- Telegram default permission changes support both basic groups and supergroups with the bot's required administrative permission. The full 22-flag snapshot remains preserved; the 21 selectable global restrictions exclude `view_messages`, which Telegram explicitly forbids in this method. Reactions and member-tag changes remain selectable.
+- Reuse one auxiliary broadcast IPC client per event handler instead of constructing one every scheduler minute. Owner-only restart acknowledges the request, stops new work and hands restart to the pinned SDK with lifecycle guards.
+- Group and group-administrator command menus contain only `settings`; private command scopes remain localized and role appropriate.
+- Shabbat/festival notices are a single overview without previous/next buttons. Closest verified holidays, parsha/Mevarchim, intermediate candles and final continuous-period exit remain visible; Yom Tov timing is gated by Hebcal's `yomtov` field. The next three verified Shabbat summaries are compact.
+- Group settings show a current Mode button with four choices. Save/cancel returns to the same subcategory/editor. Message-design actions are logically paired; default Jerusalem candle lighting explicitly shows 40 minutes.
+- Public statistics gain editable overview/reminder sections; private administrative statistics are grouped by audience, activity, groups and delivery.
+- Broadcast editing adds a short label/link wizard, a draft summary and a detailed final confirmation. Content edits preserve existing buttons; advanced keyboards remain available.
+- `source` combines repository, license and build identity. Legacy `license`/`version` commands remain aliases; the project repository is always linked separately from the deployment's exact source offer.
+- Expanded help and all changes are translated into Hebrew, English, Russian, French, Spanish and Brazilian Portuguese.
+
+Migrations 001-012, Composer lock and all four library patch sets are unchanged from dev.4. No new schema migration is needed for dev.5, but the stopped-writer backup/migrate command remains part of the update procedure. See [the 11-item verification map](docs/DEV5_TEST_FEEDBACK.md) and [Docker test deployment](docs/SERVER_TEST_DEPLOYMENT.md). The supplied log proves repeated auxiliary IPC churn, not a completed reproduction of a live Telegram restart hang or the original permission error. No Docker or real Telegram acceptance is claimed.
+
+## 3.0.0-dev.4 - unpublished local development snapshot
+
+- Replace dense group configuration text with categorized button menus, current-value summaries, preset pickers and guided custom inputs in six languages.
+- Use the requested exact Hebrew welcome, expanded role-appropriate help and ordered upcoming Shabbat/festival pages with intermediate candle times, final exit, parsha and Mevarchim.
+- Add 12 animated emoji IDs, verified Unicode alternatives, native button icons/colors and guarded one-time basic-emoji fallback. Normalize user-visible em dashes without changing UTF-16 entity positions.
+- Preserve native custom-message text/entities, including empty entity arrays, media and independently editable keyboards. Add inherit/custom/none modes and default inline-share buttons for group Shabbat, daily times and Daf messages.
+- Add the default blue tefillin acknowledgement with unique per-message/user toggles, callback deduplication, durable counts and bounded coalesced keyboard edits/retries. Group managers can rename, disable or replace it.
+- Add delegated manager capabilities, view-only access, full known subscriber profiles/filter/search/paging, authorized broadcast audiences, rich drafts, edit-last, scheduling, self-destruct jobs and confirmed maintenance controls.
+- Add system deep links and HMAC-based tracking-link statistics. Navigation links do not grant access or mutate privileged settings.
+- Add custom integer Stars donations from 1 to 10,000 with payer-bound, one-use invoice flows; preserve existing invoices and charges in migration 011.
+- Add migrations 009-012 without editing migrations 001-008. Existing test databases require stopped-writer backup/migration.
+- Keep MadelineProto exactly 8.7.0 and Composer lock unchanged. Extend the separately reviewed BroadcastManager 3.2.2 patch with an optional per-instance native-message transport for emoji fallback; bundle pristine source, previous reviewed output and an exact guarded transition diff.
+- Preserve dev.3 calendar corrections and continuous holy-period/permission restoration logic. Actual Docker build, remote CI and live Telegram acceptance remain unperformed. No commit, tag or publication was created.
+
+## 3.0.0-dev.3 โ unreleased
+
+### Fixed
+
+* Complete Hebcal solar replies include empty elevation-only seaLevel series and sparse molad/Kiddush Levanah fields. They are not daily schedules. An explicit supported daily-field contract prevents these and unknown extensions from invalidating or influencing operational times.
+* Sunset, present supported daily coverage, exact dates/IANA offsets, preceding-night midnight and selected operational nightfall validation remain enforced; missing required data still stops permission changes.
+* Full unprojected public fixtures now cover the exact default Jerusalem response. The earlier dev.2 seven-series projection did not cover that contract.
+
+### Added
+
+* Credential-free read-only calendar diagnostics through the real HTTP/provider/service path, with safe stage/reason codes.
+* Full-response service/UI tests for daily times, Shabbat/festivals, tefillin and Omer, including valid out-of-season behavior.
+
+Locked dependencies, four vendor patches and eight migrations remain unchanged. No production changes, commits, tags or publication.
+
+## 3.0.0-dev.2 โ unreleased
+
+### Fixed
+
+* Accept verified preceding-night solar midnight without weakening ordinary solar date or IANA offset validation; regression captures include Jerusalem winter, summer and daylight-saving transitions.
+* Recognize bare inline city names and retain explicit provider/configuration errors.
+* Edit the originating private callback panel instead of sending new menu messages, including save, information, statistics and rich-preview navigation.
+
+### Changed
+
+* Simplified location setup hides internal workflow tokens behind city/ID/coordinates/timezone/GPS prompts and named-context confirmation.
+* Organized six-language information categories, emoji command descriptions, original-style statistics and richer group-specific Shabbat/Daf/start presentation.
+* Consistent primary, neutral and danger button colors; custom message styles remain supported.
+
+Dependencies, four reviewed library patches and eight migration files remain unchanged. Real Telegram, Docker and remote CI acceptance remain pending; no production change or publication is performed.
+
+## 3.0.0-dev.1 โ unreleased
+
+### Fixed
+
+* Docker now gives the unprivileged bot user ownership of /app itself. PrimeModule temporary Python fallback files and MadelineProto 8.7.0's IPC bootstrap logger can be created without changing locked dependencies or running the bot as root.
+* An offline permission check exercises the locked PrimeModule source copies, worker logger and data/session paths. The image build runs it after USER shabbat; CI and release preparation repeat it with fresh runtime volumes.
+* PHP exception argument collection is disabled globally in Docker, including IPC workers, and at main Runner startup. This prevents the token argument exposure observed in MadelineProto TL traces; arbitrary exception messages still require private handling.
+
+## 3.0.0-dev โ unreleased
+
+### Added
+
+* All 22 locked native member-restriction flags, default 21 including reactions/member-tag editing, and read-only bot administrator-right diagnostics.
+* Separate opt-in holy-period cleanup for members and other bots, using authenticated incoming updates and a durable deadline/lease/retry queue.
+* Delete previous tefillin reminder only after a confirmed replacement; durable successful-message pointers and independent crash-replayable cleanup bookkeeping.
+* Group-specific rich Shabbat/festival announcements, all intermediate candle times, Hebrew/civil dates, parasha/Mevarchim/provider metadata and seven local-time presets plus custom time.
+* Independent announcement rich templates, preserved through migration 008, and a bundled test-server ZIP with an isolated setup/rehearsal guide.
+
+* Explicit UTC clock and shared calendar/solar services, per-group SQLite settings and revision-bound workflows.
+* Complete he/en/ru/fr/es/pt-br catalogs for personal/group panels, automatic notices and scoped native command descriptions.
+* GeoNames city selection with private confirmation, IANA timezone support and separate personal preferences.
+* Opt-in daily zmanim, Daf Yomi, tefillin and Omer planning; bundled licensed texts for all 49 Omer counts.
+* Permission snapshots and outbox jobs with recovery, leases, stable MTProto random IDs and execution cutoffs.
+* Additive automatic registration from `data/DBgroups.txt`; existing groups can use `/settings` without registering again.
+* Maintenance import, consistent SQLite backup/restore, self-check, dependency evidence and local release packaging.
+* Full-source patch verification for MadelineProto 8.7.0, including deliberate cancellation and shutdown tests.
+* A separately reviewed BroadcastManager 3.2.2 presentation hook for six-language live status/counters and native colored controls, preserving upstream defaults and notices.
+
+### Changed
+
+* Application licensing to AGPL-3.0-only; original MIT notices retained and historical grants preserved.
+* MadelineProto is constrained to exactly 8.7.0; its unchanged 83-package lock prevents accidental dependency selection. This upstream tag is a pre-release, and upgrades require explicit source/patch review. BroadcastManager wildcard replaced by a tested 3.2 patch range.
+* SQLite application data is separate from the MadelineProto session. Numbered migrations include administrative drafts, bound Stars accounting, temporary manual overrides and six-language preservation.
+* Operator menus retain BroadcastManager capabilities and use the configured session/data paths.
+
+### Fixed
+
+* Shared calendar engine retains ongoing events rather than discarding passed entry times.
+* Adjacent prohibited days are evaluated as continuous intervals; operational offsets are separate from displayed candle/havdalah times.
+* Exact 8.5-degree nightfall and explicit minute choices are retained without integer truncation or arbitrary minute adjustments.
+* Native integer peer IDs are handled in private GPS and payment updates; bot-owned GPS reply tokens remain scoped to their user/group.
+* A separate exact-source MadelineProto GeoPoint correction preserves fractional coordinates before inline dispatch.
+* A new reviewed formatted-date correction completes the locked entity factory and adds a typed native/Bot API wrapper. It preserves timestamps, UTF-16 offsets and valid flags, with strict two-file/schema guards; historical reference code with unresolved grants is not redistributed. See [review](docs/MADELINE_FORMATTED_DATE.md).
+* Permission FLOOD_WAIT deadlines survive restart, stale/expired owners cannot confirm mutations, and desired state is refreshed before RPCs.
+* Content waits for bot-owned permission recovery; Omer waits its configured gap after the actual restore. Eligibility deferrals do not exhaust transport retries.
+* Explicit group rejoining preserves settings, while startup list import cannot reactivate removed groups. Startup schema validation rejects missing or changed migrations without writing.
+
+### Security
+
+* Settings confirmation tokens bind user, group, revision, action and expiration; public location text cannot write settings.
+* Durable snapshots precede permission mutation. Manual conflicts stop automatic restoration.
+* Local vendor patches fail closed on unknown, partial or incompatible sources.
+
+### Breaking changes and migration
+
+This is a development version. Stop the old writer, take and verify backups, migrate an isolated copy, and rehearse rollback before production. New modules stay disabled for imported groups. Automatic list registration does not import active-lock snapshots; the explicit importer must run before replacing an old bot that owns locks. The old session is never deleted or converted. See [migration](docs/MIGRATION.md).
+
+## Earlier releases
+
+The repository contains historical tags through `v2.0.3`. Historical release descriptions and dates have not been reconstructed or invented; consult the existing Git history.
diff --git a/Dockerfile b/Dockerfile
index 8962bc9..9c2c083 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,5 +1,42 @@
-FROM hub.madelineproto.xyz/danog/madelineproto
-
+FROM composer:2.10.3@sha256:af98f42dfff7c68ba8d53c2164fd9fde1087b7d449514baa38c418b1f6bc4bac AS composer
+FROM php:8.4.19-cli-bookworm@sha256:3ddb5a91b44a1c922538576d73a6e808fb2438d9d2d65b1fc3ffd55619fac2e3
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends ca-certificates tzdata libsqlite3-dev libonig-dev libxml2-dev libzip-dev unzip curl build-essential pkg-config \
+ && curl --fail --location --proto '=https' --tlsv1.2 https://www.sqlite.org/2026/sqlite-autoconf-3510300.tar.gz -o /tmp/sqlite.tar.gz \
+ && echo '81f5be397049b0cae1b167f2225af7646fc0f82e4a9b3c48c9ea3a533e21d77a /tmp/sqlite.tar.gz' | sha256sum -c - \
+ && tar -xzf /tmp/sqlite.tar.gz -C /tmp \
+ && cd /tmp/sqlite-autoconf-3510300 \
+ && ./configure --prefix=/usr/local --enable-shared --disable-static \
+ && make -j"$(nproc)" && make install && ldconfig \
+ && docker-php-ext-install pdo_sqlite mbstring dom xml zip pcntl sockets \
+ && rm -rf /var/lib/apt/lists/* /tmp/sqlite.tar.gz /tmp/sqlite-autoconf-3510300 \
+ && groupadd --gid 10001 shabbat \
+ && useradd --uid 10001 --gid shabbat --create-home shabbat
+COPY --from=composer /usr/bin/composer /usr/local/bin/composer
+COPY resources/php/99-shabbat-runtime.ini /usr/local/etc/php/conf.d/99-shabbat-runtime.ini
WORKDIR /app
-
-CMD ["php", "/app/bot.php"]
+COPY composer.json composer.lock ./
+COPY patches ./patches
+COPY scripts ./scripts
+COPY src/Runtime/AddedFilePatchManager.php ./src/Runtime/AddedFilePatchManager.php
+COPY src/Runtime/PatchManager.php ./src/Runtime/PatchManager.php
+COPY src/Runtime/PatchSet.php ./src/Runtime/PatchSet.php
+COPY src/Runtime/SingleInstance.php ./src/Runtime/SingleInstance.php
+COPY src/Runtime/RuntimePaths.php ./src/Runtime/RuntimePaths.php
+RUN composer install --no-dev --no-scripts --no-interaction --prefer-dist --optimize-autoloader \
+ && php scripts/apply-patches.php \
+ && php scripts/verify-patches.php
+COPY --chown=shabbat:shabbat . .
+ARG BUILD_COMMIT=
+RUN php bin/build-metadata.php --commit="$BUILD_COMMIT" \
+ && mkdir -p /app/data /app/runtime \
+ && chown shabbat:shabbat /app \
+ && chown -R shabbat:shabbat /app/data /app/runtime \
+ && php bin/self-check.php --release
+ENV DATA_DIR=/app/data SESSION_PATH=/app/runtime/bot.shabbat
+USER shabbat
+# PrimeModule fallback copies and the IPC bootstrap logger use the working directory.
+# Exercise these filesystem requirements as the actual bot user before startup.
+RUN php bin/runtime-permissions.php --require-non-root
+VOLUME ["/app/data", "/app/runtime"]
+CMD ["php", "/app/bot.php"]
\ No newline at end of file
diff --git a/LICENSE b/LICENSE
index 383ac8f..dba13ed 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,21 +1,661 @@
-MIT License
-
-Copyright (c) 2024 WizardLoop
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU Affero General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+.
diff --git a/LICENSES/CC-BY-4.0.txt b/LICENSES/CC-BY-4.0.txt
new file mode 100644
index 0000000..da6ab6c
--- /dev/null
+++ b/LICENSES/CC-BY-4.0.txt
@@ -0,0 +1,396 @@
+Attribution 4.0 International
+
+=======================================================================
+
+Creative Commons Corporation ("Creative Commons") is not a law firm and
+does not provide legal services or legal advice. Distribution of
+Creative Commons public licenses does not create a lawyer-client or
+other relationship. Creative Commons makes its licenses and related
+information available on an "as-is" basis. Creative Commons gives no
+warranties regarding its licenses, any material licensed under their
+terms and conditions, or any related information. Creative Commons
+disclaims all liability for damages resulting from their use to the
+fullest extent possible.
+
+Using Creative Commons Public Licenses
+
+Creative Commons public licenses provide a standard set of terms and
+conditions that creators and other rights holders may use to share
+original works of authorship and other material subject to copyright
+and certain other rights specified in the public license below. The
+following considerations are for informational purposes only, are not
+exhaustive, and do not form part of our licenses.
+
+ Considerations for licensors: Our public licenses are
+ intended for use by those authorized to give the public
+ permission to use material in ways otherwise restricted by
+ copyright and certain other rights. Our licenses are
+ irrevocable. Licensors should read and understand the terms
+ and conditions of the license they choose before applying it.
+ Licensors should also secure all rights necessary before
+ applying our licenses so that the public can reuse the
+ material as expected. Licensors should clearly mark any
+ material not subject to the license. This includes other CC-
+ licensed material, or material used under an exception or
+ limitation to copyright. More considerations for licensors:
+ wiki.creativecommons.org/Considerations_for_licensors
+
+ Considerations for the public: By using one of our public
+ licenses, a licensor grants the public permission to use the
+ licensed material under specified terms and conditions. If
+ the licensor's permission is not necessary for any reason--for
+ example, because of any applicable exception or limitation to
+ copyright--then that use is not regulated by the license. Our
+ licenses grant only permissions under copyright and certain
+ other rights that a licensor has authority to grant. Use of
+ the licensed material may still be restricted for other
+ reasons, including because others have copyright or other
+ rights in the material. A licensor may make special requests,
+ such as asking that all changes be marked or described.
+ Although not required by our licenses, you are encouraged to
+ respect those requests where reasonable. More considerations
+ for the public:
+ wiki.creativecommons.org/Considerations_for_licensees
+
+=======================================================================
+
+Creative Commons Attribution 4.0 International Public License
+
+By exercising the Licensed Rights (defined below), You accept and agree
+to be bound by the terms and conditions of this Creative Commons
+Attribution 4.0 International Public License ("Public License"). To the
+extent this Public License may be interpreted as a contract, You are
+granted the Licensed Rights in consideration of Your acceptance of
+these terms and conditions, and the Licensor grants You such rights in
+consideration of benefits the Licensor receives from making the
+Licensed Material available under these terms and conditions.
+
+
+Section 1 -- Definitions.
+
+ a. Adapted Material means material subject to Copyright and Similar
+ Rights that is derived from or based upon the Licensed Material
+ and in which the Licensed Material is translated, altered,
+ arranged, transformed, or otherwise modified in a manner requiring
+ permission under the Copyright and Similar Rights held by the
+ Licensor. For purposes of this Public License, where the Licensed
+ Material is a musical work, performance, or sound recording,
+ Adapted Material is always produced where the Licensed Material is
+ synched in timed relation with a moving image.
+
+ b. Adapter's License means the license You apply to Your Copyright
+ and Similar Rights in Your contributions to Adapted Material in
+ accordance with the terms and conditions of this Public License.
+
+ c. Copyright and Similar Rights means copyright and/or similar rights
+ closely related to copyright including, without limitation,
+ performance, broadcast, sound recording, and Sui Generis Database
+ Rights, without regard to how the rights are labeled or
+ categorized. For purposes of this Public License, the rights
+ specified in Section 2(b)(1)-(2) are not Copyright and Similar
+ Rights.
+
+ d. Effective Technological Measures means those measures that, in the
+ absence of proper authority, may not be circumvented under laws
+ fulfilling obligations under Article 11 of the WIPO Copyright
+ Treaty adopted on December 20, 1996, and/or similar international
+ agreements.
+
+ e. Exceptions and Limitations means fair use, fair dealing, and/or
+ any other exception or limitation to Copyright and Similar Rights
+ that applies to Your use of the Licensed Material.
+
+ f. Licensed Material means the artistic or literary work, database,
+ or other material to which the Licensor applied this Public
+ License.
+
+ g. Licensed Rights means the rights granted to You subject to the
+ terms and conditions of this Public License, which are limited to
+ all Copyright and Similar Rights that apply to Your use of the
+ Licensed Material and that the Licensor has authority to license.
+
+ h. Licensor means the individual(s) or entity(ies) granting rights
+ under this Public License.
+
+ i. Share means to provide material to the public by any means or
+ process that requires permission under the Licensed Rights, such
+ as reproduction, public display, public performance, distribution,
+ dissemination, communication, or importation, and to make material
+ available to the public including in ways that members of the
+ public may access the material from a place and at a time
+ individually chosen by them.
+
+ j. Sui Generis Database Rights means rights other than copyright
+ resulting from Directive 96/9/EC of the European Parliament and of
+ the Council of 11 March 1996 on the legal protection of databases,
+ as amended and/or succeeded, as well as other essentially
+ equivalent rights anywhere in the world.
+
+ k. You means the individual or entity exercising the Licensed Rights
+ under this Public License. Your has a corresponding meaning.
+
+
+Section 2 -- Scope.
+
+ a. License grant.
+
+ 1. Subject to the terms and conditions of this Public License,
+ the Licensor hereby grants You a worldwide, royalty-free,
+ non-sublicensable, non-exclusive, irrevocable license to
+ exercise the Licensed Rights in the Licensed Material to:
+
+ a. reproduce and Share the Licensed Material, in whole or
+ in part; and
+
+ b. produce, reproduce, and Share Adapted Material.
+
+ 2. Exceptions and Limitations. For the avoidance of doubt, where
+ Exceptions and Limitations apply to Your use, this Public
+ License does not apply, and You do not need to comply with
+ its terms and conditions.
+
+ 3. Term. The term of this Public License is specified in Section
+ 6(a).
+
+ 4. Media and formats; technical modifications allowed. The
+ Licensor authorizes You to exercise the Licensed Rights in
+ all media and formats whether now known or hereafter created,
+ and to make technical modifications necessary to do so. The
+ Licensor waives and/or agrees not to assert any right or
+ authority to forbid You from making technical modifications
+ necessary to exercise the Licensed Rights, including
+ technical modifications necessary to circumvent Effective
+ Technological Measures. For purposes of this Public License,
+ simply making modifications authorized by this Section 2(a)
+ (4) never produces Adapted Material.
+
+ 5. Downstream recipients.
+
+ a. Offer from the Licensor -- Licensed Material. Every
+ recipient of the Licensed Material automatically
+ receives an offer from the Licensor to exercise the
+ Licensed Rights under the terms and conditions of this
+ Public License.
+
+ b. No downstream restrictions. You may not offer or impose
+ any additional or different terms or conditions on, or
+ apply any Effective Technological Measures to, the
+ Licensed Material if doing so restricts exercise of the
+ Licensed Rights by any recipient of the Licensed
+ Material.
+
+ 6. No endorsement. Nothing in this Public License constitutes or
+ may be construed as permission to assert or imply that You
+ are, or that Your use of the Licensed Material is, connected
+ with, or sponsored, endorsed, or granted official status by,
+ the Licensor or others designated to receive attribution as
+ provided in Section 3(a)(1)(A)(i).
+
+ b. Other rights.
+
+ 1. Moral rights, such as the right of integrity, are not
+ licensed under this Public License, nor are publicity,
+ privacy, and/or other similar personality rights; however, to
+ the extent possible, the Licensor waives and/or agrees not to
+ assert any such rights held by the Licensor to the limited
+ extent necessary to allow You to exercise the Licensed
+ Rights, but not otherwise.
+
+ 2. Patent and trademark rights are not licensed under this
+ Public License.
+
+ 3. To the extent possible, the Licensor waives any right to
+ collect royalties from You for the exercise of the Licensed
+ Rights, whether directly or through a collecting society
+ under any voluntary or waivable statutory or compulsory
+ licensing scheme. In all other cases the Licensor expressly
+ reserves any right to collect such royalties.
+
+
+Section 3 -- License Conditions.
+
+Your exercise of the Licensed Rights is expressly made subject to the
+following conditions.
+
+ a. Attribution.
+
+ 1. If You Share the Licensed Material (including in modified
+ form), You must:
+
+ a. retain the following if it is supplied by the Licensor
+ with the Licensed Material:
+
+ i. identification of the creator(s) of the Licensed
+ Material and any others designated to receive
+ attribution, in any reasonable manner requested by
+ the Licensor (including by pseudonym if
+ designated);
+
+ ii. a copyright notice;
+
+ iii. a notice that refers to this Public License;
+
+ iv. a notice that refers to the disclaimer of
+ warranties;
+
+ v. a URI or hyperlink to the Licensed Material to the
+ extent reasonably practicable;
+
+ b. indicate if You modified the Licensed Material and
+ retain an indication of any previous modifications; and
+
+ c. indicate the Licensed Material is licensed under this
+ Public License, and include the text of, or the URI or
+ hyperlink to, this Public License.
+
+ 2. You may satisfy the conditions in Section 3(a)(1) in any
+ reasonable manner based on the medium, means, and context in
+ which You Share the Licensed Material. For example, it may be
+ reasonable to satisfy the conditions by providing a URI or
+ hyperlink to a resource that includes the required
+ information.
+
+ 3. If requested by the Licensor, You must remove any of the
+ information required by Section 3(a)(1)(A) to the extent
+ reasonably practicable.
+
+ 4. If You Share Adapted Material You produce, the Adapter's
+ License You apply must not prevent recipients of the Adapted
+ Material from complying with this Public License.
+
+
+Section 4 -- Sui Generis Database Rights.
+
+Where the Licensed Rights include Sui Generis Database Rights that
+apply to Your use of the Licensed Material:
+
+ a. for the avoidance of doubt, Section 2(a)(1) grants You the right
+ to extract, reuse, reproduce, and Share all or a substantial
+ portion of the contents of the database;
+
+ b. if You include all or a substantial portion of the database
+ contents in a database in which You have Sui Generis Database
+ Rights, then the database in which You have Sui Generis Database
+ Rights (but not its individual contents) is Adapted Material; and
+
+ c. You must comply with the conditions in Section 3(a) if You Share
+ all or a substantial portion of the contents of the database.
+
+For the avoidance of doubt, this Section 4 supplements and does not
+replace Your obligations under this Public License where the Licensed
+Rights include other Copyright and Similar Rights.
+
+
+Section 5 -- Disclaimer of Warranties and Limitation of Liability.
+
+ a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
+ EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
+ AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
+ ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
+ IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
+ WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
+ PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
+ ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
+ KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
+ ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
+
+ b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
+ TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
+ NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
+ INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
+ COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
+ USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
+ ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
+ DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR
+ IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
+
+ c. The disclaimer of warranties and limitation of liability provided
+ above shall be interpreted in a manner that, to the extent
+ possible, most closely approximates an absolute disclaimer and
+ waiver of all liability.
+
+
+Section 6 -- Term and Termination.
+
+ a. This Public License applies for the term of the Copyright and
+ Similar Rights licensed here. However, if You fail to comply with
+ this Public License, then Your rights under this Public License
+ terminate automatically.
+
+ b. Where Your right to use the Licensed Material has terminated under
+ Section 6(a), it reinstates:
+
+ 1. automatically as of the date the violation is cured, provided
+ it is cured within 30 days of Your discovery of the
+ violation; or
+
+ 2. upon express reinstatement by the Licensor.
+
+ For the avoidance of doubt, this Section 6(b) does not affect any
+ right the Licensor may have to seek remedies for Your violations
+ of this Public License.
+
+ c. For the avoidance of doubt, the Licensor may also offer the
+ Licensed Material under separate terms or conditions or stop
+ distributing the Licensed Material at any time; however, doing so
+ will not terminate this Public License.
+
+ d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
+ License.
+
+
+Section 7 -- Other Terms and Conditions.
+
+ a. The Licensor shall not be bound by any additional or different
+ terms or conditions communicated by You unless expressly agreed.
+
+ b. Any arrangements, understandings, or agreements regarding the
+ Licensed Material not stated herein are separate from and
+ independent of the terms and conditions of this Public License.
+
+
+Section 8 -- Interpretation.
+
+ a. For the avoidance of doubt, this Public License does not, and
+ shall not be interpreted to, reduce, limit, restrict, or impose
+ conditions on any use of the Licensed Material that could lawfully
+ be made without permission under this Public License.
+
+ b. To the extent possible, if any provision of this Public License is
+ deemed unenforceable, it shall be automatically reformed to the
+ minimum extent necessary to make it enforceable. If the provision
+ cannot be reformed, it shall be severed from this Public License
+ without affecting the enforceability of the remaining terms and
+ conditions.
+
+ c. No term or condition of this Public License will be waived and no
+ failure to comply consented to unless expressly agreed to by the
+ Licensor.
+
+ d. Nothing in this Public License constitutes or may be interpreted
+ as a limitation upon, or waiver of, any privileges and immunities
+ that apply to the Licensor or You, including from the legal
+ processes of any jurisdiction or authority.
+
+
+=======================================================================
+
+Creative Commons is not a party to its public
+licenses. Notwithstanding, Creative Commons may elect to apply one of
+its public licenses to material it publishes and in those instances
+will be considered the โLicensor.โ The text of the Creative Commons
+public licenses is dedicated to the public domain under the CC0 Public
+Domain Dedication. Except for the limited purpose of indicating that
+material is shared under a Creative Commons public license or as
+otherwise permitted by the Creative Commons policies published at
+creativecommons.org/policies, Creative Commons does not authorize the
+use of the trademark "Creative Commons" or any other trademark or logo
+of Creative Commons without its prior written consent including,
+without limitation, in connection with any unauthorized modifications
+to any of its public licenses or any other arrangements,
+understandings, or agreements concerning use of licensed material. For
+the avoidance of doubt, this paragraph does not form part of the
+public licenses.
+
+Creative Commons may be contacted at creativecommons.org.
+
diff --git a/LICENSES/CC-BY-SA-4.0.txt b/LICENSES/CC-BY-SA-4.0.txt
new file mode 100644
index 0000000..2d58298
--- /dev/null
+++ b/LICENSES/CC-BY-SA-4.0.txt
@@ -0,0 +1,428 @@
+Attribution-ShareAlike 4.0 International
+
+=======================================================================
+
+Creative Commons Corporation ("Creative Commons") is not a law firm and
+does not provide legal services or legal advice. Distribution of
+Creative Commons public licenses does not create a lawyer-client or
+other relationship. Creative Commons makes its licenses and related
+information available on an "as-is" basis. Creative Commons gives no
+warranties regarding its licenses, any material licensed under their
+terms and conditions, or any related information. Creative Commons
+disclaims all liability for damages resulting from their use to the
+fullest extent possible.
+
+Using Creative Commons Public Licenses
+
+Creative Commons public licenses provide a standard set of terms and
+conditions that creators and other rights holders may use to share
+original works of authorship and other material subject to copyright
+and certain other rights specified in the public license below. The
+following considerations are for informational purposes only, are not
+exhaustive, and do not form part of our licenses.
+
+ Considerations for licensors: Our public licenses are
+ intended for use by those authorized to give the public
+ permission to use material in ways otherwise restricted by
+ copyright and certain other rights. Our licenses are
+ irrevocable. Licensors should read and understand the terms
+ and conditions of the license they choose before applying it.
+ Licensors should also secure all rights necessary before
+ applying our licenses so that the public can reuse the
+ material as expected. Licensors should clearly mark any
+ material not subject to the license. This includes other CC-
+ licensed material, or material used under an exception or
+ limitation to copyright. More considerations for licensors:
+ wiki.creativecommons.org/Considerations_for_licensors
+
+ Considerations for the public: By using one of our public
+ licenses, a licensor grants the public permission to use the
+ licensed material under specified terms and conditions. If
+ the licensor's permission is not necessary for any reason--for
+ example, because of any applicable exception or limitation to
+ copyright--then that use is not regulated by the license. Our
+ licenses grant only permissions under copyright and certain
+ other rights that a licensor has authority to grant. Use of
+ the licensed material may still be restricted for other
+ reasons, including because others have copyright or other
+ rights in the material. A licensor may make special requests,
+ such as asking that all changes be marked or described.
+ Although not required by our licenses, you are encouraged to
+ respect those requests where reasonable. More considerations
+ for the public:
+ wiki.creativecommons.org/Considerations_for_licensees
+
+=======================================================================
+
+Creative Commons Attribution-ShareAlike 4.0 International Public
+License
+
+By exercising the Licensed Rights (defined below), You accept and agree
+to be bound by the terms and conditions of this Creative Commons
+Attribution-ShareAlike 4.0 International Public License ("Public
+License"). To the extent this Public License may be interpreted as a
+contract, You are granted the Licensed Rights in consideration of Your
+acceptance of these terms and conditions, and the Licensor grants You
+such rights in consideration of benefits the Licensor receives from
+making the Licensed Material available under these terms and
+conditions.
+
+
+Section 1 -- Definitions.
+
+ a. Adapted Material means material subject to Copyright and Similar
+ Rights that is derived from or based upon the Licensed Material
+ and in which the Licensed Material is translated, altered,
+ arranged, transformed, or otherwise modified in a manner requiring
+ permission under the Copyright and Similar Rights held by the
+ Licensor. For purposes of this Public License, where the Licensed
+ Material is a musical work, performance, or sound recording,
+ Adapted Material is always produced where the Licensed Material is
+ synched in timed relation with a moving image.
+
+ b. Adapter's License means the license You apply to Your Copyright
+ and Similar Rights in Your contributions to Adapted Material in
+ accordance with the terms and conditions of this Public License.
+
+ c. BY-SA Compatible License means a license listed at
+ creativecommons.org/compatiblelicenses, approved by Creative
+ Commons as essentially the equivalent of this Public License.
+
+ d. Copyright and Similar Rights means copyright and/or similar rights
+ closely related to copyright including, without limitation,
+ performance, broadcast, sound recording, and Sui Generis Database
+ Rights, without regard to how the rights are labeled or
+ categorized. For purposes of this Public License, the rights
+ specified in Section 2(b)(1)-(2) are not Copyright and Similar
+ Rights.
+
+ e. Effective Technological Measures means those measures that, in the
+ absence of proper authority, may not be circumvented under laws
+ fulfilling obligations under Article 11 of the WIPO Copyright
+ Treaty adopted on December 20, 1996, and/or similar international
+ agreements.
+
+ f. Exceptions and Limitations means fair use, fair dealing, and/or
+ any other exception or limitation to Copyright and Similar Rights
+ that applies to Your use of the Licensed Material.
+
+ g. License Elements means the license attributes listed in the name
+ of a Creative Commons Public License. The License Elements of this
+ Public License are Attribution and ShareAlike.
+
+ h. Licensed Material means the artistic or literary work, database,
+ or other material to which the Licensor applied this Public
+ License.
+
+ i. Licensed Rights means the rights granted to You subject to the
+ terms and conditions of this Public License, which are limited to
+ all Copyright and Similar Rights that apply to Your use of the
+ Licensed Material and that the Licensor has authority to license.
+
+ j. Licensor means the individual(s) or entity(ies) granting rights
+ under this Public License.
+
+ k. Share means to provide material to the public by any means or
+ process that requires permission under the Licensed Rights, such
+ as reproduction, public display, public performance, distribution,
+ dissemination, communication, or importation, and to make material
+ available to the public including in ways that members of the
+ public may access the material from a place and at a time
+ individually chosen by them.
+
+ l. Sui Generis Database Rights means rights other than copyright
+ resulting from Directive 96/9/EC of the European Parliament and of
+ the Council of 11 March 1996 on the legal protection of databases,
+ as amended and/or succeeded, as well as other essentially
+ equivalent rights anywhere in the world.
+
+ m. You means the individual or entity exercising the Licensed Rights
+ under this Public License. Your has a corresponding meaning.
+
+
+Section 2 -- Scope.
+
+ a. License grant.
+
+ 1. Subject to the terms and conditions of this Public License,
+ the Licensor hereby grants You a worldwide, royalty-free,
+ non-sublicensable, non-exclusive, irrevocable license to
+ exercise the Licensed Rights in the Licensed Material to:
+
+ a. reproduce and Share the Licensed Material, in whole or
+ in part; and
+
+ b. produce, reproduce, and Share Adapted Material.
+
+ 2. Exceptions and Limitations. For the avoidance of doubt, where
+ Exceptions and Limitations apply to Your use, this Public
+ License does not apply, and You do not need to comply with
+ its terms and conditions.
+
+ 3. Term. The term of this Public License is specified in Section
+ 6(a).
+
+ 4. Media and formats; technical modifications allowed. The
+ Licensor authorizes You to exercise the Licensed Rights in
+ all media and formats whether now known or hereafter created,
+ and to make technical modifications necessary to do so. The
+ Licensor waives and/or agrees not to assert any right or
+ authority to forbid You from making technical modifications
+ necessary to exercise the Licensed Rights, including
+ technical modifications necessary to circumvent Effective
+ Technological Measures. For purposes of this Public License,
+ simply making modifications authorized by this Section 2(a)
+ (4) never produces Adapted Material.
+
+ 5. Downstream recipients.
+
+ a. Offer from the Licensor -- Licensed Material. Every
+ recipient of the Licensed Material automatically
+ receives an offer from the Licensor to exercise the
+ Licensed Rights under the terms and conditions of this
+ Public License.
+
+ b. Additional offer from the Licensor -- Adapted Material.
+ Every recipient of Adapted Material from You
+ automatically receives an offer from the Licensor to
+ exercise the Licensed Rights in the Adapted Material
+ under the conditions of the Adapter's License You apply.
+
+ c. No downstream restrictions. You may not offer or impose
+ any additional or different terms or conditions on, or
+ apply any Effective Technological Measures to, the
+ Licensed Material if doing so restricts exercise of the
+ Licensed Rights by any recipient of the Licensed
+ Material.
+
+ 6. No endorsement. Nothing in this Public License constitutes or
+ may be construed as permission to assert or imply that You
+ are, or that Your use of the Licensed Material is, connected
+ with, or sponsored, endorsed, or granted official status by,
+ the Licensor or others designated to receive attribution as
+ provided in Section 3(a)(1)(A)(i).
+
+ b. Other rights.
+
+ 1. Moral rights, such as the right of integrity, are not
+ licensed under this Public License, nor are publicity,
+ privacy, and/or other similar personality rights; however, to
+ the extent possible, the Licensor waives and/or agrees not to
+ assert any such rights held by the Licensor to the limited
+ extent necessary to allow You to exercise the Licensed
+ Rights, but not otherwise.
+
+ 2. Patent and trademark rights are not licensed under this
+ Public License.
+
+ 3. To the extent possible, the Licensor waives any right to
+ collect royalties from You for the exercise of the Licensed
+ Rights, whether directly or through a collecting society
+ under any voluntary or waivable statutory or compulsory
+ licensing scheme. In all other cases the Licensor expressly
+ reserves any right to collect such royalties.
+
+
+Section 3 -- License Conditions.
+
+Your exercise of the Licensed Rights is expressly made subject to the
+following conditions.
+
+ a. Attribution.
+
+ 1. If You Share the Licensed Material (including in modified
+ form), You must:
+
+ a. retain the following if it is supplied by the Licensor
+ with the Licensed Material:
+
+ i. identification of the creator(s) of the Licensed
+ Material and any others designated to receive
+ attribution, in any reasonable manner requested by
+ the Licensor (including by pseudonym if
+ designated);
+
+ ii. a copyright notice;
+
+ iii. a notice that refers to this Public License;
+
+ iv. a notice that refers to the disclaimer of
+ warranties;
+
+ v. a URI or hyperlink to the Licensed Material to the
+ extent reasonably practicable;
+
+ b. indicate if You modified the Licensed Material and
+ retain an indication of any previous modifications; and
+
+ c. indicate the Licensed Material is licensed under this
+ Public License, and include the text of, or the URI or
+ hyperlink to, this Public License.
+
+ 2. You may satisfy the conditions in Section 3(a)(1) in any
+ reasonable manner based on the medium, means, and context in
+ which You Share the Licensed Material. For example, it may be
+ reasonable to satisfy the conditions by providing a URI or
+ hyperlink to a resource that includes the required
+ information.
+
+ 3. If requested by the Licensor, You must remove any of the
+ information required by Section 3(a)(1)(A) to the extent
+ reasonably practicable.
+
+ b. ShareAlike.
+
+ In addition to the conditions in Section 3(a), if You Share
+ Adapted Material You produce, the following conditions also apply.
+
+ 1. The Adapter's License You apply must be a Creative Commons
+ license with the same License Elements, this version or
+ later, or a BY-SA Compatible License.
+
+ 2. You must include the text of, or the URI or hyperlink to, the
+ Adapter's License You apply. You may satisfy this condition
+ in any reasonable manner based on the medium, means, and
+ context in which You Share Adapted Material.
+
+ 3. You may not offer or impose any additional or different terms
+ or conditions on, or apply any Effective Technological
+ Measures to, Adapted Material that restrict exercise of the
+ rights granted under the Adapter's License You apply.
+
+
+Section 4 -- Sui Generis Database Rights.
+
+Where the Licensed Rights include Sui Generis Database Rights that
+apply to Your use of the Licensed Material:
+
+ a. for the avoidance of doubt, Section 2(a)(1) grants You the right
+ to extract, reuse, reproduce, and Share all or a substantial
+ portion of the contents of the database;
+
+ b. if You include all or a substantial portion of the database
+ contents in a database in which You have Sui Generis Database
+ Rights, then the database in which You have Sui Generis Database
+ Rights (but not its individual contents) is Adapted Material,
+ including for purposes of Section 3(b); and
+
+ c. You must comply with the conditions in Section 3(a) if You Share
+ all or a substantial portion of the contents of the database.
+
+For the avoidance of doubt, this Section 4 supplements and does not
+replace Your obligations under this Public License where the Licensed
+Rights include other Copyright and Similar Rights.
+
+
+Section 5 -- Disclaimer of Warranties and Limitation of Liability.
+
+ a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
+ EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
+ AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
+ ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
+ IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
+ WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
+ PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
+ ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
+ KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
+ ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
+
+ b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
+ TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
+ NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
+ INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
+ COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
+ USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
+ ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
+ DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR
+ IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
+
+ c. The disclaimer of warranties and limitation of liability provided
+ above shall be interpreted in a manner that, to the extent
+ possible, most closely approximates an absolute disclaimer and
+ waiver of all liability.
+
+
+Section 6 -- Term and Termination.
+
+ a. This Public License applies for the term of the Copyright and
+ Similar Rights licensed here. However, if You fail to comply with
+ this Public License, then Your rights under this Public License
+ terminate automatically.
+
+ b. Where Your right to use the Licensed Material has terminated under
+ Section 6(a), it reinstates:
+
+ 1. automatically as of the date the violation is cured, provided
+ it is cured within 30 days of Your discovery of the
+ violation; or
+
+ 2. upon express reinstatement by the Licensor.
+
+ For the avoidance of doubt, this Section 6(b) does not affect any
+ right the Licensor may have to seek remedies for Your violations
+ of this Public License.
+
+ c. For the avoidance of doubt, the Licensor may also offer the
+ Licensed Material under separate terms or conditions or stop
+ distributing the Licensed Material at any time; however, doing so
+ will not terminate this Public License.
+
+ d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
+ License.
+
+
+Section 7 -- Other Terms and Conditions.
+
+ a. The Licensor shall not be bound by any additional or different
+ terms or conditions communicated by You unless expressly agreed.
+
+ b. Any arrangements, understandings, or agreements regarding the
+ Licensed Material not stated herein are separate from and
+ independent of the terms and conditions of this Public License.
+
+
+Section 8 -- Interpretation.
+
+ a. For the avoidance of doubt, this Public License does not, and
+ shall not be interpreted to, reduce, limit, restrict, or impose
+ conditions on any use of the Licensed Material that could lawfully
+ be made without permission under this Public License.
+
+ b. To the extent possible, if any provision of this Public License is
+ deemed unenforceable, it shall be automatically reformed to the
+ minimum extent necessary to make it enforceable. If the provision
+ cannot be reformed, it shall be severed from this Public License
+ without affecting the enforceability of the remaining terms and
+ conditions.
+
+ c. No term or condition of this Public License will be waived and no
+ failure to comply consented to unless expressly agreed to by the
+ Licensor.
+
+ d. Nothing in this Public License constitutes or may be interpreted
+ as a limitation upon, or waiver of, any privileges and immunities
+ that apply to the Licensor or You, including from the legal
+ processes of any jurisdiction or authority.
+
+
+=======================================================================
+
+Creative Commons is not a party to its public
+licenses. Notwithstanding, Creative Commons may elect to apply one of
+its public licenses to material it publishes and in those instances
+will be considered the โLicensor.โ The text of the Creative Commons
+public licenses is dedicated to the public domain under the CC0 Public
+Domain Dedication. Except for the limited purpose of indicating that
+material is shared under a Creative Commons public license or as
+otherwise permitted by the Creative Commons policies published at
+creativecommons.org/policies, Creative Commons does not authorize the
+use of the trademark "Creative Commons" or any other trademark or logo
+of Creative Commons without its prior written consent including,
+without limitation, in connection with any unauthorized modifications
+to any of its public licenses or any other arrangements,
+understandings, or agreements concerning use of licensed material. For
+the avoidance of doubt, this paragraph does not form part of the
+public licenses.
+
+Creative Commons may be contacted at creativecommons.org.
+
diff --git a/LICENSES/README.md b/LICENSES/README.md
new file mode 100644
index 0000000..6dc5b5c
--- /dev/null
+++ b/LICENSES/README.md
@@ -0,0 +1,10 @@
+# Retained grants
+
+SHABBAT-LEGACY-MIT.txt retains the original WizardLoop MIT permission for historic source/contributions. The new application grant is the full root LICENSE (AGPL-3.0-only), not an alternative blanket MIT grant.
+
+CC-BY-4.0.txt and CC-BY-SA-4.0.txt are the unchanged plain legal texts downloaded on 2026-10-13 from the official Creative Commons endpoints:
+
+- https://creativecommons.org/licenses/by/4.0/legalcode.txt
+- https://creativecommons.org/licenses/by-sa/4.0/legalcode.txt
+
+Their resource scope, attribution, pinned sources and modifications are described in [third-party notices](../THIRD_PARTY_NOTICES.md) and the bundled resource metadata. Dependency grants remain in their installed source, included in the corresponding-source archive and indexed by [dependency evidence](../docs/DEPENDENCIES.md).
diff --git a/LICENSES/SHABBAT-LEGACY-MIT.txt b/LICENSES/SHABBAT-LEGACY-MIT.txt
new file mode 100644
index 0000000..de60bec
--- /dev/null
+++ b/LICENSES/SHABBAT-LEGACY-MIT.txt
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2024 WizardLoop
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index 840889f..c5d5ef1 100644
--- a/README.md
+++ b/README.md
@@ -1,33 +1,466 @@
-# โก๏ธ๐ฏ๏ธ Shabbat Bot
+# ๐ฏ๏ธ WizardLoop Shabbat Bot
-**WizardLoop Shabbat Bot** is an automated Telegram bot that changes group permissions at the entrance and exit of Shabbat, helping admins manage group activity and maintain Shabbat observance.
+A Telegram bot built with PHP, MadelineProto and SQLite for Shabbat and festival group quiet periods, Jewish calendar views and opt-in reminders.
-[](LICENSE)
-[](https://github.com/WizardLoop)
-[](https://t.me/WizardLoop)
+[English](README.md) | [ืขืืจืืช](readme-he.md)
----
+[](LICENSE)
+[](composer.json)
+[](composer.lock)
+[](VERSION)
-> โจ Let your group rest on Shabbat! Automate muting/unmuting group chats every week, hands free.
+**Current version: 3.0.0-dev.9, a prerelease.** Use the matching [release assets](https://github.com/WizardLoop/shabbat/releases/tag/v3.0.0-dev.9) or tagged checkout and retain its build metadata. Automated tests, Docker checks and live Telegram acceptance remain separate. For an existing bot, follow the [production upgrade and rollback guide](docs/PRODUCTION_UPGRADE.md) before replacing files or starting a second instance. See [publication status](docs/PUBLICATION_STATUS.md), [changes](CHANGELOG.md) and [delivery safety](docs/DEV9_DELIVERY_SAFETY.md). Badges describe source configuration, not production acceptance.
----
+## Contents
-## ๐ Features
+[Features](#features) ยท [Requirements](#requirements) ยท [Docker installation](#docker-installation) ยท [Manual installation](#manual-installation) ยท [Configuration](#configuration) ยท [Telegram setup](#telegram-setup) ยท [Group setup](#group-setup) ยท [Location and languages](#location-and-languages) ยท [Calendar modules](#calendar-modules) ยท [Bot commands](#bot-commands) ยท [Operations](#operations) ยท [Upgrade and legacy import](#upgrade-and-legacy-import) ยท [Backup and rollback](#backup-and-rollback) ยท [Troubleshooting](#troubleshooting) ยท [Project and development](#project-and-development) ยท [License and credits](#license-and-credits)
-- ๐ฏ **Automatic Shabbat Mode:** Restricts group permissions at Shabbat entrance, restores at exit
-- ๐ **Customizable Messages:** Set your own "Shabbat" and "Motzei Shabbat" messages
-- ๐ **Admin Panel:** Only admins can change settings or override
+
+## Features
----
+| Area | Implemented behavior |
+|---|---|
+| Shabbat/festivals | Per-group location, Israel/diaspora calendar, candle/havdalah methods, operational offsets and continuous adjacent holy-day closures. |
+| Modes | Off, messages only, lock only, or lock and messages; Shabbat and festivals are selected separately. |
+| Permission recovery | Original 22-flag snapshots, verified fresh reads, durable restoration state, conflicts and an authorized recovery panel. The default lock mask has 21 selectable member restrictions; administrator powers are separate. |
+| Private controls | Known groups, guided settings, independent personal preferences, preview and confirmation. |
+| Calendar/reminders | Daily zmanim, Daf Yomi, tefillin and seasonal Omer with explicit group opt-in and separate settings. |
+| Message design | Text, Telegram entities, reusable media and default/custom/no keyboards; separate close/open/announcement/module templates. Optional cleanup defaults off. |
+| Administration | Full/limited/view-only managers, subscriber directory, broadcasts, statistics, diagnostics, system links and tracking links. |
+| Storage/operation | SQLite migrations/backups, durable delivery jobs, independent scheduler heartbeats, one application writer and explicit maintenance tools. |
+| Donations/source | Optional Telegram Stars, including whole custom amounts 1โ10000; version, license and an operator-supplied exact source URL. |
-## ๐ฆ Installation
+dev.9 adds delivery-time eligibility checks, bounded reminder backlogs, actual spacing/quiet-window checks and independent permission, notification and broadcast work. MadelineProto remains 8.7.0 and the dependency lock is retained. Missing solar values are reported; no polar fallback is invented. Times and ritual texts are informational, with method choices made by the administrator.
-```bash
-git clone https://github.com/WizardLoop/shabbat.git
+
+## Requirements
+
+| Component | Requirement |
+|---|---|
+| Recommended deployment | Linux, Docker Engine and Compose v2, outbound HTTPS/Telegram access, persistent storage. |
+| Manual runtime | 64-bit PHP 8.3/8.4/8.5 and Composer 2. Docker pins PHP 8.4.19 and Composer 2.10.3. |
+| Extensions | PDO/PDO SQLite, mbstring, XML, DOM, fileinfo, zlib; ZIP for packaging. Verify all locked requirements with `composer check-platform-reqs`. Docker also builds pcntl and sockets. |
+| SQLite | Application minimum 3.35. Release gate accepts 3.51.3+, or reviewed 3.44.6 / 3.50.7. Check PHP's loaded library. Docker builds checksum-verified 3.51.3. |
+| Telegram | Your application API ID/hash, BotFather token and configured operator user IDs or resolvable usernames. |
+| GeoNames | Your webservice-enabled account for city search/automatic timezone lookup. Known cities and explicit coordinates remain available without it. |
+| Filesystem | Persistent application data and separate MadelineProto session. The runtime also needs a writable working directory for its library worker log and PrimeModule copies. |
+
+Offline tests use fake Telegram gateways and provider fixtures without bot credentials. Live staging acceptance must cover permission restoration, interruption/restart, topics, client rendering, inline/GPS, Stars and command menus.
+
+
+## Docker installation
+
+Shell examples use a Linux/POSIX terminal. Run Docker commands **on the host, from the extracted project root**, with Docker-daemon access. The image runs as `shabbat`, UID/GID `10001`. There is no separate Composer service.
+
+1. Obtain the reviewed source. For the release deployment archive:
+
+~~~sh
+unzip shabbat-3.0.0-dev.9.zip -d shabbat
cd shabbat
-composer install
+~~~
+
+It contains source and `composer.lock`, without installed `vendor/`. Docker installs locked production dependencies. **Retain archive `build-info.json`.** In this publication archive it records the verified historical baseline commit, `local_changes=true` and the exact publication-source fingerprint; the baseline is not a new commit of these changes. For a reviewed Git checkout instead, supply its actual commit before building:
+
+~~~sh
+export BUILD_COMMIT="$(git rev-parse HEAD)"
+~~~
+
+A baseline commit alone does not identify uncommitted changes; the source fingerprint also identifies this snapshot. Do not substitute an unrelated commit.
+
+2. Create private configuration once in this new installation:
+
+~~~sh
cp .env.example .env
-# Fill your API credentials and settings in .env
+chmod 600 .env
+~~~
+
+Edit `.env`: replace Telegram examples, set `ADMIN`, your GeoNames account when needed, and `SOURCE_URL` to the exact corresponding source you make available. Keep simple `KEY=value` syntax compatible with PHP raw INI and Compose. Exclude the configured file from uploads.
+
+3. Build and check the image. These steps install/patch dependencies and write image metadata without starting the bot:
+
+~~~sh
+docker compose build
+docker compose run --rm --no-deps shabbat php bin/self-check.php --release
+docker compose run --rm --no-deps shabbat php bin/runtime-permissions.php --require-non-root
+~~~
+
+4. Initialize a **new empty database**, with the service stopped. Existing databases require the upgrade/backup procedure.
+
+~~~sh
+docker compose run --rm --no-deps shabbat php bin/migrate.php --writers-stopped --db /app/data/shabbat.sqlite
+~~~
+
+5. Start when ready for real Telegram connections and operations on registered groups:
+
+~~~sh
+docker compose up -d shabbat
+docker compose ps
+docker compose logs --tail=100 shabbat
+docker compose exec shabbat php bin/health.php
+~~~
+
+Service: `shabbat`. Named volumes: `bot-data` โ `/app/data`, `madeline-session` โ `/app/runtime`; actual names include the Compose project name. Preserve the project identity and volumes during upgrades. Compose forces `DATA_DIR=/app/data` and `SESSION_PATH=/app/runtime/bot.shabbat`. Initial health waits for both scheduler loops; the configured start period is 90 seconds.
+
+[Installation details](docs/INSTALLATION.md) ยท [Staging guide](docs/SERVER_TEST_DEPLOYMENT.md)
+
+
+## Manual installation
+
+Use separate source and a non-root service account that can write the working/data/session directories. Run **from that project root** after copying/configuring `.env` as above:
+
+~~~sh
+composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader
+composer check-platform-reqs --no-dev
+mkdir -p data/session
+php bin/self-check.php --release
+php bin/runtime-permissions.php --require-non-root
+php bin/migrate.php --writers-stopped --db data/shabbat.sqlite
php bot.php
+~~~
+
+The last command starts the bot in the foreground and connects to Telegram. Stop with Ctrl+C and wait for exit; repeat `php bot.php` to start again. Manual process supervision is the operator's responsibility; no systemd unit is supplied. Windows can run many offline checks; the POSIX non-root probe and Linux deployment acceptance require the appropriate host.
+
+Composer normally applies/verifies local patches on install. If scripts were disabled, run these **in the stopped installation**:
+
+~~~sh
+composer install --no-dev --no-scripts --no-interaction --prefer-dist
+php scripts/apply-patches.php
+php scripts/verify-patches.php
+~~~
+
+Unknown/partially modified dependency bytes fail verification. Reapplication is idempotent. Never patch an installed library while its bot is running.
+
+
+## Configuration
+
+The bot reads project-root `.env` as raw INI. Nonempty process environment values take precedence for credential/path lookup; avoid duplicate empty overrides. Maintenance path resolution also reads `.env`; explicit `--db` selects the target database. Relative paths are rooted at the project.
+
+| Variable | Default / requirement | Purpose |
+|---|---|---|
+| `API_ID` | Required positive integer | Telegram application identifier. |
+| `API_HASH` | Required 32 hexadecimal characters | Telegram application secret. |
+| `BOT_TOKEN` | Required | This installation's BotFather token. |
+| `ADMIN` | Configure for operator access | Comma-separated IDs/resolvable usernames; numeric IDs avoid ambiguity. Configured owners retain recovery/restart access. |
+| `DATA_DIR` | Project `data` | Application and BroadcastManager state; Compose overrides to `/app/data`. |
+| `APP_DB_PATH` | `DATA_DIR/shabbat.sqlite` | Application database; example explicitly uses `data/shabbat.sqlite`. |
+| `SESSION_PATH` | Project `bot.shabbat` | MadelineProto session; example uses `data/session/bot.shabbat`, Compose uses `/app/runtime/bot.shabbat`. |
+| `HEARTBEAT_PATH` | `DATA_DIR/scheduler-heartbeat.json` | Permission/notification heartbeat file. |
+| `LEGACY_DATA_PATH` | Project root | Read-only legacy root containing `data/DBgroups.txt`; startup only adds missing registrations. |
+| `GEONAMES_USERNAME` | Empty | Your webservice account; demo is unsupported. |
+| `GEONAMES_ENDPOINT` | `https://secure.geonames.org` | Verified HTTPS endpoint, no HTTP fallback. |
+| `CUSTOM_EMOJI_ENABLED` | `1` | `0` selects basic emoji; definite eligibility failures also trigger guarded fallback. |
+| `SOURCE_URL` | Empty; set before deployment | HTTPS exact corresponding source URL. Missing configuration is reported, not blocked at startup. |
+| `BUILD_COMMIT` | Archive metadata or 40-hex commit | Build/runtime identity and Compose build argument. Preserve fingerprints for local changes. |
+
+[Configuration details](docs/CONFIGURATION.md) ยท [Privacy](docs/PRIVACY.md)
+
+
+## Telegram setup
+
+1. Obtain API credentials through [Telegram application management](https://my.telegram.org/apps).
+2. Create/select the bot with [BotFather](https://t.me/BotFather), obtain its token and allow group additions.
+3. Open the bot privately with `/start`. Add it to the intended group with rights to restrict members/change default permissions. Optional deletion/pinning needs the relevant Telegram rights.
+4. Enable BotFather `/setinline` for inline search/sharing. Suggested placeholder: `shabbat ยท zmanim ยท daf ยท omer ยท loc city`. City-name search requires neither `/setinlinegeo` nor `/setinlinefeedback`; GPS sharing is explicit.
+5. With command-menu mutation access, send `/setcommands all all preview` privately to inspect the menu count; `/setcommands all all` updates Telegram. Group administration alone does not grant that access.
+
+Optional cleanup only processes updates Telegram delivers. General messages from other bots require the receiving bot's **Bot-to-Bot Communication Mode** enabled through BotFather, group administrator status and **Group Privacy Mode** disabled, as described in [Telegram's bot-to-bot documentation](https://core.telegram.org/api/bots/bot-to-bot). Deletion still needs the relevant rights; this is not a guarantee of receiving every message.
+
+
+## Group setup
+
+1. Send `/add` **as a group administrator in the group**. New groups added this way start **off** and open private setup.
+2. Confirm the named group, location, IANA zone and Israel/diaspora calendar. Jerusalem defaults must be reviewed; they are not inferred location.
+3. Select mode and Shabbat/festival policy, then candle/havdalah methods and operational offsets.
+4. Explicitly enable desired zmanim/Daf/tefillin/Omer modules. Preview privately; a group TEST needs separate confirmation and is rate limited.
+5. Return through private `/groups` or group `/settings`. Personal and group settings remain separate.
+
+**Current registration caveat:** `/settings` can register a previously unknown group after verifying its administrator, and that path inherits the database's **lock** default. Use `/add` for the explicit off-first flow and review mode/location immediately. `/settings` does not reactivate a removed group; `/add` can. This behavior is documented as implemented, not silently changed during publication preparation.
+
+Panels show current values, upcoming work and recovery status. Changes require current authority and a user/group/revision-bound confirmation. A missing/closed forum topic produces an error, not redirection. Restoration uses saved original rights; missing/unverified/conflicting snapshots need authorized resolution. Optional cleanup of member/bot messages starts disabled. Group removal is blocked by unresolved restoration debt.
+
+[Settings](docs/GROUP_SETTINGS.md) ยท [Guided interface](docs/USER_EXPERIENCE.md) ยท [Permission recovery](docs/PERMISSION_RECOVERY.md)
+
+
+## Location and languages
+
+Six locale catalogs are supplied: Hebrew `he`, English `en`, Russian `ru`, French `fr`, Spanish `es`, Brazilian Portuguese `pt-br`. Their keys/placeholders are checked; this is not a promise that every third-party error or inherited interface is translated. Hebrew ritual wording remains Hebrew, with supplied explanatory text. Telegram menus use `pt`; client menu language and the saved bot preference are separate.
+
+Use private `/preferences` or `/language`; group `/language` requires its administrator. For city search, create a [GeoNames account](https://www.geonames.org/login), activate free webservices and set `GEONAMES_USERNAME`.
+
+In the appropriate panel: **location/timezone โ inline city search โ city/country/region โ inspect coordinates/timezone โ confirm the named target**. Private search, known cities, coordinates, IANA zone search and prompted GPS replies are available. Choices expire, belong to one user and are consumed once. Changing timezone does not move coordinates.
+
+Public `loc city` and a bare city name support location search/sharing. Public timezone queries include `tz Jerusalem` or `Asia/Jerusalem`; they do not update settings. Setup queries `location CITY` and `timezone QUERY` require the active private location/timezone prompt and lead to confirmation. Other modes: `shabat`, `shabbat`, `ืฉืืช`, `zmanim`, `daf`, `omer`. Check the result's displayed location: personal sharing and an authorized group-context preview may differ. Quota/account failures remain visible errors.
+
+[Inline/GeoNames guide](docs/INLINE_GEONAMES.md)
+
+
+## Calendar modules
+
+| Module | Implemented settings / limits |
+|---|---|
+| Shabbat/festivals | City candle custom or exact 10/15/18/20/22/30/40/custom minutes. Jerusalem city default is 40; explicit 18 remains 18. Havdalah: 8.5ยฐ or fixed 42/50/72/custom minutes. Fixed 72 does not represent every Rabbeinu Tam method. |
+| Group timing | Close-before-candles/open-after-havdalah offsets are separate. Adjacent holy days stay closed continuously. Israel/diaspora is explicit. Announcements have independent timing/design, Hebrew dates and Torah-portion metadata. |
+| Zmanim | Civil date/IANA zone; dawn, misheyakir, sunrise, Gra/MGA Shema/prayer, midday, mincha, plag, sunset, nightfall and midnight where available. Missing values remain unavailable. |
+| Daf Yomi | Civil-date calculation and provider study link; skipping sends never pauses the cycle. |
+| Tefillin | Fixed/interval/sunset-relative schedules, deduplication, limits, spacing, quiet windows/pause. Start uses 11.5ยฐ `misheyakir`; the stricter provider field is not selectable. No Shabbat/Yom Tov sends; explicit Chol HaMoed/observed Tisha B'Av policies. Cutoff rechecked at dispatch. Optional unique-user acknowledgement toggles participation. |
+| Omer | 49-day seasonal count advances across unsent days/years. Night sends wait for selected nightfall and group quiet-period end; optional day completion omits blessing. `ashkenaz` and documented `sephard` sequence, not every community variant. Full form includes selected preparation/traditional additions. |
+
+Blessing guidance concerns individual eligibility; delivery history is not a person's counting history. See [method definitions](docs/ZMANIM_METHODS.md), [calendar presentation](docs/CALENDAR_PRESENTATION.md) and [text provenance](THIRD_PARTY_NOTICES.md).
+
+
+## Bot commands
+
+Send these **in Telegram**. Menus are shortcuts, not a full permission contract. `[...]` means optional.
+
+### Users
+
+| Command | Where / permission | Effect |
+|---|---|---|
+| `/start`, `/help` | Private; any user | Home menu/help. |
+| `/preferences` | Private; own settings | Personal location/calendar/methods. |
+| `/language`, `/lang` | Private; own settings | Personal language; group usage needs group admin. |
+| `/groups [search]` | Private; fresh group-admin checks | List/search manageable known groups. |
+| `/shabat [YYYY-MM-DD]`, `/shabbat [YYYY-MM-DD]` | Private or registered group; any user | Shabbat/festival times for that context. |
+| `/zmanim [YYYY-MM-DD]` | Private or registered group; any user | Daily times. |
+| `/daf [YYYY-MM-DD]` | Private or registered group; any user | Daf Yomi. |
+| `/omer [YYYY-MM-DD]` | Private or registered group; any user | Count/season for the selected date. |
+| `/stats` | Private or group; any user | Public aggregates, without subscriber identities. |
+| `/donate` | Private; any user | Optional Stars offers; payment needs Telegram checkout. |
+| `/source`, `/license`, `/version` | Private or group; any user | Source offer, licensing/build identity. |
+
+### Group administrators
+
+| Command | Where / permission | Effect |
+|---|---|---|
+| `/add` | Group; current group admin | Register/reactivate and open setup. |
+| `/remove` | Group; current group admin | Deactivate; restoration debt blocks removal. |
+| `/settings` | Group; current group admin | Private group settings; see registration caveat above. |
+| `/settings [GROUP_ID]` | Private; current target-group admin | Open target or list groups without ID. |
+| `/language`, `/lang` | Group; current group admin | Private selection of group language. |
+
+Advanced **private** forms require the panel's exact token, its owner/scope/revision and current group authority for group targets. Prefer the guided buttons.
+
+| Form | Effect |
+|---|---|
+| `/city TOKEN QUERY` | Search pending location. |
+| `/coordinates TOKEN LATITUDE LONGITUDE IANA_ZONE [NAME]` | Preview explicit coordinates/zone. |
+| `/location TOKEN IANA_ZONE` or `/location TOKEN auto` | Prompt for GPS reply; auto requires GeoNames. |
+| `/timezone TOKEN QUERY` | Search IANA zones. |
+| `/configure TOKEN FORM` | Preview the panel's supported settings form. |
+| `/viewdate TOKEN YYYY-MM-DD` | Selected calendar date. |
+| `/template TOKEN` + newline + content | Review a group template candidate. |
+| `/buttons TOKEN` + newline + keyboard content | Review JSON/legacy keyboard content. |
+
+### Bot managers
+
+All below run **in private**. Bot-manager roles are separate from group administration; view-only roles cannot mutate.
+
+| Command | Required access | Effect |
+|---|---|---|
+| `/admin` | Bot manager | Administration panel. |
+| `/adminstats` | Statistics | Administrative aggregates. |
+| `/testzmanim [GROUP_ID]` | Statistics | Paged diagnostics/filter/search and optional private JSON export. |
+| `/subscribers` | Subscribers | Known peer/profile directory. |
+| `/broadcast` | Broadcast | Drafts/jobs; sending/editing/deleting needs mutation rights and relevant confirmation. |
+| `/managers` | Manage managers | Delegated roles within actor's authority. |
+| `/systemlinks` | System links | Navigation links, which confer no permissions. |
+| `/trackinglinks` | Tracking links | Activation counters/management; changes need mutation access. |
+| `/setcommands [LANGUAGE] [SCOPE] [preview]` | Command-menu mutation, even preview | Inspect or update Telegram menus. |
+| `/closegroups`, `/opengroups` | Group-operations mutation | Immediately apply a one-hour override to active registered groups; preserve prelocks/report conflicts. Real permission operations. |
+| `/restart` | Configured `ADMIN` owner only | Orderly runtime restart. |
+
+Menu languages: `all|he|en|ru|fr|es|pt-br`; scopes: `all|private|operators|groups|group_admins`; defaults: `all all`. Example: `/setcommands ru group_admins preview`. Group menus currently list `/settings`; other supported commands can be typed.
+
+[Administration](docs/ADMIN_PANEL.md) ยท [Diagnostics](docs/DIAGNOSTICS.md)
+
+
+## Operations
+
+Docker commands run **on the host at the same project root/project identity**, with daemon access. PHP commands run **inside the image at /app**, or at the manual root as its service user. Operational output can contain group IDs/state; keep it private.
+
+| Task | Command | Effect / condition |
+|---|---|---|
+| Build | `docker compose build` | Build from source/lock. |
+| Start | `docker compose up -d shabbat` | Start/recreate and connect to Telegram. |
+| Stop | `docker compose stop shabbat` | Graceful stop, 60-second grace; retain volumes. |
+| Restart | `docker compose restart shabbat` | Restart same service; does not rebuild image. |
+| Status | `docker compose ps` | Container/service state. |
+| Logs | `docker compose logs --tail=100 shabbat` | Read logs; add `--follow` to stream, Ctrl+C exits viewer. |
+| Health | `docker compose exec shabbat php bin/health.php` | Schema/connection, both heartbeats and queue/lock metrics. |
+| Deep health | `docker compose exec shabbat php bin/health.php --deep` | Also integrity/foreign-key checks; more expensive. |
+| Release gate | `docker compose run --rm --no-deps shabbat php bin/self-check.php --release` | Offline platform/patch checks. |
+| Write probe | `docker compose run --rm --no-deps shabbat php bin/runtime-permissions.php --require-non-root` | Offline temporary-file probe; may append to existing worker log. |
+
+Both heartbeats must be no older than 180 seconds. Offline/restored DBs can pass structural checks yet return `healthy=false` / exit 1 without schedulers. Never manufacture heartbeat files.
+
+Run direct maintenance below **at project root as service user**. In Docker, prefix PHP commands with `docker compose run --rm --no-deps shabbat` and use container paths. `--writers-stopped` is an attestation, not a stop command.
+
+| Task | Command | Effect / requirement |
+|---|---|---|
+| Migrate | `php bin/migrate.php --writers-stopped --db data/shabbat.sqlite --backup-dir backups` | All writers stopped, existing backup directory. Back up existing DB/apply migrations; omit backup only for new DB. |
+| Backup | `php bin/backup.php --db data/shabbat.sqlite --directory backups --keep 7` | Consistent SQLite copy/manifest; retain seven tool-named backups. Online maintenance allowed. |
+| Restore | `php bin/restore.php --backup backups/CHOSEN.sqlite --db data/shabbat.sqlite --writers-stopped` | Verified chosen backup, stopped writers; previous destination retained. |
+| Inspect legacy | `php bin/import-legacy.php --db data/shabbat.sqlite --legacy /path/to/legacy-copy --dry-run` | Inspect/report, no imported-record writes or Telegram actions. |
+| Import legacy | `php bin/import-legacy.php --db data/shabbat.sqlite --legacy /path/to/legacy-copy --apply --writers-stopped --backup-dir backups` | Existing migrated DB/backup directory, stopped writers; additive import. |
+| Calendar diagnostic | `php bin/check-calendar.php --date=2026-10-15` | Public HTTPS only; no DB, .env or session. `--help` lists coordinates/zone/calendar/language options. |
+| Verify patches | `php scripts/verify-patches.php` | Inspect approved installed bytes. |
+| Apply patches | `composer patches` | Apply/verify; stopped installation only. |
+| Build identity | `php bin/build-metadata.php --commit=YOUR_40_HEX_COMMIT` | Replace placeholder with verified provenance; writes metadata/rejects stale fingerprint. |
+
+
+## Upgrade and legacy import
+
+Preserve private configuration, data/session and Compose project identity. Use reviewed source/lock; stop every writer before schema or installed dependency changes. Routine upgrades use `composer install`, not `composer update`.
+
+For an existing Compose DB, from the prepared project connected to the **same volumes**:
+
+~~~sh
+docker compose build
+docker compose stop shabbat
+docker compose run --rm --no-deps shabbat mkdir -p /app/data/backups
+docker compose run --rm --no-deps shabbat php bin/backup.php --db /app/data/shabbat.sqlite --directory /app/data/backups --keep 7
+~~~
+
+Complete the stopped session/BroadcastManager backup and restore rehearsal below **before proceeding**. Then apply the schema and release checks:
+
+~~~sh
+docker compose run --rm --no-deps shabbat php bin/migrate.php --writers-stopped --db /app/data/shabbat.sqlite --backup-dir /app/data/backups
+docker compose run --rm --no-deps shabbat php bin/self-check.php --release
+~~~
+
+`docker compose up -d shabbat` then activates the reviewed image; inspect logs/health. Manual installations follow stop/backup/install-from-lock/migrate/check/start.
+
+dev.8 โ dev.9 adds `016_delivery_safety.sql`. dev.6 also needs 013โ015; earlier versions apply all missing migrations automatically. Do not edit applied SQL. Uncertain past delivery attempts are handled conservatively; older unverified permission snapshots still require administrator review.
+
+For the old file-based bot: preserve a protected legacy copy; stop old writers for final import/cutover; initialize a new DB; run dry-run, compare counts/templates/rights/warnings, then apply with stopped writers and backup directory. The exact commands appear above. For Docker mount only the protected copy read-only:
+
+~~~sh
+docker compose run --rm --no-deps --volume /absolute/path/legacy-copy:/legacy:ro shabbat php bin/import-legacy.php --db /app/data/shabbat.sqlite --legacy /legacy --dry-run
+~~~
+
+Explicit import preserves applicable groups, templates/entities/media/buttons, drafts, known peers and permission evidence without Telegram calls. New reminder modules remain off; malformed/missing snapshots remain review items. The startup `DBgroups.txt` bridge only adds registrations and does not replace full import. Never substitute a test bot's DB/session for production state.
+
+[Migration](docs/MIGRATION.md) ยท [Schema](docs/DATABASE.md) ยท [Older snapshots](docs/DEV8_PERMISSIONS.md)
+
+
+## Backup and rollback
+
+SQLite backup uses `VACUUM INTO`, validates integrity/foreign keys/schema history and writes SHA-256 metadata. Copying only a live main file can omit committed WAL. Retention only removes this tool's strictly named backups in the chosen directory; choose `--keep` deliberately.
+
+SQLite does **not** include the session or all BroadcastManager files. Stop their writers and keep these separate paths, private configuration and matching code/lock alongside the DB backup. Docker example **after stopping the service**, on the host project root. Use a new empty snapshot directory for each backup (replace `../shabbat-private-backup` consistently):
+
+~~~sh
+mkdir -p ../shabbat-private-backup
+chmod 700 ../shabbat-private-backup
+docker compose cp shabbat:/app/data ../shabbat-private-backup/data
+docker compose cp shabbat:/app/runtime ../shabbat-private-backup/runtime
+cp .env ../shabbat-private-backup/config.env
+chmod 600 ../shabbat-private-backup/config.env
+~~~
+
+This copies complete stopped durable directories to a sibling outside the Docker build context. Keep them private, outside publication material. For manual installation copy the actual resolved data/session paths with writers stopped. Verify all backups before replacement.
+
+Create a separate rehearsal directory and restore a chosen verified SQLite backup there:
+
+~~~sh
+mkdir -p ../shabbat-rehearsal
+php bin/restore.php --backup backups/CHOSEN.sqlite --db ../shabbat-rehearsal/restored.sqlite --writers-stopped
+php bin/health.php --db ../shabbat-rehearsal/restored.sqlite --deep
+~~~
+
+Replace `CHOSEN.sqlite` with the real filename. Inspect structural fields, schema and sampled records; overall health may exit 1 without live heartbeats.
+
+Rollback: stop writers; retain a current recovery backup; resolve/record outstanding Telegram permission obligations; restore older compatible DB/code/lock/session/BroadcastManager state; start one matching instance and review fresh remote reconciliation. DB restoration cannot reverse already accepted Telegram changes. Restore validates the input/manifest, obtains the writer lock and retains `*.before-restore-*`. Busy writers or incompatible schema require investigation, not deleting locks/volumes.
+
+[Backup/restore details](docs/BACKUP_RESTORE.md)
+
+
+## Troubleshooting
+
+| Symptom | Action |
+|---|---|
+| Missing commit/changed source at build | Retain verified archive metadata or supply actual reviewed checkout identity. Regenerate archive metadata after source changes. |
+| SQLite release gate fails | Use the pinned image/accepted fixed runtime; development warnings are not production acceptance. |
+| Schema missing/stale | Stop writers, back up and migrate explicit paths. Startup does not migrate. |
+| Patch failure | Check lock/manifests; reinstall in a clean stopped copy. Do not weaken verification. |
+| New group sends nothing | Review mode, location, module opt-in and schedule; `/add` starts off. |
+| Settings inaccessible/expired | Open private chat and fresh `/settings` as current group administrator. |
+| City search fails | Check GeoNames activation/account/quota/HTTPS; use known cities/coordinates if needed. |
+| Unhealthy status | Inspect both heartbeat ages, schema, queue and logs; stopped schedulers have no fresh heartbeat. |
+| Group not reopening | Use recovery/diagnostics for missing/unverified/conflicting snapshots; preserve original restrictions. |
+| Missing/late reminder | Check timezone/date, quiet hours, holiday/cutoff policy, module and topic. Dispatch rechecks eligibility. |
+| Custom emoji rejection | Set `CUSTOM_EMOJI_ENABLED=0`; definite eligibility failures also trigger fallback. |
+| Permission-denied files | Check image UID/GID 10001 and working/data/session paths; run write probe. Avoid world-writable permissions. |
+
+[More troubleshooting](docs/TROUBLESHOOTING.md) ยท [Privacy](docs/PRIVACY.md) ยท [Security reporting](.github/SECURITY.md)
+
+
+## Project and development
+
+| Path | Contents |
+|---|---|
+| `bot.php`, `src/` | Entry point; calendar/UI/scheduling/storage/Telegram/runtime services. |
+| `bin/`, `migrations/` | Maintenance tools and immutable numbered schema changes. |
+| `resources/` | Six locales, attributed Omer resources and PHP configuration. |
+| `patches/` | Reviewed bases/replacements/diffs, provenance and SHA-256 manifests. |
+| `tests/` | Offline tests and required JSON/TXT/PHP fixtures. |
+| `scripts/` | Patches, quality, dependencies and local packaging. |
+| `docs/`, `LICENSES/` | Guides, provenance and license texts. |
+| `.github/` | CI/release workflows and contribution/security templates. |
+
+Run **in a separate development copy at its root as developer**, without production state/credentials. Install includes test tools; tests use isolated temporary state.
+
+~~~sh
+composer install --no-interaction --prefer-dist
+composer validate --strict --no-check-all
+composer check-platform-reqs
+php scripts/verify-patches.php
+composer test
+composer lint
+composer style
+composer analyse
+composer check-locales
+composer check-docs
+composer self-check
+~~~
+
+`--no-check-all` omits generic version-range advice for the deliberate exact MadelineProto pin; strict schema/lock checks remain. `lint` checks syntax/quality; `style` enforces PHPCS errors; `analyse` runs PHPStan. `composer style-review` includes the broader style report; `composer style-fix` **rewrites files**, for reviewed development copies only.
+
+At the same development root:
+
+~~~sh
+composer show --locked --format=json
+composer licenses --format=json
+composer audit --locked --format=json
+composer dependencies
+~~~
+
+These report locked packages, installed licenses and current advisories; audit contacts advisory services. `dependencies` writes evidence under `docs/`; review/sanitize it before inclusion. Do not replace the lock to silence a check.
+
+Developer-only patch regeneration, from the development root:
+
+~~~sh
+php patches/madelineproto/generate-artifacts.php
+php patches/broadcastmanager/generate-artifacts.php
+~~~
+
+These commands rewrite patch artifacts/manifests from bundled reviewed sources. They are for an intentional patch review, not routine installation. Review the changed hashes/diffs and run the full patch checks afterward. The apply/verify PHP scripts also accept an optional first positional vendor directory; leave it unset for the normal `vendor/` path.
+
+For local packaging with required development dependencies and verified identity:
+
+~~~sh
+php scripts/package.php
+~~~
+
+This writes application, corresponding-source and server-test ZIPs/checksums/manifests to `dist/`, without publishing. This prerelease uses `shabbat-3.0.0-dev.9.zip` without installed dependencies and a separately inventoried runtime corresponding-source bundle; do not substitute the developer packager's installed tool distributions for that source collection. Consult each manifest for inclusion/omissions; application source plus a lockfile is not a complete dependency source bundle.
+
+[Architecture](docs/ARCHITECTURE.md) ยท [Dependencies](docs/DEPENDENCIES.md) ยท [Patches](docs/PATCHES.md) ยท [Release preparation](docs/RELEASE.md) ยท [Contributing](.github/CONTRIBUTING.md) ยท [Code of conduct](.github/CODE_OF_CONDUCT.md)
+
+
+## License and credits
+
+The integrated application is **AGPL-3.0-only**: [LICENSE](LICENSE), [licensing policy](docs/LICENSING.md). Historical MIT notices/prior grants remain. Dependency, data and transcription licenses are separate; their headers/notices are preserved.
+
+Credit to [WizardLoop](https://github.com/WizardLoop), [MadelineProto](https://github.com/danog/MadelineProto), [BroadcastManager](https://github.com/WizardLoop/BroadcastManager), [Hebcal](https://www.hebcal.com/), [GeoNames](https://www.geonames.org/) and attributed Hebrew Wikisource liturgy sources. Components, licenses, immutable revisions, modifications and attribution are recorded in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md), dependency manifests and resource provenance.
-# new: added support for multiple admins bot - eg: ADMIN=@user1, @user2 [username/ID]
+Operators/forks must make the **exact deployed corresponding source** available, including local application/dependency changes and necessary build material, and set `SOURCE_URL`. A different public `main` is insufficient. Publication material excludes private configuration, live DB/WAL/SHM, sessions, backups, logs and caches, while retaining required source, tests/fixtures, patch baselines and license texts.
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
new file mode 100644
index 0000000..59de998
--- /dev/null
+++ b/THIRD_PARTY_NOTICES.md
@@ -0,0 +1,38 @@
+# Third-party notices
+
+WizardLoop Shabbat **3.0.0-dev.9** is distributed as an **AGPL-3.0-only** application. Dependency, historical-source and data licenses retain their own scope. The full application license is in [LICENSE](LICENSE); the [licensing guide](docs/LICENSING.md) explains the source offer. Exact locked versions, sources, license paths and findings are recorded in the [dependency inventory](docs/dependencies.json).
+
+## Code and tools
+
+| Component | Source and license | Attribution and local changes |
+|---|---|---|
+| Original WizardLoop Shabbat | [WizardLoop/shabbat](https://github.com/WizardLoop/shabbat), historical MIT | Copyright (c) 2024 WizardLoop. The complete original grant is retained in [SHABBAT-LEGACY-MIT.txt](LICENSES/SHABBAT-LEGACY-MIT.txt); earlier grants remain effective. |
+| MadelineProto 8.7.0 | [Pinned source](https://github.com/danog/MadelineProto/tree/7b99dd0f2d964a492c377912ffce9f5ce61aa9ed), package metadata AGPL-3.0-only | Preserve its distributed license and copyright headers. Four local compatibility patches cover update-loop cancellation, fractional coordinates, formatted-date entities and fresh full-peer response evidence. |
+| WizardLoop BroadcastManager 3.2.2 | [Pinned source](https://github.com/WizardLoop/BroadcastManager/tree/f2792a383416b66bb68e55f09414563e540538d6), AGPL-3.0-only | Preserve WizardLoop notices. The local patch adds presentation, message-transport, authorization, cancellation and joined-worker lifecycle hooks. |
+| Amp and Revolt | [Amp](https://github.com/amphp) and [Revolt](https://github.com/revoltphp/event-loop), MIT | Distributed author/copyright notices remain in their packages. Exact direct and transitive versions are in the inventory. |
+| Development tools | PHPUnit and PHP_CodeSniffer: BSD-3-Clause; PHPStan: MIT | Locked tool and transitive-package notices are inventoried separately from runtime dependencies. The runtime corresponding-source archive does not bundle the development tools. |
+| SQLite | [SQLite public-domain dedication](https://sqlite.org/copyright.html) | External PHP-loaded runtime library. The Dockerfile identifies the reviewed source version and checksum; SQLite is not relicensed as application code. |
+
+There are **five active patch sets**. Their original sources, approved intermediate sources, diffs, added source and SHA-256 manifests are preserved under [patches/](patches). [Technical provenance](docs/THIRD_PARTY_DETAILS.md) records exact identities and historical changes. Installation verifies them before startup; running vendor trees must not be changed.
+
+MadelineProto package metadata says `AGPL-3.0-only`, while retained source headers allow version 3 or later. The new formatted-date wrapper also grants `AGPL-3.0-or-later`. These grants are preserved; the combined application's policy uses version 3. The metadata/header difference remains a recorded finding.
+
+## Data and text
+
+| Resource | Attribution and license | Modifications and provenance |
+|---|---|---|
+| Hebcal API responses and regression fixtures | **Hebcal.com**, [CC-BY-4.0](https://www.hebcal.com/home/195/jewish-calendar-rest-api) | Projected fixtures identify removed fields and normalization; full-response fixtures retain every response field. Queries, retrieval dates and hashes accompany the fixtures. |
+| [Omer count strings](resources/templates/omer-counts.json) | **Hebcal.com**, CC-BY-4.0 | 49 Hebrew/English counts, retrieved 2026-10-13. The basic Ashkenaz suffix modification is identified in `OmerText`. |
+| [Omer explanatory translations](resources/templates/omer-counts-localized.json) | **Hebcal.com; explanatory translations by WizardLoop Shabbat**, CC-BY-4.0 | Static French, Spanish, Brazilian Portuguese and Russian explanations derived from the verified count structure; ritual Hebrew is separate. |
+| [Omer liturgy](resources/templates/omer-liturgy.json) | **Hebrew Wikisource contributors**, CC-BY-SA-4.0 for the transcription/editorial resource | Pinned revisions cover Ashkenaz/Sephard preparations and endings, Harachaman, Psalm 67, Ana Bekoach and daily sefira. Changes remove vowel/cantillation marks, normalize punctuation/lines and divine-name presentation, and identify the ืจืืื variant. |
+| GeoNames place data | **GeoNames.org**, [CC-BY-4.0](https://www.geonames.org/about.html) | An operator-owned API account is required. API access terms and quotas are separate from data reuse permissions. |
+
+The complete [CC-BY-4.0](LICENSES/CC-BY-4.0.txt) and [CC-BY-SA-4.0](LICENSES/CC-BY-SA-4.0.txt) terms are retained unchanged. [Detailed resource provenance](docs/THIRD_PARTY_DETAILS.md#data-provenance) links the exact fixture records and Wikisource revisions. Traditional underlying liturgy is distinguished from the separately attributed transcription/editorial resource; no complete modern siddur is claimed.
+
+## References and unresolved evidence
+
+[JewishPulse](https://github.com/WizardLoop/JewishPulse/tree/d7b06eb1acb206543dcff35a7c14ebce4760b4e5), AGPL-3.0-only, informed the interface comparison and documentation structure. Its branding and hardcoded bot links are not used. The [feature comparison](docs/FEATURE_PARITY.md) describes the reference relationship.
+
+No third-party graphic asset was added. Telegram standard emoji and button metadata are interface features. Provenance of an older remote thumbnail is unestablished; current inline results do not depend on it.
+
+Historical formatted-date reference exports had conflicting project declarations and no recovered standalone grant or Git history. Their hashes and findings are retained in [the provenance review](docs/MADELINE_FORMATTED_DATE.md); those original exports are not redistributed. The retained historical UpdateLoop script has a provenance hash but no standalone license header; its authorship/grant provenance remains a release-review item. Do not treat a successful checksum or a package license inventory as a resolution of that item.
diff --git a/VERSION b/VERSION
new file mode 100644
index 0000000..b13f1d9
--- /dev/null
+++ b/VERSION
@@ -0,0 +1 @@
+3.0.0-dev.9
diff --git a/bin/backup.php b/bin/backup.php
new file mode 100644
index 0000000..03ad399
--- /dev/null
+++ b/bin/backup.php
@@ -0,0 +1,26 @@
+databasePath);
+ $directory = (string)($options['directory'] ?? dirname(__DIR__) . '/backups');
+ if (!is_file($path)) {
+ throw new RuntimeException('Database does not exist.');
+ }
+ if (!is_dir($directory) && !mkdir($directory, 0700, true)) {
+ throw new RuntimeException('Cannot create backup directory.');
+ }
+ $service = new BackupService();
+ $result = $service->backup(Database::open($path), $directory . '/shabbat-backup-' . gmdate('Ymd-His') . '-' . bin2hex(random_bytes(4)) . '.sqlite');
+ $result['removed'] = BackupService::retain($directory, (int)($options['keep'] ?? 7));
+ echo Database::encode($result) . "\n";
+} catch (Throwable $error) {
+ fwrite(STDERR, $error->getMessage() . "\n");
+ exit(1);
+}
diff --git a/bin/build-metadata.php b/bin/build-metadata.php
new file mode 100644
index 0000000..dd0294f
--- /dev/null
+++ b/bin/build-metadata.php
@@ -0,0 +1,18 @@
+getMessage() . "\n");
+ exit(1);
+}
diff --git a/bin/check-calendar.php b/bin/check-calendar.php
new file mode 100644
index 0000000..1d7f0fa
--- /dev/null
+++ b/bin/check-calendar.php
@@ -0,0 +1,47 @@
+ 'calendar-public-v1', 'ok' => false, 'reason' => 'cli_arguments_unavailable'], JSON_THROW_ON_ERROR) . "\n";
+ exit(2);
+}
+$arguments = array_slice($argv, 1);
+if (in_array('--help', $arguments, true)) {
+ echo "Usage: php bin/check-calendar.php [--date=YYYY-MM-DD] [--latitude=NUMBER --longitude=NUMBER]\n";
+ echo " [--timezone=IANA_ZONE] [--israel=on|off] [--language=he|en|ru|fr|es|pt-br]\n";
+ echo "Defaults: Jerusalem, 31.76904 / 35.21633, Asia/Jerusalem, Israel, Hebrew, 40-minute city candles.\n";
+ echo "An explicit date is checked at local noon. Public HTTPS queries only; no credentials or stored data.\n";
+ exit(0);
+}
+try {
+ // Some platform-specific vendor polyfills print notices; keep this tool's JSON channel clean.
+ ob_start();
+ try {
+ require_once dirname(__DIR__) . '/vendor/autoload.php';
+ } finally {
+ ob_end_clean();
+ }
+ $options = CalendarDiagnostics::parseArguments($arguments);
+ $report = (new CalendarDiagnostics(new SystemClock()))->run($options);
+ $exit = $report['ok'] ? 0 : 1;
+} catch (CancelledException) {
+ $report = ['diagnostic' => 'calendar-public-v1', 'ok' => false, 'reason' => 'cancelled'];
+ $exit = 130;
+} catch (InvalidArgumentException) {
+ $report = ['diagnostic' => 'calendar-public-v1', 'ok' => false, 'reason' => 'invalid_options', 'help' => 'php bin/check-calendar.php --help'];
+ $exit = 2;
+} catch (Throwable) {
+ $report = ['diagnostic' => 'calendar-public-v1', 'ok' => false, 'reason' => 'diagnostic_internal_error'];
+ $exit = 1;
+}
+echo json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . "\n";
+exit($exit);
diff --git a/bin/health.php b/bin/health.php
new file mode 100644
index 0000000..a4d4d08
--- /dev/null
+++ b/bin/health.php
@@ -0,0 +1,39 @@
+databasePath);
+ if (!is_file($path)) {
+ throw new RuntimeException('Application database is missing; run the maintenance migration first.');
+ }
+ $db = Database::open($path);
+ $db->assertCurrentSchema();
+ // The frequent Docker probe must not rescan the entire historical database.
+ // Explicit maintenance/deployment checks retain full integrity validation.
+ $deep = array_key_exists('deep', $options);
+ $checks = $deep ? $db->integrity() : ['connection_check' => (int) $db->pdo()->query('SELECT 1')->fetchColumn() === 1];
+ $healthy = $deep ? $checks['integrity_check'] === ['ok'] && $checks['foreign_key_check'] === [] : $checks['connection_check'];
+ $schema = $db->pdo()->query('SELECT version,checksum FROM schema_migrations ORDER BY version')->fetchAll();
+ $jobs = $db->pdo()->query('SELECT status,COUNT(*) AS count FROM scheduled_jobs GROUP BY status')->fetchAll();
+ $locks = $db->pdo()->query('SELECT status,COUNT(*) AS count FROM group_lock_state GROUP BY status')->fetchAll();
+ $heartbeatPath = $paths->heartbeatPath;
+ $heartbeat = is_file($heartbeatPath) ? json_decode((string)file_get_contents($heartbeatPath), true, 16, JSON_THROW_ON_ERROR) : null;
+ $heartbeatAge = is_array($heartbeat) && isset($heartbeat['timestamp']) ? time() - (int)$heartbeat['timestamp'] : null;
+ $notificationAge = is_array($heartbeat) && isset($heartbeat['notification_timestamp']) ? time() - (int) $heartbeat['notification_timestamp'] : null;
+ $healthy = $healthy && $heartbeatAge !== null && $heartbeatAge >= 0 && $heartbeatAge <= 180
+ && $notificationAge !== null && $notificationAge >= 0 && $notificationAge <= 180;
+ $queue = (new \Shabbat\Scheduling\JobRepository($db, new \Shabbat\Time\SystemClock()))->queueMetrics();
+ $version = trim((string)file_get_contents(dirname(__DIR__) . '/VERSION'));
+ echo Database::encode(['healthy' => $healthy,'version' => $version,'php' => PHP_VERSION,'commit' => \Shabbat\Runtime\Version::commit(),
+ 'heartbeat_age_seconds' => $heartbeatAge,'notification_heartbeat_age_seconds' => $notificationAge,'scheduler_cycles' => $heartbeat['cycles'] ?? [],'delivery_queue' => $queue,'sqlite_version' => $db->pdo()->query('SELECT sqlite_version()')->fetchColumn(),'deep_check' => $deep,'checks' => $checks,'schema' => $schema,'jobs' => $jobs,'locks' => $locks,'geonames_configured' => (bool)getenv('GEONAMES_USERNAME')]) . "\n";
+ exit($healthy ? 0 : 1);
+} catch (Throwable $error) {
+ fwrite(STDERR, $error->getMessage() . "\n");
+ exit(1);
+}
diff --git a/bin/import-legacy.php b/bin/import-legacy.php
new file mode 100644
index 0000000..7523e65
--- /dev/null
+++ b/bin/import-legacy.php
@@ -0,0 +1,45 @@
+databasePath);
+ if (!$dryRun) {
+ if (!isset($options['writers-stopped'])) {
+ throw new RuntimeException('Explicit import requires --writers-stopped.');
+ }
+ if (!is_file($path)) {
+ throw new RuntimeException('Run bin/migrate.php before applying legacy import.');
+ }
+ if (!isset($options['backup-dir']) || !is_dir($options['backup-dir'])) {
+ throw new RuntimeException('Specify --backup-dir for a verified pre-import backup.');
+ }
+ $lock = new InstanceLock($path . '.writer.lock');
+ $db = Database::open($path);
+ (new BackupService())->backup($db, $options['backup-dir'] . '/shabbat-backup-' . gmdate('Ymd-His') . '-' . bin2hex(random_bytes(4)) . '.sqlite');
+ } elseif (is_file($path)) {
+ $db = Database::open($path);
+ } else {
+ $db = Database::open(':memory:');
+ $db->migrate();
+ }
+ echo Database::encode((new LegacyImporter($db, new SystemClock()))->run((string)$options['legacy'], $dryRun)) . "\n";
+} catch (Throwable $error) {
+ fwrite(STDERR, $error->getMessage() . "\n");
+ exit(1);
+}
diff --git a/bin/migrate.php b/bin/migrate.php
new file mode 100644
index 0000000..e6bdfde
--- /dev/null
+++ b/bin/migrate.php
@@ -0,0 +1,30 @@
+databasePath);
+ $existed = is_file($path);
+ $lock = new InstanceLock($path . '.writer.lock');
+ $db = Database::open($path);
+ $backup = null;
+ if ($existed) {
+ if (!isset($options['backup-dir']) || !is_dir($options['backup-dir'])) {
+ throw new RuntimeException('An existing database requires --backup-dir with an existing directory.');
+ }
+ $backup = (new BackupService())->backup($db, $options['backup-dir'] . '/shabbat-backup-' . gmdate('Ymd-His') . '-' . bin2hex(random_bytes(4)) . '.sqlite');
+ }
+ echo Database::encode(['migrations' => $db->migrate(),'backup' => $backup,'checks' => $db->integrity()]) . "\n";
+} catch (Throwable $error) {
+ fwrite(STDERR, $error->getMessage() . "\n");
+ exit(1);
+}
diff --git a/bin/restore.php b/bin/restore.php
new file mode 100644
index 0000000..d8f87af
--- /dev/null
+++ b/bin/restore.php
@@ -0,0 +1,19 @@
+databasePath);
+ echo Database::encode(BackupService::restore((string)$options['backup'], $path, isset($options['writers-stopped']))) . "\n";
+} catch (Throwable $error) {
+ fwrite(STDERR, $error->getMessage() . "\n");
+ exit(1);
+}
diff --git a/bin/runtime-permissions.php b/bin/runtime-permissions.php
new file mode 100644
index 0000000..81f8c57
--- /dev/null
+++ b/bin/runtime-permissions.php
@@ -0,0 +1,175 @@
+ '5dc87da7b6dec039a55c3a494bd8aee60316ef6b102e4b0aabdc9b3bcccb0e03',
+ 'alt_prime.py' => 'ec63d5de8cce42908bc8bf1043626c552c7eae5318658217a1093d093b199bfc',
+ ];
+ foreach ($sources as $name => $approvedHash) {
+ $source = $root . '/vendor/danog/primemodule/lib/danog/' . $name;
+ if (!is_file($source) || hash_file('sha256', $source) !== $approvedHash) {
+ throw new RuntimeException('A PrimeModule source differs from the reviewed version.');
+ }
+ $contents = file_get_contents($source);
+ if (!is_string($contents)) {
+ throw new RuntimeException('A reviewed PrimeModule source is unreadable.');
+ }
+ $create($cwd . '/' . $prefix . '-' . $name, $contents);
+ }
+
+ $stage = 'configured data directory';
+ $paths = RuntimePaths::resolve($root);
+ if (!is_dir($paths->dataDirectory)) {
+ throw new RuntimeException('The configured data directory must already exist.');
+ }
+ $create($paths->dataDirectory . '/' . $prefix . '-data.tmp', 'offline runtime write probe');
+ $stage = 'configured session parent';
+ $sessionParent = dirname($paths->sessionPath);
+ if (!is_dir($sessionParent)) {
+ throw new RuntimeException('The configured session parent must already exist.');
+ }
+ $create($sessionParent . '/' . $prefix . '-session.tmp', 'offline runtime write probe');
+
+ $stage = 'default MadelineProto worker logger';
+ $logPath = $cwd . '/MadelineProto.log';
+ if (is_link($logPath) || (file_exists($logPath) && !is_file($logPath))) {
+ throw new RuntimeException('The default worker log is not a regular file.');
+ }
+ if (!file_exists($logPath)) {
+ $create($logPath, '');
+ }
+ define('MADELINE_WORKER', true);
+ Magic::start(light: true);
+ // The bot entrypoint is at /app/bot.php. This bin script must probe its CWD,
+ // rather than Magic's backtrace-derived /app/bin fallback for CLI helpers.
+ Magic::$script_cwd = $cwd;
+ Magic::$cwd = $cwd;
+ set_error_handler($errorHandler);
+ $settings = new LoggerSettings();
+ // Preserve existing operator logs even when larger than the default limit.
+ $settings->setMaxSize(-1);
+ if ($settings->getType() !== Logger::FILE_LOGGER || $settings->getExtra() !== $logPath) {
+ throw new RuntimeException('The worker logger did not select the default CWD log.');
+ }
+ $logger = Logger::constructorFromSettings($settings);
+ $loggerReady = true;
+ $logger->logger('Shabbat offline runtime permission probe.');
+ Logger::finalize();
+ $loggerReady = false;
+ Logger::$default = null;
+ unset($logger);
+ gc_collect_cycles();
+ clearstatcache(true, $logPath);
+ if (!is_file($logPath) || !is_writable($logPath) || filesize($logPath) === 0) {
+ throw new RuntimeException('The default worker log did not accept output.');
+ }
+} catch (Throwable) {
+ $failed = true;
+} finally {
+ if ($loggerReady) {
+ try {
+ Logger::finalize();
+ } catch (Throwable) {
+ $failed = true;
+ }
+ }
+ foreach ($created as $path => $identity) {
+ try {
+ clearstatcache(true, $path);
+ $current = lstat($path);
+ // Never remove a replaced file or a pre-existing operator artifact.
+ if (
+ !is_array($identity)
+ || !is_array($current)
+ || is_link($path)
+ || $current['dev'] !== $identity['dev']
+ || $current['ino'] !== $identity['ino']
+ || !unlink($path)
+ ) {
+ $failed = true;
+ $stage = 'probe cleanup';
+ }
+ } catch (Throwable) {
+ $failed = true;
+ $stage = 'probe cleanup';
+ }
+ }
+}
+
+if ($failed) {
+ fwrite(STDERR, 'Offline runtime permission check failed: ' . $stage . ".\n");
+ exit(1);
+}
+fwrite(STDOUT, "Offline runtime permissions verified: working directory, PrimeModule copies, worker logger, data and session.\n");
diff --git a/bin/self-check.php b/bin/self-check.php
new file mode 100644
index 0000000..b046266
--- /dev/null
+++ b/bin/self-check.php
@@ -0,0 +1,47 @@
+= 80600) {
+ $failures[] = 'Supported platform is 64-bit PHP 8.3, 8.4 or 8.5.';
+}
+try {
+ $pdo = new PDO('sqlite::memory:');
+ $sqlite = $pdo->query('SELECT sqlite_version()')->fetchColumn();
+ if (version_compare((string) $sqlite, '3.35.0', '<')) {
+ $failures[] = 'SQLite >=3.35 is required for supported database operations.';
+ }
+ $walFixed = version_compare((string) $sqlite, '3.51.3', '>=') || ((string) $sqlite === '3.44.6') || ((string) $sqlite === '3.50.7');
+ if (!$walFixed) {
+ $message = 'Loaded SQLite predates the verified WAL-reset fix. Use >=3.51.3 or a reviewed backport for production.';
+ if (in_array('--release', $argv, true)) {
+ $failures[] = $message;
+ } else {
+ $warnings[] = $message;
+ }
+ }
+} catch (Throwable $error) {
+ $sqlite = 'unavailable';
+ $failures[] = 'SQLite connection failed.';
+}
+try {
+ $patches = \Shabbat\Runtime\PatchSet::process($root, false);
+ $patch = $patches['madelineproto'];
+} catch (Throwable $error) {
+ $patch = 'FAILED';
+ $failures[] = $error->getMessage();
+}
+$output = ['version' => Shabbat\Runtime\Version::current($root), 'commit' => Shabbat\Runtime\Version::commit($root), 'php' => PHP_VERSION, 'sqlite' => $sqlite, 'timezone' => (new DateTimeZone('Asia/Jerusalem'))->getName(), 'patch' => $patch, 'library_patches' => $patches ?? [], 'secrets_required' => false, 'warnings' => $warnings, 'failures' => $failures];
+echo json_encode($output, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . "\n";
+exit($failures === [] ? 0 : 1);
diff --git a/bot.php b/bot.php
index a4bdaba..758c5fe 100644
--- a/bot.php
+++ b/bot.php
@@ -7,7 +7,8 @@
* This file is Written by wizardloop!
* @author wizardloop
* @copyright wizardloop
- * @license https://opensource.org/license/mit MIT License
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Original MIT notice retained in LICENSES/SHABBAT-LEGACY-MIT.txt.
* @link wizardloop => https://wizardloop.t.me
*/
@@ -46,6 +47,7 @@
class Shabbat extends SimpleEventHandler
{
+use \Shabbat\Runtime\BotIntegration;
/*
* text - ืืจืืจืช ืืืื ืื ืืกื ืืืฆืืืช ืฉืืช ืืื
@@ -779,13 +781,15 @@ private function isShabbatOrHolidayNow(): bool {
* ืื ืืืื
*/
public function getReportPeers() {
- return array_map('trim', explode(',', parse_ini_file(__DIR__.'/.env')['ADMIN']));
+ return array_values(array_filter(array_map('trim', explode(',', getenv('ADMIN') ?: ''))));
}
public function onStart(): void {
-try {
- $this->sendMessageToAdmins("The system has been restarted!",parseMode: ParseMode::HTML);
-} catch (\Throwable $e) {}
+\Shabbat\Runtime\Runner::attachHandler($this, $this->v5StopWorkers(...));
+$this->v3App();
+\Shabbat\Runtime\Runner::notifyStartup(fn(\Amp\Cancellation $cancellation) => $this->sendMessageToAdmins(
+ "The system has been restarted!", parseMode: ParseMode::HTML, cancellation: $cancellation
+));
}
/*
@@ -800,6 +804,8 @@ public function ChannelsLeave(ChannelMessage $message): void {
#[FilterCommandCaseInsensitive('start')]
public function StartCommand(Incoming & PrivateMessage & IsNotEdited $message): void {
+$this->v3Message($message);
+return;
try {
$senderid = $message->senderId;
$messageid = $message->id;
@@ -909,6 +915,8 @@ public function StartCommand(Incoming & PrivateMessage & IsNotEdited $message):
#[FilterButtonQueryData('ืืืจื')]
public function BackCommand(callbackQuery $query) {
+$this->v3App()->ui->handleCallback($this->v3Query($query,'sb:start'));
+return;
try {
$userid = $query->userId;
$User_Full = $this->getInfo($userid);
@@ -1008,7 +1016,9 @@ public function BackCommand(callbackQuery $query) {
}
#[FilterButtonQueryData('ืืื ืืฉืืช')]
-public function ShabbatTimes(callbackQuery $query) {
+public function ShabbatTimes(callbackQuery $query) {
+$this->v3App()->ui->handleCallback($this->v3Query($query,'sb:show:shabbat'));
+return;
try {
$bot_API_markup[] = [['text'=>"ืืืจื",'callback_data'=>"ืืืจื"]];
$bot_API_markup = [ 'inline_keyboard'=> $bot_API_markup,];
@@ -1025,6 +1035,8 @@ public function ShabbatTimes(callbackQuery $query) {
#[FilterButtonQueryData('ืืืืคืงืืืืช')]
public function AllCommands(callbackQuery $query) {
+$this->v3App()->ui->handleCallback($this->v3Query($query,'sb:help'));
+return;
try {
$txtbot = "ืืจืืืื ืืืืื ืืชืคืจืื ืืขืืจื! ๐
ืืชืคืจืื ืื ืชืืฆืื ืืช ืื ืืคืงืืืืช ืืืืืืข";
@@ -1071,6 +1083,8 @@ public function Rules(callbackQuery $query) {
#[FilterButtonQueryData('ืคืงืืืืชืืื ืืืื')]
public function CommandForAdmins(callbackQuery $query) {
+$this->v3App()->ui->handleCallback($this->v3Query($query,'sb:help'));
+return;
try {
$txtbot = "๐ก ืจืฉืืืช ืคืงืืืืช ืืืื ืืช:
/add - ืฉืืืืช ืคืงืืื ืื ืืงืืืฆื ืชืืกืืฃ ืืช ืืงืืืฆื ืืืกืืก ื ืชืื ืื ืขื ืื ืช ืฉืืื ืชืกืืจ ืืฉืืชืืช ืืืืื!
@@ -1100,6 +1114,8 @@ public function CommandForAdmins(callbackQuery $query) {
#[FilterButtonQueryData('ืคืงืืืืชืืืืืืฉืชืืฉืื')]
public function CommandForAll(callbackQuery $query) {
+$this->v3App()->ui->handleCallback($this->v3Query($query,'sb:help'));
+return;
try {
$me = $this->getSelf();
$me_username = '@'.$me['username'];
@@ -1129,6 +1145,8 @@ public function CommandForAll(callbackQuery $query) {
#[FiltersOr(new FilterCommandCaseInsensitive('shabat'), new FilterCommandCaseInsensitive('shabbat'))]
public function shabatCommand(Incoming & IsNotEdited $message): void {
+$this->v3Message($message);
+return;
try {
$senderid = $message->senderId;
$messageid = $message->id;
@@ -1161,6 +1179,8 @@ public function shabatCommand(Incoming & IsNotEdited $message): void {
}
public function onUpdateBotInlineQuery($update) {
+$this->v3App()->ui->handleInline($update);
+return;
try {
$ShabatTimes = $this->getZmanimForCities();
@@ -1221,6 +1241,18 @@ public function closecommand(callbackQuery $query) {
#[FilterCommandCaseInsensitive('add')]
public function addgroupCommand(Incoming & GroupMessage & IsNotEdited $message): void {
+$app=$this->v3App();
+if(!$app->gateway->isGroupAdmin((string)$message->senderId,(string)$message->chatId)){$message->reply($this->v3Text((string)$message->senderId,'permission_required'));return;}
+$info=$this->getInfo($message->chatId);
+$existing=$app->settings->getGroup((string)$message->chatId);$new=$existing===null;
+if($existing!==null&&!$existing['active']){
+$app->settings->reactivateGroup((string)$message->chatId,(string)$message->senderId,(string)($info['Chat']['title']??$message->chatId));
+}else{
+$app->settings->ensureGroup((string)$message->chatId,(string)($info['Chat']['title']??$message->chatId),['mode'=>'off']);
+}
+if($new){$app->gateway->sendPrivate((string)$message->senderId,$this->v3Text((string)$message->senderId,'group_new_wizard'));}
+$this->v3Message($message,'/settings');
+return;
try {
$senderid = $message->senderId;
$messageid = $message->id;
@@ -1347,6 +1379,10 @@ public function addgroupCommand(Incoming & GroupMessage & IsNotEdited $message):
#[FilterCommandCaseInsensitive('remove')]
public function removegroupCommand(Incoming & GroupMessage & IsNotEdited $message): void {
+$app=$this->v3App();
+if(!$app->gateway->isGroupAdmin((string)$message->senderId,(string)$message->chatId)){$message->reply($this->v3Text((string)$message->senderId,'permission_required'));return;}
+try{$app->settings->deactivateGroup((string)$message->chatId,(string)$message->senderId);$message->reply($this->v3Text((string)$message->senderId,'group_removed'));}catch(\Throwable $error){$message->reply($error->getMessage());}
+return;
try {
$senderid = $message->senderId;
$messageid = $message->id;
@@ -1494,6 +1530,8 @@ public function removegroupCommand(Incoming & GroupMessage & IsNotEdited $messag
#[FilterCommandCaseInsensitive('settings')]
public function grupsettingsCommand(Incoming & GroupMessage & IsNotEdited $message): void {
+$this->v3Message($message);
+return;
try {
$senderid = $message->senderId;
$messageid = $message->id;
@@ -1653,6 +1691,8 @@ public function grupsettingsCommand(Incoming & GroupMessage & IsNotEdited $messa
#[FiltersOr(new FilterCommandCaseInsensitive('add'), new FilterCommandCaseInsensitive('remove'), new FilterCommandCaseInsensitive('settings'))]
public function ifNotCommands(Incoming & PrivateMessage & IsNotEdited $message): void {
+$this->v3Message($message);
+return;
try {
$messageid = $message->id;
@@ -1669,6 +1709,7 @@ public function ifNotCommands(Incoming & PrivateMessage & IsNotEdited $message):
#[FilterButtonQueryData('ืืืจืืืืืืจืืช')]
public function backtosettings(callbackQuery $query) {
+if(!$this->legacySettingsPanelIsCurrent($query)){return;}
try {
$userid = $query->userId;
$chatid = $query->chatId;
@@ -1710,6 +1751,7 @@ public function backtosettings(callbackQuery $query) {
#[FilterButtonQueryData('ืืืืจืืจืืจืชืืืื')]
public function defaultset(callbackQuery $query) {
+if(!$this->legacySettingsPanelIsCurrent($query)){return;}
try {
$userid = $query->userId;
$chatid = $query->chatId;
@@ -1734,6 +1776,7 @@ public function defaultset(callbackQuery $query) {
#[FilterButtonQueryData('ืืืืจืืจืืจืชืืืืืืืฉืืจ')]
public function defaultset1(callbackQuery $query) {
+if(!$this->legacySettingsPanelIsCurrent($query)){return;}
try {
$userid = $query->userId;
$chatid = $query->chatId;
@@ -1801,6 +1844,7 @@ public function defaultset1(callbackQuery $query) {
#[FilterButtonQueryData('ืืกืืจืืื ืืฉืืช')]
public function infotimes(callbackQuery $query) {
+if(!$this->legacySettingsPanelIsCurrent($query)){return;}
try {
$query->answer($message = "ืืื ืืงืืืฆื ืชืงืื ืืืื ืืื ืฉืืฉื / ืืื ืื (ืืฉืขื 13:30) ืืืืขื ืขื ืืื ื ืื ืืกืช ืืฉืืช/ืื!", $alert = true, $url = null, $cacheTime = 0);
} catch (Throwable $e) {
@@ -1809,6 +1853,7 @@ public function infotimes(callbackQuery $query) {
#[FilterButtonQueryData('ืืกืืจืืืืขืืชืืคืื')]
public function infomessages(callbackQuery $query) {
+if(!$this->legacySettingsPanelIsCurrent($query)){return;}
try {
$query->answer($message = "ืืื ืืงืืืฆื ืชืงืื ืืืื ืืื ืฉืืฉื/ืืื ืื ืืืืขื ืฉืชืฉืื ืืขืจื ืืื ืืกื(ืืฉืืงืืืฆื ื ืกืืจืช) ืืืืื ืืืืื(ืืฆืืื) ืืฉืืงืืืฆื ื ืคืชืืช!
โข ื ืืชื ืืืฉืชืืฉ ืืืจืืจืช ืืืื.
@@ -1819,6 +1864,7 @@ public function infomessages(callbackQuery $query) {
#[FilterButtonQueryData('ืฉืืืืื ื')]
public function TimesON(callbackQuery $query) {
+if(!$this->legacySettingsPanelIsCurrent($query)){return;}
try {
$userid = $query->userId;
$chatid = $query->chatId;
@@ -1842,6 +1888,7 @@ public function TimesON(callbackQuery $query) {
#[FilterButtonQueryData('ืฉืืืืื ื1')]
public function TimesOFF(callbackQuery $query) {
+if(!$this->legacySettingsPanelIsCurrent($query)){return;}
try {
$userid = $query->userId;
$chatid = $query->chatId;
@@ -1867,6 +1914,7 @@ public function TimesOFF(callbackQuery $query) {
#[FilterButtonQueryData('ืืืืขืืชืืคื ืืืืืจื')]
public function MessagesON(callbackQuery $query) {
+if(!$this->legacySettingsPanelIsCurrent($query)){return;}
try {
$userid = $query->userId;
$chatid = $query->chatId;
@@ -1891,6 +1939,7 @@ public function MessagesON(callbackQuery $query) {
#[FilterButtonQueryData('ืืืืขืืชืืคื ืืืืืจื1')]
public function MessagesOFF(callbackQuery $query) {
+if(!$this->legacySettingsPanelIsCurrent($query)){return;}
try {
$userid = $query->userId;
$chatid = $query->chatId;
@@ -2522,6 +2571,7 @@ private function validateButtonsInput(string $input): bool {
#[FilterButtonQueryData('ืืืืขืชืคืชืืื')]
public function openMessageGroup(callbackQuery $query) {
+if(!$this->legacySettingsPanelIsCurrent($query)){return;}
try {
$userid = $query->userId;
$chatid = $query->chatId;
@@ -2551,6 +2601,7 @@ public function openMessageGroup(callbackQuery $query) {
#[FilterButtonQueryData('ืืืืขืชืกืืืจื')]
public function closeGroupMessage(callbackQuery $query) {
+if(!$this->legacySettingsPanelIsCurrent($query)){return;}
try {
$userid = $query->userId;
$chatid = $query->chatId;
@@ -2580,6 +2631,7 @@ public function closeGroupMessage(callbackQuery $query) {
#[FilterButtonQueryData('ืืืจืืชืคืจืืืืืืขื')]
public function backGroupMessage(callbackQuery $query) {
+if(!$this->legacySettingsPanelIsCurrent($query)){return;}
try {
$userid = $query->userId;
$chatid = $query->chatId;
@@ -2623,6 +2675,7 @@ public function backGroupMessage(callbackQuery $query) {
#[FilterButtonQueryData('ืืืืจืืงืกื')]
public function GroupTextSet1(callbackQuery $query) {
+if(!$this->legacySettingsPanelIsCurrent($query)){return;}
try {
$userid = $query->userId;
$User_Full = $this->getInfo($userid);
@@ -2688,6 +2741,7 @@ public function GroupTextSet1(callbackQuery $query) {
#[FilterButtonQueryData('ืืืงืืงืกื')]
public function RemoveText1(callbackQuery $query) {
+if(!$this->legacySettingsPanelIsCurrent($query)){return;}
try {
$userid = $query->userId;
$User_Full = $this->getInfo($userid);
@@ -2705,6 +2759,7 @@ public function RemoveText1(callbackQuery $query) {
#[FilterButtonQueryData('ืืืงืืงืกืืืืฉืืจ')]
public function RemoveText1_1(callbackQuery $query) {
+if(!$this->legacySettingsPanelIsCurrent($query)){return;}
try {
$userid = $query->userId;
$User_Full = $this->getInfo($userid);
@@ -2755,6 +2810,7 @@ public function RemoveText1_1(callbackQuery $query) {
#[FilterButtonQueryData('ืฆืคืืืืงืกื')]
public function GroupTextView1(callbackQuery $query) {
+if(!$this->legacySettingsPanelIsCurrent($query)){return;}
try {
$userid = $query->userId;
$msgqutryid = $query->messageId;
@@ -2846,6 +2902,7 @@ public function GroupTextView1(callbackQuery $query) {
#[FilterButtonQueryData('ืืืืจืืืื')]
public function GroupMsgMedia1(callbackQuery $query) {
+if(!$this->legacySettingsPanelIsCurrent($query)){return;}
try {
$userid = $query->userId;
$User_Full = $this->getInfo($userid);
@@ -2907,6 +2964,7 @@ public function GroupMsgMedia1(callbackQuery $query) {
#[FilterButtonQueryData('ืฆืคืืืืืื')]
public function GroupMsgMediaView1(callbackQuery $query) {
+if(!$this->legacySettingsPanelIsCurrent($query)){return;}
try {
$userid = $query->userId;
$msgqutryid = $query->messageId;
@@ -2999,6 +3057,7 @@ public function GroupMsgMediaView1(callbackQuery $query) {
#[FilterButtonQueryData('ืืืงืืืื')]
public function RemoveMedia1(callbackQuery $query) {
+if(!$this->legacySettingsPanelIsCurrent($query)){return;}
try {
$userid = $query->userId;
$User_Full = $this->getInfo($userid);
@@ -3016,6 +3075,7 @@ public function RemoveMedia1(callbackQuery $query) {
#[FilterButtonQueryData('ืืืงืืืืืืืฉืืจ')]
public function RemoveMedia1_1(callbackQuery $query) {
+if(!$this->legacySettingsPanelIsCurrent($query)){return;}
try {
$userid = $query->userId;
$User_Full = $this->getInfo($userid);
@@ -3063,6 +3123,7 @@ public function RemoveMedia1_1(callbackQuery $query) {
#[FilterButtonQueryData('ืฆืคืืืืคืชืืจืื')]
public function buttonsmanageviewgroup1(callbackQuery $query) {
+if(!$this->legacySettingsPanelIsCurrent($query)){return;}
try {
$userid = $query->userId;
$User_Full = $this->getInfo($userid);
@@ -3182,6 +3243,7 @@ public function buttonsmanageviewgroup1(callbackQuery $query) {
#[FilterButtonQueryData('ืืืืจืืคืชืืจืื')]
public function GroupMsgButtonSet1(callbackQuery $query) {
+if(!$this->legacySettingsPanelIsCurrent($query)){return;}
try {
$userid = $query->userId;
$User_Full = $this->getInfo($userid);
@@ -3389,6 +3451,7 @@ public function GroupMsgButtonSet1(callbackQuery $query) {
#[FilterButtonQueryData('ืืืงืืคืชืืจืื')]
public function RemoveButtons1(callbackQuery $query) {
+if(!$this->legacySettingsPanelIsCurrent($query)){return;}
try {
$userid = $query->userId;
$User_Full = $this->getInfo($userid);
@@ -3406,6 +3469,7 @@ public function RemoveButtons1(callbackQuery $query) {
#[FilterButtonQueryData('ืืืงืืคืชืืจืืืืืฉืืจ')]
public function RemoveButtons1_1(callbackQuery $query) {
+if(!$this->legacySettingsPanelIsCurrent($query)){return;}
try {
$userid = $query->userId;
$User_Full = $this->getInfo($userid);
@@ -3453,6 +3517,7 @@ public function RemoveButtons1_1(callbackQuery $query) {
#[Handler]
public function HandleGroupMsgSet1(Incoming & PrivateMessage $message): void {
+return; // Edits are accepted only by the new scoped TemplateEditor.
try {
$messagetext = $message->message;
$entities = $message->entities;
@@ -3946,6 +4011,7 @@ public function HandleGroupMsgSet1(Incoming & PrivateMessage $message): void {
#[FilterButtonQueryData('ืชืฆืืืืืงืืืืืคืชืืื')]
public function view_welcomeMessage_full(callbackQuery $query) {
+if(!$this->legacySettingsPanelIsCurrent($query)){return;}
try {
$userid = $query->userId;
$msgqutryid = $query->messageId;
@@ -4253,6 +4319,9 @@ public function view_welcomeMessage_full(callbackQuery $query) {
public function StatsGroups(
Incoming & IsNotEdited $message
): void {
+$this->v3Statistics($message);
+return;
+
try {
@@ -4265,7 +4334,7 @@ public function StatsGroups(
$sentMessage
);
- $dialogs = $this->getDialogIds();
+ $dialogs = $this->v3KnownPeers();
$supergroups = 0;
$normalGroups = 0;
@@ -4381,8 +4450,21 @@ public function StatsGroups(
}
/* ================ cron ================ */
-#[Cron(period: 60.0)]
+#[Cron(period: 1.0)]
+public function shabatNotifications(): void {
+$this->v3App()->notificationTick();
+$this->v4FlushTefillinAcknowledgements();
+}
+
+#[Cron(period: 15.0)]
+public function shabbatBroadcastCron(): void {
+$this->v4RunBroadcastJobs();
+}
+
+#[Cron(period: 15.0)]
public function shabatCronClose(): void {
+$this->v3App()->permissionTick();
+return;
try {
date_default_timezone_set("Asia/Jerusalem");
@@ -4593,6 +4675,7 @@ public function shabatCronClose(): void {
#[Cron(period: 60.0)]
public function shabatCronOpen(): void {
+return; // Restoration belongs to the per-group reconciler.
try {
date_default_timezone_set("Asia/Jerusalem");
@@ -4773,6 +4856,7 @@ public function shabatCronOpen(): void {
#[Cron(period: 60.0)]
public function shabatCronAlert(): void {
+return; // Announcements belong to the persistent outbox.
try {
date_default_timezone_set("Asia/Jerusalem");
@@ -4862,6 +4946,8 @@ public function shabatCronAlert(): void {
/* ================ payments / donate ================ */
#[FilterCommandCaseInsensitive('donate')]
public function Payments(Incoming & PrivateMessage & IsNotEdited $message): void {
+$this->v3DonationMenu((string)$message->senderId);
+return;
try {
$messagetext = $message->message;
$messageid = $message->id;
@@ -4946,6 +5032,8 @@ public function Payments(Incoming & PrivateMessage & IsNotEdited $message): void
} catch (Throwable $e) {}
}
public function onupdateBotPrecheckoutQuery($update) {
+$this->v3App()->donations?->answerPrecheckout($update);
+return;
try{
if ($this->isSelfBot()) {
$userid = $update['user_id'];
@@ -4956,6 +5044,8 @@ public function onupdateBotPrecheckoutQuery($update) {
} catch (\Throwable $e) {}
}
public function onUpdateNewMessage($update) {
+if($this->v3RawLocation($update)){return;}
+if(($update['message']['action']['_']??'')==='messageActionPaymentSentMe'){$this->v3RecordPayment($update);return;}
try{
if ($this->isSelfBot()) {
$msg = $update['message'];
@@ -4994,7 +5084,7 @@ public function onUpdateNewMessage($update) {
$currency = $msg['action']['currency'];
$payload = (string) $msg['action']['payload'];
$charge = $msg['action']['charge']['id'];
-echo $charge;
+// Payment charge identifiers stay out of logs.
$parts = explode('|', $payload);
if (count($parts) < 3) return;
$type = $parts[0];
@@ -5034,8 +5124,11 @@ public function onUpdateNewMessage($update) {
*/
#[FilterCommandCaseInsensitive('closegroups')]
public function closeGroupManually(
- Incoming & PrivateMessage & FromAdmin & IsNotEdited $message
+ Incoming & PrivateMessage & IsNotEdited $message
): void {
+$this->v3ManualGroups($message,true);
+return;
+
try {
@@ -5213,8 +5306,11 @@ public function closeGroupManually(
*/
#[FilterCommandCaseInsensitive('opengroups')]
public function openGroupManually(
- Incoming & PrivateMessage & FromAdmin & IsNotEdited $message
+ Incoming & PrivateMessage & IsNotEdited $message
): void {
+$this->v3ManualGroups($message,false);
+return;
+
try {
@@ -5328,8 +5424,11 @@ public function openGroupManually(
*/
#[FilterCommandCaseInsensitive('testzmanim')]
public function testzmanim(
- Incoming & PrivateMessage & FromAdmin & IsNotEdited $message
+ Incoming & PrivateMessage & IsNotEdited $message
): void {
+$this->v3TestZmanim($message);
+return;
+
try {
@@ -5392,8 +5491,11 @@ public function testzmanim(
* ืืืืจืช ืคืงืืืืช
*/
#[FilterCommandCaseInsensitive('setcommands')]
-public function setcommands(Incoming & PrivateMessage & FromAdmin & IsNotEdited $message): void
+public function setcommands(Incoming & PrivateMessage & IsNotEdited $message): void
{
+$this->v3SetCommands($message);
+return;
+
try {
$this->bots->setBotCommands(
@@ -5563,7 +5665,8 @@ public function getAdminKeyboard() {
}
#[FilterCommandCaseInsensitive('admin')]
-public function admincommand(Incoming & PrivateMessage & FromAdmin & IsNotEdited $message): void {
+public function admincommand(Incoming & PrivateMessage & IsNotEdited $message): void {
+$this->v3OperatorMain($message); return;
try {
$senderid = $message->senderId;
$User_Full = $this->getInfo($message->senderId);
@@ -5587,14 +5690,17 @@ public function admincommand(Incoming & PrivateMessage & FromAdmin & IsNotEdited
/restart - ืืชืื ืืช ืืืขืจืืช
", reply_markup: $markup, parse_mode: 'HTML');
- if (file_exists("data/$senderid/grs1.txt")) {
-unlink("data/$senderid/grs1.txt");
+ if (\Shabbat\Storage\AdminStateFiles::exists("data/$senderid/grs1.txt")) {
+\Shabbat\Storage\AdminStateFiles::delete("data/$senderid/grs1.txt");
}
} catch (Throwable $e) {}
}
#[FilterButtonQueryData('ืืืจืืื ืื')]
public function addsohe1hazor(callbackQuery $query) {
+if(!$this->legacySystemQueryAllowed($query)){return;}
+$this->v3OperatorPanel($query,'main'); return;
+
try {
$userid = $query->userId;
$User_Full = $this->getInfo($userid);
@@ -5613,8 +5719,8 @@ public function addsohe1hazor(callbackQuery $query) {
/restart - ืืชืื ืืช ืืืขืจืืช
", $replyMarkup = $markup, ParseMode::HTML, $noWebpage = false, $scheduleDate = NULL);
-if (file_exists("data/$userid/grs1.txt")) {
-unlink("data/$userid/grs1.txt");
+if (\Shabbat\Storage\AdminStateFiles::exists("data/$userid/grs1.txt")) {
+\Shabbat\Storage\AdminStateFiles::delete("data/$userid/grs1.txt");
}
}
} catch (Throwable $e) {}
@@ -5622,6 +5728,9 @@ public function addsohe1hazor(callbackQuery $query) {
#[FilterButtonQueryData('ืืืจืืื ืื2')]
public function addsohe1hazor2(callbackQuery $query) {
+if(!$this->legacySystemQueryAllowed($query)){return;}
+$this->v3OperatorPanel($query,'clear'); return;
+
try {
$userid = $query->userId;
$msgid = $query->messageId;
@@ -5641,21 +5750,21 @@ public function addsohe1hazor2(callbackQuery $query) {
", reply_markup: $markup, parse_mode: 'HTML');
-if (file_exists(__DIR__."/data/$userid/grs1.txt")) {
-unlink(__DIR__."/data/$userid/grs1.txt");
+if (\Shabbat\Storage\AdminStateFiles::exists(__DIR__."/data/$userid/grs1.txt")) {
+\Shabbat\Storage\AdminStateFiles::delete(__DIR__."/data/$userid/grs1.txt");
}
- if (file_exists(__DIR__."/data/BUTTONS.txt")) {
-unlink(__DIR__."/data/BUTTONS.txt");
+ if (\Shabbat\Storage\AdminStateFiles::exists(__DIR__."/data/BUTTONS.txt")) {
+\Shabbat\Storage\AdminStateFiles::delete(__DIR__."/data/BUTTONS.txt");
}
- if (file_exists(__DIR__."/data/$userid/txt.txt")) {
-unlink(__DIR__."/data/$userid/txt.txt");
+ if (\Shabbat\Storage\AdminStateFiles::exists(__DIR__."/data/$userid/txt.txt")) {
+\Shabbat\Storage\AdminStateFiles::delete(__DIR__."/data/$userid/txt.txt");
}
- if (file_exists(__DIR__."/data/$userid/ent.txt")) {
-unlink(__DIR__."/data/$userid/ent.txt");
+ if (\Shabbat\Storage\AdminStateFiles::exists(__DIR__."/data/$userid/ent.txt")) {
+\Shabbat\Storage\AdminStateFiles::delete(__DIR__."/data/$userid/ent.txt");
}
- if (file_exists(__DIR__."/data/$userid/media.txt")) {
-unlink(__DIR__."/data/$userid/media.txt");
+ if (\Shabbat\Storage\AdminStateFiles::exists(__DIR__."/data/$userid/media.txt")) {
+\Shabbat\Storage\AdminStateFiles::delete(__DIR__."/data/$userid/media.txt");
}
}
@@ -5664,7 +5773,7 @@ public function addsohe1hazor2(callbackQuery $query) {
/* ================ restart ================ */
public static function getPlugins(): array {
- return [\danog\MadelineProto\EventHandler\Plugin\RestartPlugin::class];
+ return []; // Restart is handled by BotIntegration::gracefulRestart with application shutdown fences.
}
public static function getPluginPaths(): string|array|null {
return null;
@@ -5675,6 +5784,9 @@ public static function getPluginPaths(): string|array|null {
#[FilterButtonQueryData('ืจืฉืืืชืืฉืชืืฉืื')]
public function reshimamishtamshim(callbackQuery $query)
{
+if(!$this->legacySystemQueryAllowed($query)){return;}
+$this->v3OperatorPanel($query,'subscribers'); return;
+
try {
$userid = $query->userId;
$User_Full = $this->getInfo($userid);
@@ -5683,12 +5795,12 @@ public function reshimamishtamshim(callbackQuery $query)
$first_name = "null";
}
-$dialogs = $this->getDialogIds();
+$dialogs = $this->v3KnownPeers();
$newLangsComma = implode("\n", $dialogs);
-Amp\File\write(__DIR__."/ids.txt",$newLangsComma);
+\Shabbat\Storage\AdminStateFiles::write(__DIR__."/ids.txt",$newLangsComma);
- if (file_exists(__DIR__."/ids.txt")) {
-$filex = Amp\File\read(__DIR__."/ids.txt");
+ if (\Shabbat\Storage\AdminStateFiles::exists(__DIR__."/ids.txt")) {
+$filex = \Shabbat\Storage\AdminStateFiles::read(__DIR__."/ids.txt");
$numFruits = count($dialogs);
if($filex != null){
@@ -5696,14 +5808,14 @@ public function reshimamishtamshim(callbackQuery $query)
$outputFile = __DIR__."/idsnew.txt";
$startLine = 1;
$endLine = 5;
-$lines = file($file);
+$lines = \Shabbat\Storage\AdminStateFiles::lines($file);
$selectedLines = array_slice($lines, $startLine - 1, $endLine - $startLine + 1);
-Amp\File\write($outputFile, implode("", $selectedLines));
-$outputFilex = Amp\File\read(__DIR__."/idsnew.txt");
+\Shabbat\Storage\AdminStateFiles::write($outputFile, implode("", $selectedLines));
+$outputFilex = \Shabbat\Storage\AdminStateFiles::read(__DIR__."/idsnew.txt");
-Amp\File\write(__DIR__."/data/startline_cat.txt","$startLine");
-Amp\File\write(__DIR__."/data/endline_cat.txt","$endLine");
-Amp\File\write(__DIR__."/data/page_var.txt","1");
+\Shabbat\Storage\AdminStateFiles::write(__DIR__."/data/startline_cat.txt","$startLine");
+\Shabbat\Storage\AdminStateFiles::write(__DIR__."/data/endline_cat.txt","$endLine");
+\Shabbat\Storage\AdminStateFiles::write(__DIR__."/data/page_var.txt","1");
$category = array_filter(array_map('trim', explode("\n", $outputFilex)));
@@ -5762,7 +5874,7 @@ public function reshimamishtamshim(callbackQuery $query)
}
}
- if (!file_exists(__DIR__."/ids.txt")) {
+ if (!\Shabbat\Storage\AdminStateFiles::exists(__DIR__."/ids.txt")) {
if($filex == null){
$newLangsComma = "ืืื ืขืืืื ืืฉืชืืฉืื..";
$bot_API_markup[] = [['text'=>"ืืืจื",'callback_data'=>"ืืืจืืื ืื"]];
@@ -5778,6 +5890,9 @@ public function reshimamishtamshim(callbackQuery $query)
#[FilterButtonQueryData('ืจืฉืืืชืื ืืืืืืืฉื')]
public function reshimamishtamshim2(callbackQuery $query) {
+if(!$this->legacySystemQueryAllowed($query)){return;}
+$this->v3OperatorPanel($query,'next'); return;
+
try {
$userid = $query->userId;
$User_Full = $this->getInfo($userid);
@@ -5786,17 +5901,17 @@ public function reshimamishtamshim2(callbackQuery $query) {
$first_name = "null";
}
-$dialogs = $this->getDialogIds();
+$dialogs = $this->v3KnownPeers();
$newLangsComma = implode("\n", $dialogs);
-Amp\File\write(__DIR__."/ids.txt",$newLangsComma);
+\Shabbat\Storage\AdminStateFiles::write(__DIR__."/ids.txt",$newLangsComma);
- if (file_exists(__DIR__."/ids.txt")) {
-$filex = Amp\File\read(__DIR__."/ids.txt");
+ if (\Shabbat\Storage\AdminStateFiles::exists(__DIR__."/ids.txt")) {
+$filex = \Shabbat\Storage\AdminStateFiles::read(__DIR__."/ids.txt");
$numFruits = count($dialogs);
if($filex != null){
-$startx = Amp\File\read(__DIR__."/data/startline_cat.txt");
-$endx = Amp\File\read(__DIR__."/data/endline_cat.txt");
+$startx = \Shabbat\Storage\AdminStateFiles::read(__DIR__."/data/startline_cat.txt");
+$endx = \Shabbat\Storage\AdminStateFiles::read(__DIR__."/data/endline_cat.txt");
@@ -5804,17 +5919,17 @@ public function reshimamishtamshim2(callbackQuery $query) {
$outputFile = __DIR__."/idsnew.txt";
$startLine = $startx + 5;
$endLine = $endx + 5;
-$lines = file($file);
+$lines = \Shabbat\Storage\AdminStateFiles::lines($file);
$selectedLines = array_slice($lines, $startLine - 1, $endLine - $startLine + 1);
-Amp\File\write($outputFile, implode("", $selectedLines));
-$outputFilex = Amp\File\read(__DIR__."/idsnew.txt");
+\Shabbat\Storage\AdminStateFiles::write($outputFile, implode("", $selectedLines));
+$outputFilex = \Shabbat\Storage\AdminStateFiles::read(__DIR__."/idsnew.txt");
-$pages_varx = Amp\File\read(__DIR__."/data/page_var.txt");
+$pages_varx = \Shabbat\Storage\AdminStateFiles::read(__DIR__."/data/page_var.txt");
$pages_var = $pages_varx + 1;
-Amp\File\write(__DIR__."/data/startline_cat.txt","$startLine");
-Amp\File\write(__DIR__."/data/endline_cat.txt","$endLine");
-Amp\File\write(__DIR__."/data/page_var.txt","$pages_var");
+\Shabbat\Storage\AdminStateFiles::write(__DIR__."/data/startline_cat.txt","$startLine");
+\Shabbat\Storage\AdminStateFiles::write(__DIR__."/data/endline_cat.txt","$endLine");
+\Shabbat\Storage\AdminStateFiles::write(__DIR__."/data/page_var.txt","$pages_var");
$category = array_filter(array_map('trim', explode("\n", $outputFilex)));
@@ -5873,7 +5988,7 @@ public function reshimamishtamshim2(callbackQuery $query) {
}
}
- if (!file_exists(__DIR__."/ids.txt")) {
+ if (!\Shabbat\Storage\AdminStateFiles::exists(__DIR__."/ids.txt")) {
if($filex == null){
$newLangsComma = "ืืื ืขืืืื ืืฉืชืืฉืื..";
$bot_API_markup[] = [['text'=>"ืืืจื",'callback_data'=>"ืืืจืืื ืื"]];
@@ -5888,6 +6003,9 @@ public function reshimamishtamshim2(callbackQuery $query) {
#[FilterButtonQueryData('ืจืฉืืืชืื ืืืืืืงืืื')]
public function reshimamishtamshim3(callbackQuery $query) {
+if(!$this->legacySystemQueryAllowed($query)){return;}
+$this->v3OperatorPanel($query,'previous'); return;
+
try {
$userid = $query->userId;
$User_Full = $this->getInfo($userid);
@@ -5896,33 +6014,33 @@ public function reshimamishtamshim3(callbackQuery $query) {
$first_name = "null";
}
-$dialogs = $this->getDialogIds();
+$dialogs = $this->v3KnownPeers();
$newLangsComma = implode("\n", $dialogs);
-Amp\File\write(__DIR__."/ids.txt",$newLangsComma);
+\Shabbat\Storage\AdminStateFiles::write(__DIR__."/ids.txt",$newLangsComma);
- if (file_exists(__DIR__."/ids.txt")) {
-$filex = Amp\File\read(__DIR__."/ids.txt");
+ if (\Shabbat\Storage\AdminStateFiles::exists(__DIR__."/ids.txt")) {
+$filex = \Shabbat\Storage\AdminStateFiles::read(__DIR__."/ids.txt");
$numFruits = count($dialogs);
if($filex != null){
-$startx = Amp\File\read(__DIR__."/data/startline_cat.txt");
-$endx = Amp\File\read(__DIR__."/data/endline_cat.txt");
+$startx = \Shabbat\Storage\AdminStateFiles::read(__DIR__."/data/startline_cat.txt");
+$endx = \Shabbat\Storage\AdminStateFiles::read(__DIR__."/data/endline_cat.txt");
$file = __DIR__."/ids.txt";
$outputFile = __DIR__."/idsnew.txt";
$startLine = $startx - 5;
$endLine = $endx - 5;
-$lines = file($file);
+$lines = \Shabbat\Storage\AdminStateFiles::lines($file);
$selectedLines = array_slice($lines, $startLine - 1, $endLine - $startLine + 1);
-Amp\File\write($outputFile, implode("", $selectedLines));
-$outputFilex = Amp\File\read(__DIR__."/idsnew.txt");
+\Shabbat\Storage\AdminStateFiles::write($outputFile, implode("", $selectedLines));
+$outputFilex = \Shabbat\Storage\AdminStateFiles::read(__DIR__."/idsnew.txt");
-$pages_varx = Amp\File\read(__DIR__."/data/page_var.txt");
+$pages_varx = \Shabbat\Storage\AdminStateFiles::read(__DIR__."/data/page_var.txt");
$pages_var = $pages_varx - 1;
-Amp\File\write(__DIR__."/data/startline_cat.txt","$startLine");
-Amp\File\write(__DIR__."/data/endline_cat.txt","$endLine");
-Amp\File\write(__DIR__."/data/page_var.txt","$pages_var");
+\Shabbat\Storage\AdminStateFiles::write(__DIR__."/data/startline_cat.txt","$startLine");
+\Shabbat\Storage\AdminStateFiles::write(__DIR__."/data/endline_cat.txt","$endLine");
+\Shabbat\Storage\AdminStateFiles::write(__DIR__."/data/page_var.txt","$pages_var");
$category = array_filter(array_map('trim', explode("\n", $outputFilex)));
@@ -5982,7 +6100,7 @@ public function reshimamishtamshim3(callbackQuery $query) {
}
}
- if (!file_exists(__DIR__."/ids.txt")) {
+ if (!\Shabbat\Storage\AdminStateFiles::exists(__DIR__."/ids.txt")) {
if($filex == null){
$newLangsComma = "ืืื ืขืืืื ืืฉืชืืฉืื..";
$bot_API_markup[] = [['text'=>"ืืืจื",'callback_data'=>"ืืืจืืื ืื"]];
@@ -5996,13 +6114,16 @@ public function reshimamishtamshim3(callbackQuery $query) {
}
#[FilterButtonQueryData('var_cat')]
-public function var_cat_command(callbackQuery $query) {
+public function var_cat_command(callbackQuery $query) {
+if(!$this->legacySystemQueryAllowed($query)){return;}
+$this->v3OperatorPanel($query,'page'); return;
+
try {
- if (file_exists(__DIR__."/ids.txt")) {
-$filex = Amp\File\read(__DIR__."/ids.txt");
+ if (\Shabbat\Storage\AdminStateFiles::exists(__DIR__."/ids.txt")) {
+$filex = \Shabbat\Storage\AdminStateFiles::read(__DIR__."/ids.txt");
$numFruits = count(explode("\n",$filex));
-if (file_exists(__DIR__."/data/page_var.txt")) {
-$pages_varx = Amp\File\read(__DIR__."/data/page_var.txt");
+if (\Shabbat\Storage\AdminStateFiles::exists(__DIR__."/data/page_var.txt")) {
+$pages_varx = \Shabbat\Storage\AdminStateFiles::read(__DIR__."/data/page_var.txt");
$pages_var = $pages_varx;
$resultpages = ceil($numFruits / 5);
@@ -6014,6 +6135,10 @@ public function var_cat_command(callbackQuery $query) {
#[Handler]
public function adnimhandle(callbackQuery $query): void {
+if (!$query instanceof \danog\MadelineProto\EventHandler\Query\ButtonQuery || !str_starts_with($query->data,'openprofile_')) { return; }
+if(!$this->legacySystemQueryAllowed($query)){return;}
+$this->v3OperatorProfile($query); return;
+
try {
$userid = $query->userId;
$querydata = $query->data;
@@ -6135,8 +6260,8 @@ public function adnimhandle(callbackQuery $query): void {
} catch (Throwable $e) {}
-if (file_exists(__DIR__."/data/$userid/grs1.txt")) {
-unlink(__DIR__."/data/$userid/grs1.txt");
+if (\Shabbat\Storage\AdminStateFiles::exists(__DIR__."/data/$userid/grs1.txt")) {
+\Shabbat\Storage\AdminStateFiles::delete(__DIR__."/data/$userid/grs1.txt");
}
}
@@ -6149,6 +6274,9 @@ public function adnimhandle(callbackQuery $query): void {
#[FilterButtonQueryData('ืกืืืืกืืืงืืช')]
public function StatsUsers(callbackQuery $query)
{
+if(!$this->legacySystemQueryAllowed($query)){return;}
+$this->v3OperatorPanel($query,'stats'); return;
+
try {
$bot_API_markup = [
'inline_keyboard' => [
@@ -6158,7 +6286,7 @@ public function StatsUsers(callbackQuery $query)
$query->editText("โ๏ธ", null, ParseMode::HTML);
-$dialogs = $this->getDialogIds();
+$dialogs = $this->v3KnownPeers();
$numChannels = $numSupergroups = $numChats = $numBots = 0;
$numUsers = 0;
@@ -6203,6 +6331,8 @@ public function StatsUsers(callbackQuery $query)
#[FilterButtonQueryData('closeMsg')]
public function closeBroadcastMsg(callbackQuery $query) {
+if(!$this->legacySystemQueryAllowed($query)){return;}
+
try {
$this->messages->deleteMessages(revoke: true, id: [$query->messageId]);
} catch (\Throwable $e) {
@@ -6218,6 +6348,9 @@ public function closeBroadcastMsg(callbackQuery $query) {
#[FilterButtonQueryData('Broadcast')]
public function broadcastCommand(callbackQuery $query)
{
+if(!$this->legacySystemQueryAllowed($query)){return;}
+$this->v3OperatorPanel($query,'broadcast'); return;
+
try {
$userid = $query->userId;
$User_Full = $this->getInfo($userid);
@@ -6241,15 +6374,18 @@ public function broadcastCommand(callbackQuery $query)
#[FilterButtonQueryData('deleteLastBroadcast')]
public function deleteLastBroadcast(callbackQuery $query)
{
+if(!$this->legacySystemQueryAllowed($query)){return;}
+$this->v3OperatorMaintenance($query,'delete_last'); return;
+
try {
-$API = new \danog\MadelineProto\API(__DIR__.'/bot.shabbat');
+$API = new \danog\MadelineProto\API(getenv('SESSION_PATH') ?: __DIR__.'/bot.shabbat');
$manager = new BroadcastManager($API);
-BroadcastManager::setDataDir(__DIR__ . '/data');
+BroadcastManager::setDataDir(getenv('DATA_DIR') ?: __DIR__ . '/data');
if (!$manager->hasLastBroadcast()) {
$query->answer($message = "ืืื ืืืืขืช ืฉืืืืจ ืืืืืงื!", $alert = true, $url = null, $cacheTime = 0);
}else{
$query->answer($message = "ืื ื ืืืชื...", $alert = false, $url = null, $cacheTime = 0);
-$allUsers = $this->getDialogIds();
+$allUsers = $this->v3KnownPeers();
$manager->deleteLastBroadcastForAll($allUsers, $query->userId, 20);
}
} catch (Throwable $e) {}
@@ -6258,16 +6394,19 @@ public function deleteLastBroadcast(callbackQuery $query)
#[FilterButtonQueryData('deleteAllBroadcast')]
public function deleteAllBroadcast(callbackQuery $query)
{
+if(!$this->legacySystemQueryAllowed($query)){return;}
+$this->v3OperatorMaintenance($query,'delete_all'); return;
+
try {
-$API = new \danog\MadelineProto\API(__DIR__.'/bot.shabbat');
+$API = new \danog\MadelineProto\API(getenv('SESSION_PATH') ?: __DIR__.'/bot.shabbat');
$manager = new BroadcastManager($API);
-BroadcastManager::setDataDir(__DIR__ . '/data');
+BroadcastManager::setDataDir(getenv('DATA_DIR') ?: __DIR__ . '/data');
if (!$manager->hasAllBroadcast()) {
$query->answer($message = "ืืื ืืืืขืืช ืฉืืืืจ ืืืืืงื!", $alert = true, $url = null, $cacheTime = 0);
}else{
$query->answer($message = "ืื ื ืืืชื...", $alert = false, $url = null, $cacheTime = 0);
-$allUsers = $this->getDialogIds();
+$allUsers = $this->v3KnownPeers();
$manager->deleteAllBroadcastsForAll($allUsers, $query->userId, 20);
}
} catch (Throwable $e) {}
@@ -6276,12 +6415,15 @@ public function deleteAllBroadcast(callbackQuery $query)
#[FilterButtonQueryData('cancelPinned')]
public function cancelPinned(callbackQuery $query)
{
+if(!$this->legacySystemQueryAllowed($query)){return;}
+$this->v3OperatorMaintenance($query,'unpin'); return;
+
try {
-$api = new \danog\MadelineProto\API(__DIR__.'/bot.shabbat');
+$api = new \danog\MadelineProto\API(getenv('SESSION_PATH') ?: __DIR__.'/bot.shabbat');
$manager = new BroadcastManager($api);
-BroadcastManager::setDataDir(__DIR__ . '/data');
+BroadcastManager::setDataDir(getenv('DATA_DIR') ?: __DIR__ . '/data');
$query->answer($message = "ืื ื ืืืชื...", $alert = false, $url = null, $cacheTime = 0);
-$allUsers = $this->getDialogIds();
+$allUsers = $this->v3KnownPeers();
$subfilter = 'users';
$filter_sub = $manager->filterPeers($allUsers, $subfilter);
$subs = $filter_sub['targets'];
@@ -6291,12 +6433,15 @@ public function cancelPinned(callbackQuery $query)
#[FilterButtonQueryData('LastBrodDATA')]
public function LastBrodDATA(callbackQuery $query)
-{
+{
+if(!$this->legacySystemQueryAllowed($query)){return;}
+$this->v3OperatorPanel($query,'progress'); return;
+
try{
-$API = new \danog\MadelineProto\API(__DIR__.'/bot.shabbat');
+$API = new \danog\MadelineProto\API(getenv('SESSION_PATH') ?: __DIR__.'/bot.shabbat');
$manager = new BroadcastManager($API);
-BroadcastManager::setDataDir(__DIR__ . '/data');
+BroadcastManager::setDataDir(getenv('DATA_DIR') ?: __DIR__ . '/data');
if ($manager->lastBroadcastData()) {
$filex = $manager->lastBroadcastData();
$bot_API_markup[] = [['text'=>"ืืืจื",'callback_data'=>"Broadcast"]];
@@ -6314,6 +6459,9 @@ public function LastBrodDATA(callbackQuery $query)
#[FilterButtonQueryData('setBroadcast')]
public function setBroadcast(callbackQuery $query)
{
+if(!$this->legacySystemQueryAllowed($query)){return;}
+$this->v3OperatorPanel($query,'edit'); return;
+
try {
$userid = $query->userId;
$User_Full = $this->getInfo($userid);
@@ -6322,9 +6470,9 @@ public function setBroadcast(callbackQuery $query)
$first_name = "null";
}
-$api = new \danog\MadelineProto\API(__DIR__.'/bot.shabbat');
+$api = new \danog\MadelineProto\API(getenv('SESSION_PATH') ?: __DIR__.'/bot.shabbat');
$manager = new BroadcastManager($api);
-BroadcastManager::setDataDir(__DIR__ . '/data');
+BroadcastManager::setDataDir(getenv('DATA_DIR') ?: __DIR__ . '/data');
if(!$manager->progress()){
@@ -6334,13 +6482,13 @@ public function setBroadcast(callbackQuery $query)
$query->editText($message = "ื ื ืฉืื ืืช ืืืืืขื ืฉืชืจืฆื ืืฉืืื:", $replyMarkup = $bot_API_markup, ParseMode::HTML, $noWebpage = false, $scheduleDate = NULL);
$userDir = __DIR__ . "/data/$userid";
- if (!is_dir($userDir)) {
- mkdir($userDir, 0777, true);
+ if (!\Shabbat\Storage\AdminStateFiles::isDirectory($userDir)) {
+ \Shabbat\Storage\AdminStateFiles::mkdir($userDir, 0777, true);
}
-Amp\File\write(__DIR__."/data/$userid/grs1.txt", 'broadcast1');
+\Shabbat\Storage\AdminStateFiles::write(__DIR__."/data/$userid/grs1.txt", 'broadcast1');
$msgqutryid = $query->messageId;
-Amp\File\write(__DIR__."/data/$userid/messagetodelete.txt", "$msgqutryid");
+\Shabbat\Storage\AdminStateFiles::write(__DIR__."/data/$userid/messagetodelete.txt", "$msgqutryid");
}else{
$message = "ืืฉ ืฉืืืืจ ืคืขืื ืืจืืข ืื ื ืืืชื..";
@@ -6350,9 +6498,9 @@ public function setBroadcast(callbackQuery $query)
} catch (Throwable $e) {}
}
- #[Handler]
- public function handlebroadcast1(Incoming & PrivateMessage & FromAdmin $message): void
+ public function handlebroadcast1(Incoming & PrivateMessage $message): void
{
+if ($this->v3OperatorCapture($message,'broadcast1')) { return; }
try {
$messagetext = $message->message;
$messageid = $message->id;
@@ -6375,13 +6523,13 @@ public function handlebroadcast1(Incoming & PrivateMessage & FromAdmin $message)
}
$userDir = __DIR__ . "/data/$senderid";
- if (!is_dir($userDir)) {
- mkdir($userDir, 0777, true);
+ if (!\Shabbat\Storage\AdminStateFiles::isDirectory($userDir)) {
+ \Shabbat\Storage\AdminStateFiles::mkdir($userDir, 0777, true);
}
- if (file_exists(__DIR__."/data/$senderid/grs1.txt")) {
-$check = Amp\File\read(__DIR__."/data/$senderid/grs1.txt");
+ if (\Shabbat\Storage\AdminStateFiles::exists(__DIR__."/data/$senderid/grs1.txt")) {
+$check = \Shabbat\Storage\AdminStateFiles::read(__DIR__."/data/$senderid/grs1.txt");
if($check == "broadcast1"){
if(!preg_match('/^\/([Ss]tart)/',$messagetext)){
@@ -6414,8 +6562,8 @@ public function handlebroadcast1(Incoming & PrivateMessage & FromAdmin $message)
ืืืืช ืืชืืืื ืฉืฉืืืช: $messageLength", reply_markup: $bot_API_markup);
- if (file_exists(__DIR__."/data/$senderid/messagetodelete.txt")) {
-$filexmsgid = Amp\File\read(__DIR__."/data/$senderid/messagetodelete.txt");
+ if (\Shabbat\Storage\AdminStateFiles::exists(__DIR__."/data/$senderid/messagetodelete.txt")) {
+$filexmsgid = \Shabbat\Storage\AdminStateFiles::read(__DIR__."/data/$senderid/messagetodelete.txt");
try {
$this->messages->deleteMessages(revoke: true, id: [$filexmsgid]);
@@ -6429,36 +6577,36 @@ public function handlebroadcast1(Incoming & PrivateMessage & FromAdmin $message)
}
}
-unlink(__DIR__."/data/$senderid/messagetodelete.txt");
+\Shabbat\Storage\AdminStateFiles::delete(__DIR__."/data/$senderid/messagetodelete.txt");
}
}else{
- if (file_exists(__DIR__."/data/$senderid/grs1.txt")) {
-unlink(__DIR__."/data/$senderid/grs1.txt");
+ if (\Shabbat\Storage\AdminStateFiles::exists(__DIR__."/data/$senderid/grs1.txt")) {
+\Shabbat\Storage\AdminStateFiles::delete(__DIR__."/data/$senderid/grs1.txt");
}
- if (file_exists(__DIR__."/data/BUTTONS.txt")) {
-unlink(__DIR__."/data/BUTTONS.txt");
+ if (\Shabbat\Storage\AdminStateFiles::exists(__DIR__."/data/BUTTONS.txt")) {
+\Shabbat\Storage\AdminStateFiles::delete(__DIR__."/data/BUTTONS.txt");
}
- if (file_exists(__DIR__."/data/$senderid/txt.txt")) {
-unlink(__DIR__."/data/$senderid/txt.txt");
+ if (\Shabbat\Storage\AdminStateFiles::exists(__DIR__."/data/$senderid/txt.txt")) {
+\Shabbat\Storage\AdminStateFiles::delete(__DIR__."/data/$senderid/txt.txt");
}
- if (file_exists(__DIR__."/data/$senderid/ent.txt")) {
-unlink(__DIR__."/data/$senderid/ent.txt");
+ if (\Shabbat\Storage\AdminStateFiles::exists(__DIR__."/data/$senderid/ent.txt")) {
+\Shabbat\Storage\AdminStateFiles::delete(__DIR__."/data/$senderid/ent.txt");
}
- if (file_exists(__DIR__."/data/$senderid/media.txt")) {
-unlink(__DIR__."/data/$senderid/media.txt");
+ if (\Shabbat\Storage\AdminStateFiles::exists(__DIR__."/data/$senderid/media.txt")) {
+\Shabbat\Storage\AdminStateFiles::delete(__DIR__."/data/$senderid/media.txt");
}
if($messagetext != null){
-Amp\File\write(__DIR__."/data/$senderid/txt.txt", "$messagetext");
-Amp\File\write(__DIR__."/data/$senderid/ent.txt", json_encode(array_map(static fn($e) => $e->toMTProto(),$entities,)));
+\Shabbat\Storage\AdminStateFiles::write(__DIR__."/data/$senderid/txt.txt", "$messagetext");
+\Shabbat\Storage\AdminStateFiles::write(__DIR__."/data/$senderid/ent.txt", json_encode(array_map(static fn($e) => $e->toMTProto(),$entities,)));
}
if(!$messagefile){
}else{
$botApiFileId = $message->media->botApiFileId;
-Amp\File\write(__DIR__."/data/$senderid/media.txt", "$botApiFileId");
+\Shabbat\Storage\AdminStateFiles::write(__DIR__."/data/$senderid/media.txt", "$botApiFileId");
}
try {
@@ -6473,8 +6621,8 @@ public function handlebroadcast1(Incoming & PrivateMessage & FromAdmin $message)
}
}
- if (file_exists(__DIR__."/data/$senderid/messagetodelete.txt")) {
-$filexmsgid = Amp\File\read(__DIR__."/data/$senderid/messagetodelete.txt");
+ if (\Shabbat\Storage\AdminStateFiles::exists(__DIR__."/data/$senderid/messagetodelete.txt")) {
+$filexmsgid = \Shabbat\Storage\AdminStateFiles::read(__DIR__."/data/$senderid/messagetodelete.txt");
try {
$this->messages->deleteMessages(revoke: true, id: [$filexmsgid]);
@@ -6488,21 +6636,21 @@ public function handlebroadcast1(Incoming & PrivateMessage & FromAdmin $message)
}
}
-unlink(__DIR__."/data/$senderid/messagetodelete.txt");
+\Shabbat\Storage\AdminStateFiles::delete(__DIR__."/data/$senderid/messagetodelete.txt");
}
-if (file_exists(__DIR__."/data/broadcastsend.txt")) {
-$broadcast_send = Amp\File\read(__DIR__."/data/broadcastsend.txt");
+if (\Shabbat\Storage\AdminStateFiles::exists(__DIR__."/data/broadcastsend.txt")) {
+$broadcast_send = \Shabbat\Storage\AdminStateFiles::read(__DIR__."/data/broadcastsend.txt");
}
-if (!file_exists(__DIR__."/data/broadcastsend.txt")) {
+if (!\Shabbat\Storage\AdminStateFiles::exists(__DIR__."/data/broadcastsend.txt")) {
$broadcast_send = "ืืฉืชืืฉืื";
}
-if (file_exists(__DIR__."/data/pinmessage.txt")) {
+if (\Shabbat\Storage\AdminStateFiles::exists(__DIR__."/data/pinmessage.txt")) {
$bot_API_markup[] = [['text'=>"๐ ื ืขืฅ ืืืืขื ืืฆ'ืื: โ๏ธ",'callback_data'=>"ื ืขืฅืืืืขืืืื"]];
}
-if (!file_exists(__DIR__."/data/pinmessage.txt")) {
+if (!\Shabbat\Storage\AdminStateFiles::exists(__DIR__."/data/pinmessage.txt")) {
$bot_API_markup[] = [['text'=>"๐ ื ืขืฅ ืืืืขื ืืฆ'ืื: โ๏ธ",'callback_data'=>"ื ืขืฅืืืืขื"]];
}
@@ -6516,18 +6664,18 @@ public function handlebroadcast1(Incoming & PrivateMessage & FromAdmin $message)
$bot_API_markup[] = [['text'=>"โ ืืืืื โ",'callback_data'=>"ืืืจืืื ืื2"]];
$bot_API_markup = [ 'inline_keyboard'=> $bot_API_markup,];
- if (file_exists(__DIR__."/data/$senderid/txt.txt")) {
-$filexmsgidtxt = Amp\File\read(__DIR__."/data/$senderid/txt.txt");
+ if (\Shabbat\Storage\AdminStateFiles::exists(__DIR__."/data/$senderid/txt.txt")) {
+$filexmsgidtxt = \Shabbat\Storage\AdminStateFiles::read(__DIR__."/data/$senderid/txt.txt");
}else{
$filexmsgidtxt = null;
}
- if (file_exists(__DIR__."/data/$senderid/ent.txt")) {
-$filexmsgident = json_decode(Amp\File\read(__DIR__."/data/$senderid/ent.txt"),true);
+ if (\Shabbat\Storage\AdminStateFiles::exists(__DIR__."/data/$senderid/ent.txt")) {
+$filexmsgident = json_decode(\Shabbat\Storage\AdminStateFiles::read(__DIR__."/data/$senderid/ent.txt"),true);
}else{
$filexmsgident = null;
}
- if (file_exists(__DIR__."/data/$senderid/media.txt")) {
-$filexmsgidmedia = Amp\File\read(__DIR__."/data/$senderid/media.txt");
+ if (\Shabbat\Storage\AdminStateFiles::exists(__DIR__."/data/$senderid/media.txt")) {
+$filexmsgidmedia = \Shabbat\Storage\AdminStateFiles::read(__DIR__."/data/$senderid/media.txt");
}else{
$filexmsgidmedia = null;
}
@@ -6564,6 +6712,9 @@ public function handlebroadcast1(Incoming & PrivateMessage & FromAdmin $message)
#[FilterButtonQueryData('ืืืจืืชืคืจืืืฉืืืืจ')]
public function hazarashidur(callbackQuery $query)
{
+if(!$this->legacySystemQueryAllowed($query)){return;}
+$this->v3OperatorPanel($query,'draft'); return;
+
try {
$userid = $query->userId;
$msgqutryid = $query->messageId;
@@ -6573,18 +6724,18 @@ public function hazarashidur(callbackQuery $query)
$first_name = "null";
}
-if (file_exists(__DIR__."/data/broadcastsend.txt")) {
-$broadcast_send = Amp\File\read(__DIR__."/data/broadcastsend.txt");
+if (\Shabbat\Storage\AdminStateFiles::exists(__DIR__."/data/broadcastsend.txt")) {
+$broadcast_send = \Shabbat\Storage\AdminStateFiles::read(__DIR__."/data/broadcastsend.txt");
}
-if (!file_exists(__DIR__."/data/broadcastsend.txt")) {
+if (!\Shabbat\Storage\AdminStateFiles::exists(__DIR__."/data/broadcastsend.txt")) {
$broadcast_send = "ืืฉืชืืฉืื";
}
-if (file_exists(__DIR__."/data/pinmessage.txt")) {
+if (\Shabbat\Storage\AdminStateFiles::exists(__DIR__."/data/pinmessage.txt")) {
$bot_API_markup[] = [['text'=>"๐ ื ืขืฅ ืืืืขื ืืฆ'ืื: โ๏ธ",'callback_data'=>"ื ืขืฅืืืืขืืืื"]];
}
-if (!file_exists(__DIR__."/data/pinmessage.txt")) {
+if (!\Shabbat\Storage\AdminStateFiles::exists(__DIR__."/data/pinmessage.txt")) {
$bot_API_markup[] = [['text'=>"๐ ื ืขืฅ ืืืืขื ืืฆ'ืื: โ๏ธ",'callback_data'=>"ื ืขืฅืืืืขื"]];
}
$bot_API_markup[] = [['text'=>"๐ฎ ืืขื ืชืคืืฆื: $broadcast_send",'callback_data'=>"ืืฆืืชืคืืฆื"]];
@@ -6597,13 +6748,13 @@ public function hazarashidur(callbackQuery $query)
$bot_API_markup[] = [['text'=>"โ ืืืืื โ",'callback_data'=>"ืืืจืืื ืื2"]];
$bot_API_markup = [ 'inline_keyboard'=> $bot_API_markup,];
- if (file_exists(__DIR__."/data/$userid/txt.txt")) {
-$filexmsgidtxt = Amp\File\read(__DIR__."/data/$userid/txt.txt");
+ if (\Shabbat\Storage\AdminStateFiles::exists(__DIR__."/data/$userid/txt.txt")) {
+$filexmsgidtxt = \Shabbat\Storage\AdminStateFiles::read(__DIR__."/data/$userid/txt.txt");
}else{
$filexmsgidtxt = null;
}
- if (file_exists(__DIR__."/data/$userid/ent.txt")) {
-$filexmsgident = json_decode(Amp\File\read(__DIR__."/data/$userid/ent.txt"),true);
+ if (\Shabbat\Storage\AdminStateFiles::exists(__DIR__."/data/$userid/ent.txt")) {
+$filexmsgident = json_decode(\Shabbat\Storage\AdminStateFiles::read(__DIR__."/data/$userid/ent.txt"),true);
}else{
$filexmsgident = null;
}
@@ -6619,6 +6770,9 @@ public function hazarashidur(callbackQuery $query)
#[FilterButtonQueryData('ื ืขืฅืืืืขื')]
public function addsoheshidur1forneitza1(callbackQuery $query)
{
+if(!$this->legacySystemQueryAllowed($query)){return;}
+$this->v3OperatorPanel($query,'pin'); return;
+
try {
$userid = $query->userId;
$User_Full = $this->getInfo($userid);
@@ -6627,7 +6781,7 @@ public function addsoheshidur1forneitza1(callbackQuery $query)
$first_name = "null";
}
-Amp\File\write(__DIR__."/data/pinmessage.txt","on");
+\Shabbat\Storage\AdminStateFiles::write(__DIR__."/data/pinmessage.txt","on");
$bot_API_markup[] = [['text'=>"ืืืจื",'callback_data'=>"ืืืจืืชืคืจืืืฉืืืืจ"]];
$bot_API_markup = [ 'inline_keyboard'=> $bot_API_markup,];
@@ -6639,6 +6793,9 @@ public function addsoheshidur1forneitza1(callbackQuery $query)
#[FilterButtonQueryData('ื ืขืฅืืืืขืืืื')]
public function addsoheshidur1forneitza2(callbackQuery $query)
{
+if(!$this->legacySystemQueryAllowed($query)){return;}
+$this->v3OperatorPanel($query,'unpin'); return;
+
try {
$userid = $query->userId;
$User_Full = $this->getInfo($userid);
@@ -6647,8 +6804,8 @@ public function addsoheshidur1forneitza2(callbackQuery $query)
$first_name = "null";
}
-if (file_exists(__DIR__."/data/pinmessage.txt")) {
-unlink(__DIR__."/data/pinmessage.txt");
+if (\Shabbat\Storage\AdminStateFiles::exists(__DIR__."/data/pinmessage.txt")) {
+\Shabbat\Storage\AdminStateFiles::delete(__DIR__."/data/pinmessage.txt");
}
$bot_API_markup[] = [['text'=>"ืืืจื",'callback_data'=>"ืืืจืืชืคืจืืืฉืืืืจ"]];
@@ -6661,6 +6818,9 @@ public function addsoheshidur1forneitza2(callbackQuery $query)
#[FilterButtonQueryData('ืืฆืืชืคืืฆื')]
public function broadsetsenders(callbackQuery $query)
{
+if(!$this->legacySystemQueryAllowed($query)){return;}
+$this->v3OperatorPanel($query,'audience'); return;
+
try {
$userid = $query->userId;
$User_Full = $this->getInfo($userid);
@@ -6684,6 +6844,9 @@ public function broadsetsenders(callbackQuery $query)
#[FilterButtonQueryData('ืืฆืืชืคืืฆื1')]
public function broadsetsenders1(callbackQuery $query)
{
+if(!$this->legacySystemQueryAllowed($query)){return;}
+$this->v3OperatorPanel($query,'audience:users'); return;
+
try {
$userid = $query->userId;
$User_Full = $this->getInfo($userid);
@@ -6692,7 +6855,7 @@ public function broadsetsenders1(callbackQuery $query)
$first_name = "null";
}
-Amp\File\write(__DIR__."/data/broadcastsend.txt","ืืฉืชืืฉืื");
+\Shabbat\Storage\AdminStateFiles::write(__DIR__."/data/broadcastsend.txt","ืืฉืชืืฉืื");
$bot_API_markup[] = [['text'=>"ืืืจื",'callback_data'=>"ืืืจืืชืคืจืืืฉืืืืจ"]];
$bot_API_markup = [ 'inline_keyboard'=> $bot_API_markup,];
@@ -6704,6 +6867,9 @@ public function broadsetsenders1(callbackQuery $query)
#[FilterButtonQueryData('ืืฆืืชืคืืฆื2')]
public function broadsetsenders2(callbackQuery $query)
{
+if(!$this->legacySystemQueryAllowed($query)){return;}
+$this->v3OperatorPanel($query,'audience:channels'); return;
+
try {
$userid = $query->userId;
$User_Full = $this->getInfo($userid);
@@ -6712,7 +6878,7 @@ public function broadsetsenders2(callbackQuery $query)
$first_name = "null";
}
-Amp\File\write(__DIR__."/data/broadcastsend.txt","ืขืจืืฆืื");
+\Shabbat\Storage\AdminStateFiles::write(__DIR__."/data/broadcastsend.txt","ืขืจืืฆืื");
$bot_API_markup[] = [['text'=>"ืืืจื",'callback_data'=>"ืืืจืืชืคืจืืืฉืืืืจ"]];
$bot_API_markup = [ 'inline_keyboard'=> $bot_API_markup,];
@@ -6724,6 +6890,9 @@ public function broadsetsenders2(callbackQuery $query)
#[FilterButtonQueryData('ืืฆืืชืคืืฆื3')]
public function broadsetsenders3(callbackQuery $query)
{
+if(!$this->legacySystemQueryAllowed($query)){return;}
+$this->v3OperatorPanel($query,'audience:groups'); return;
+
try {
$userid = $query->userId;
$User_Full = $this->getInfo($userid);
@@ -6732,7 +6901,7 @@ public function broadsetsenders3(callbackQuery $query)
$first_name = "null";
}
-Amp\File\write(__DIR__."/data/broadcastsend.txt","ืงืืืฆืืช");
+\Shabbat\Storage\AdminStateFiles::write(__DIR__."/data/broadcastsend.txt","ืงืืืฆืืช");
$bot_API_markup[] = [['text'=>"ืืืจื",'callback_data'=>"ืืืจืืชืคืจืืืฉืืืืจ"]];
$bot_API_markup = [ 'inline_keyboard'=> $bot_API_markup,];
@@ -6744,6 +6913,9 @@ public function broadsetsenders3(callbackQuery $query)
#[FilterButtonQueryData('ืืฆืืชืคืืฆื4')]
public function broadsetsenders4(callbackQuery $query)
{
+if(!$this->legacySystemQueryAllowed($query)){return;}
+$this->v3OperatorPanel($query,'audience:all'); return;
+
try {
$userid = $query->userId;
$User_Full = $this->getInfo($userid);
@@ -6752,7 +6924,7 @@ public function broadsetsenders4(callbackQuery $query)
$first_name = "null";
}
-Amp\File\write(__DIR__."/data/broadcastsend.txt","ืืืื");
+\Shabbat\Storage\AdminStateFiles::write(__DIR__."/data/broadcastsend.txt","ืืืื");
$bot_API_markup[] = [['text'=>"ืืืจื",'callback_data'=>"ืืืจืืชืคืจืืืฉืืืืจ"]];
$bot_API_markup = [ 'inline_keyboard'=> $bot_API_markup,];
@@ -6764,6 +6936,9 @@ public function broadsetsenders4(callbackQuery $query)
#[FilterButtonQueryData('ืืืกืคืชืืคืชืืจืื')]
public function hosafkaf(callbackQuery $query)
{
+if(!$this->legacySystemQueryAllowed($query)){return;}
+$this->v3OperatorPanel($query,'buttons'); return;
+
try {
$userid = $query->userId;
$User_Full = $this->getInfo($userid);
@@ -6795,15 +6970,15 @@ public function hosafkaf(callbackQuery $query)
ืืืืื:
ืกืืืจ - data:closeMsg
", $replyMarkup = $bot_API_markup, ParseMode::HTML, $noWebpage = false, $scheduleDate = NULL);
-Amp\File\write(__DIR__."/data/$userid/grs1.txt", 'addBUTTONS');
+\Shabbat\Storage\AdminStateFiles::write(__DIR__."/data/$userid/grs1.txt", 'addBUTTONS');
$msgqutryid = $query->messageId;
-Amp\File\write(__DIR__."/data/$userid/messagetodelete.txt", "$msgqutryid");
+\Shabbat\Storage\AdminStateFiles::write(__DIR__."/data/$userid/messagetodelete.txt", "$msgqutryid");
} catch (Throwable $e) {}
}
-#[Handler]
-public function handlebuttons(Incoming & PrivateMessage & FromAdmin $message): void
+public function handlebuttons(Incoming & PrivateMessage $message): void
{
+if ($this->v3OperatorCapture($message,'addBUTTONS')) { return; }
try {
$messagetext = $message->message;
$entities = $message->entities;
@@ -6824,8 +6999,8 @@ public function handlebuttons(Incoming & PrivateMessage & FromAdmin $message): v
$username = "null";
}
- if (file_exists(__DIR__."/data/$senderid/grs1.txt")) {
-$edit = Amp\File\read(__DIR__."/data/$senderid/grs1.txt");
+ if (\Shabbat\Storage\AdminStateFiles::exists(__DIR__."/data/$senderid/grs1.txt")) {
+$edit = \Shabbat\Storage\AdminStateFiles::read(__DIR__."/data/$senderid/grs1.txt");
if($edit == "addBUTTONS"){
if(!preg_match('/^\/([Ss]tart)/',$messagetext)){
@@ -6893,7 +7068,7 @@ function parseButtons(string $text): array|false
$parsedButtons = parseButtons($messagetext);
if ($parsedButtons !== false) {
-unlink(__DIR__."/data/$senderid/grs1.txt");
+\Shabbat\Storage\AdminStateFiles::delete(__DIR__."/data/$senderid/grs1.txt");
try {
$this->messages->deleteMessages(revoke: true, id: [$messageid]);
@@ -6907,14 +7082,14 @@ function parseButtons(string $text): array|false
}
}
- if (file_exists(__DIR__."/data/$senderid/messagetodelete.txt")) {
-$filexmsgid = Amp\File\read(__DIR__."/data/$senderid/messagetodelete.txt");
+ if (\Shabbat\Storage\AdminStateFiles::exists(__DIR__."/data/$senderid/messagetodelete.txt")) {
+$filexmsgid = \Shabbat\Storage\AdminStateFiles::read(__DIR__."/data/$senderid/messagetodelete.txt");
$bot_API_markup = [];
$bot_API_markup[] = [['text'=>"ืืืจื",'callback_data'=>"ืืืจืืชืคืจืืืฉืืืืจ"]];
$bot_API_markup = [ 'inline_keyboard'=> $bot_API_markup,];
$Updates = $this->messages->editMessage(peer: $senderid, id: $filexmsgid, message: "ืืืคืชืืจืื ื ืฉืืจื ืืืฆืืื! โ๏ธ", reply_markup: $bot_API_markup, parse_mode: 'HTML');
-Amp\File\write(__DIR__."/data/BUTTONS.txt", json_encode($parsedButtons, JSON_UNESCAPED_UNICODE));
+\Shabbat\Storage\AdminStateFiles::write(__DIR__."/data/BUTTONS.txt", json_encode($parsedButtons, JSON_UNESCAPED_UNICODE));
}
@@ -6932,8 +7107,8 @@ function parseButtons(string $text): array|false
}
}
- if (file_exists(__DIR__."/data/$senderid/messagetodelete.txt")) {
-$filexmsgid = Amp\File\read(__DIR__."/data/$senderid/messagetodelete.txt");
+ if (\Shabbat\Storage\AdminStateFiles::exists(__DIR__."/data/$senderid/messagetodelete.txt")) {
+$filexmsgid = \Shabbat\Storage\AdminStateFiles::read(__DIR__."/data/$senderid/messagetodelete.txt");
$bot_API_markup = [];
$bot_API_markup[] = [['text'=>"โ ืืืืื โ",'callback_data'=>"ืืืจืืชืคืจืืืฉืืืืจ"]];
$bot_API_markup = [ 'inline_keyboard'=> $bot_API_markup,];
@@ -6980,6 +7155,9 @@ function parseButtons(string $text): array|false
#[FilterButtonQueryData('ืฆืคืืืืคืชืืจืืืื ืื')]
public function buttonsmanageview(callbackQuery $query)
{
+if(!$this->legacySystemQueryAllowed($query)){return;}
+$this->v3OperatorPanel($query,'preview'); return;
+
try {
$userid = $query->userId;
$User_Full = $this->getInfo($userid);
@@ -6990,16 +7168,16 @@ public function buttonsmanageview(callbackQuery $query)
$buttons = __DIR__."/data/BUTTONS.txt";
- if (file_exists($buttons)) {
+ if (\Shabbat\Storage\AdminStateFiles::exists($buttons)) {
if (!function_exists('loadButtons')) {
function loadButtons(string $file): array|null
{
- if (!file_exists($file)) {
+ if (!\Shabbat\Storage\AdminStateFiles::exists($file)) {
return null;
}
- $json = Amp\File\read($file);
+ $json = \Shabbat\Storage\AdminStateFiles::read($file);
$buttons = json_decode($json, true);
if (!is_array($buttons)) {
@@ -7026,7 +7204,7 @@ function loadButtons(string $file): array|null
}
- if (!file_exists($buttons)) {
+ if (!\Shabbat\Storage\AdminStateFiles::exists($buttons)) {
$BUTTONS = "ืื ืืืืืจื ืขืืืื ืืคืชืืจืื..";
$query->answer($message = "$BUTTONS", $alert = true, $url = null, $cacheTime = 10);
}
@@ -7037,6 +7215,9 @@ function loadButtons(string $file): array|null
#[FilterButtonQueryData('ืฉืืจืืืืขื')]
public function buttonsmanageview2(callbackQuery $query)
{
+if(!$this->legacySystemQueryAllowed($query)){return;}
+$this->v3OperatorConfirmBroadcast($query); return;
+
try{
$userid = $query->userId;
$msgqutryid = $query->messageId;
@@ -7048,16 +7229,16 @@ public function buttonsmanageview2(callbackQuery $query)
$buttons = __DIR__."/data/BUTTONS.txt";
- if (file_exists($buttons)) {
+ if (\Shabbat\Storage\AdminStateFiles::exists($buttons)) {
if (!function_exists('loadButtons')) {
function loadButtons(string $file): array|null
{
- if (!file_exists($file)) {
+ if (!\Shabbat\Storage\AdminStateFiles::exists($file)) {
return null;
}
- $json = Amp\File\read($file);
+ $json = \Shabbat\Storage\AdminStateFiles::read($file);
$buttons = json_decode($json, true);
if (!is_array($buttons)) {
@@ -7086,36 +7267,36 @@ function loadButtons(string $file): array|null
$this->messages->deleteMessages(revoke: true, id: [$msgqutryid]);
} catch (Throwable $e) {}
- if (file_exists(__DIR__."/data/$userid/txt.txt")) {
-$filexmsgidtxt = Amp\File\read(__DIR__."/data/$userid/txt.txt");
+ if (\Shabbat\Storage\AdminStateFiles::exists(__DIR__."/data/$userid/txt.txt")) {
+$filexmsgidtxt = \Shabbat\Storage\AdminStateFiles::read(__DIR__."/data/$userid/txt.txt");
}else{
$filexmsgidtxt = null;
}
- if (file_exists(__DIR__."/data/$userid/ent.txt")) {
-$filexmsgident = json_decode(Amp\File\read(__DIR__."/data/$userid/ent.txt"),true);
+ if (\Shabbat\Storage\AdminStateFiles::exists(__DIR__."/data/$userid/ent.txt")) {
+$filexmsgident = json_decode(\Shabbat\Storage\AdminStateFiles::read(__DIR__."/data/$userid/ent.txt"),true);
}else{
$filexmsgident = null;
}
- if (file_exists(__DIR__."/data/$userid/media.txt")) {
-$filexmsgidmedia = Amp\File\read(__DIR__."/data/$userid/media.txt");
+ if (\Shabbat\Storage\AdminStateFiles::exists(__DIR__."/data/$userid/media.txt")) {
+$filexmsgidmedia = \Shabbat\Storage\AdminStateFiles::read(__DIR__."/data/$userid/media.txt");
}else{
$filexmsgidmedia = null;
}
try {
-$dialogs = $this->getDialogIds();
+$dialogs = $this->v3KnownPeers();
} catch (Throwable $e) { $dialogs = []; }
- if (!file_exists(__DIR__."/data/pinmessage.txt")) {
+ if (!\Shabbat\Storage\AdminStateFiles::exists(__DIR__."/data/pinmessage.txt")) {
$pinmessage = false;
}else{
$pinmessage = true;
}
- if (!file_exists(__DIR__."/data/broadcastsend.txt")) {
+ if (!\Shabbat\Storage\AdminStateFiles::exists(__DIR__."/data/broadcastsend.txt")) {
$subfilter = 'users';
}else{
-$check2 = Amp\File\read(__DIR__."/data/broadcastsend.txt");
+$check2 = \Shabbat\Storage\AdminStateFiles::read(__DIR__."/data/broadcastsend.txt");
if($check2 == "ืืฉืชืืฉืื"){
$subfilter = 'users';
@@ -7147,9 +7328,9 @@ function loadButtons(string $file): array|null
}
-$api = new \danog\MadelineProto\API(__DIR__.'/bot.shabbat');
+$api = new \danog\MadelineProto\API(getenv('SESSION_PATH') ?: __DIR__.'/bot.shabbat');
$manager = new BroadcastManager($api);
-BroadcastManager::setDataDir(__DIR__ . '/data');
+BroadcastManager::setDataDir(getenv('DATA_DIR') ?: __DIR__ . '/data');
if(!$manager->progress()){
$filter_sub = $manager->filterPeers($dialogs, $subfilter);
@@ -7168,28 +7349,15 @@ function loadButtons(string $file): array|null
#[FilterButtonQueryData('ืืงืจืื')]
public function comingsoon(callbackQuery $query) {
try{
-$query->answer($message = "ืืงืจืื ืืืื ืื ืืคืขื ๐ก", $alert = true, $url = null, $cacheTime = 0);
+$query->answer($this->v3Text((string)$query->userId, 'unavailable'), true);
} catch (Throwable $e) {}
}
}
function RunBot(): void {
- try {
-$API_ID = parse_ini_file(__DIR__."/".'.env')['API_ID'];
-$API_HASH = parse_ini_file(__DIR__."/".'.env')['API_HASH'];
-$BOT_TOKEN = parse_ini_file(__DIR__."/".'.env')['BOT_TOKEN'];
-$settings = new \danog\MadelineProto\Settings;
-$settings->setAppInfo((new \danog\MadelineProto\Settings\AppInfo)->setApiId((int)$API_ID)->setApiHash($API_HASH));
-
-$logger = (new \danog\MadelineProto\Settings\Logger)->setLevel(\danog\MadelineProto\Logger::ERROR);
-$settings->setLogger($logger);
-
-Shabbat::startAndLoopBot(__DIR__.'/bot.shabbat', $BOT_TOKEN, $settings);
-
-} catch (\Throwable $e) {
-if (strpos($e->getMessage(), 'bad_msg_notification') !== false) exit(1);
-if ($e instanceof \Amp\TimeoutException || $e instanceof \Amp\CancelledException) exit(1);
+ exit(\Shabbat\Runtime\Runner::run(Shabbat::class, __DIR__));
}
+if (PHP_SAPI === 'cli' && realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__) {
+ RunBot();
}
-RunBot();
diff --git a/composer.json b/composer.json
index 65958d0..3f1ec02 100644
--- a/composer.json
+++ b/composer.json
@@ -1,20 +1,66 @@
-{
- "name": "wizardloop/shabbat",
- "description": "A robot for changing the Telegram group's permissions at the entrance and exit of Shabbat.",
- "type": "project",
- "require": {
- "danog/madelineproto": "^8.6",
- "wizardloop/broadcastmanager": "*"
- },
- "license": "MIT",
- "authors": [
- {
- "name": "WizardLoop"
- }
- ],
- "autoload": {
- "psr-4": {
- "Shabbat\\": ""
- }
- }
-}
+{
+ "name": "wizardloop/shabbat",
+ "description": "WizardLoop Shabbat Bot: group permissions, Jewish calendar and opt-in reminders.",
+ "type": "project",
+ "license": "AGPL-3.0-only",
+ "authors": [
+ {
+ "name": "WizardLoop"
+ }
+ ],
+ "require": {
+ "php": "~8.3.0 || ~8.4.0 || ~8.5.0",
+ "ext-pdo": "*",
+ "ext-pdo_sqlite": "*",
+ "ext-mbstring": "*",
+ "danog/madelineproto": "8.7.0",
+ "wizardloop/broadcastmanager": "~3.2.2",
+ "amphp/amp": "^3.1.1",
+ "amphp/file": "^3.2",
+ "amphp/http-client": "^5.3.4",
+ "revolt/event-loop": "^1.0.7"
+ },
+ "require-dev": {
+ "phpstan/phpstan": "^2.1",
+ "phpunit/phpunit": "^12.5",
+ "squizlabs/php_codesniffer": "^4.0"
+ },
+ "autoload": {
+ "psr-4": {
+ "Shabbat\\": "src/"
+ }
+ },
+ "config": {
+ "allow-plugins": {},
+ "sort-packages": true
+ },
+ "scripts": {
+ "post-install-cmd": [
+ "@php scripts/apply-patches.php",
+ "@php scripts/verify-patches.php"
+ ],
+ "post-update-cmd": [
+ "@php scripts/apply-patches.php",
+ "@php scripts/verify-patches.php"
+ ],
+ "patches": [
+ "@php scripts/apply-patches.php",
+ "@php scripts/verify-patches.php"
+ ],
+ "test": "phpunit",
+ "lint": "@php scripts/quality.php",
+ "analyse": "phpstan analyse --no-progress --memory-limit=1G --debug",
+ "check-docs": "@php scripts/check-docs.php",
+ "check-locales": "@php scripts/check-locales.php",
+ "dependencies": "@php scripts/dependencies.php",
+ "self-check": "@php bin/self-check.php",
+ "style": "phpcs --standard=phpcs.xml.dist -n",
+ "style-fix": "phpcbf --standard=phpcs.xml.dist",
+ "style-review": "phpcs --standard=phpcs.xml.dist"
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Shabbat\\Tests\\": "tests/"
+ }
+ }
+}
\ No newline at end of file
diff --git a/composer.lock b/composer.lock
new file mode 100644
index 0000000..242eb69
--- /dev/null
+++ b/composer.lock
@@ -0,0 +1,5915 @@
+{
+ "_readme": [
+ "This file locks the dependencies of your project to a known state",
+ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
+ "This file is @generated automatically"
+ ],
+ "content-hash": "a66db3dc1cf910ea659bd986ec4af70a",
+ "packages": [
+ {
+ "name": "amphp/amp",
+ "version": "v3.1.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/amp.git",
+ "reference": "73c38b323ff8d790abf0f76c56fcc892bda4111a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/amp/zipball/73c38b323ff8d790abf0f76c56fcc892bda4111a",
+ "reference": "73c38b323ff8d790abf0f76c56fcc892bda4111a",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1",
+ "revolt/event-loop": "^1 || ^0.2"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "6.16.1"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/functions.php",
+ "src/Future/functions.php",
+ "src/Internal/functions.php"
+ ],
+ "psr-4": {
+ "Amp\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ },
+ {
+ "name": "Bob Weinand",
+ "email": "bobwei9@hotmail.com"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ },
+ {
+ "name": "Daniel Lowrey",
+ "email": "rdlowrey@php.net"
+ }
+ ],
+ "description": "A non-blocking concurrency framework for PHP applications.",
+ "homepage": "https://amphp.org/amp",
+ "keywords": [
+ "async",
+ "asynchronous",
+ "awaitable",
+ "concurrency",
+ "event",
+ "event-loop",
+ "future",
+ "non-blocking",
+ "promise"
+ ],
+ "support": {
+ "issues": "https://github.com/amphp/amp/issues",
+ "source": "https://github.com/amphp/amp/tree/v3.1.3"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2026-07-19T17:59:20+00:00"
+ },
+ {
+ "name": "amphp/byte-stream",
+ "version": "v2.1.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/byte-stream.git",
+ "reference": "55a6bd071aec26fa2a3e002618c20c35e3df1b46"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/byte-stream/zipball/55a6bd071aec26fa2a3e002618c20c35e3df1b46",
+ "reference": "55a6bd071aec26fa2a3e002618c20c35e3df1b46",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/amp": "^3",
+ "amphp/parser": "^1.1",
+ "amphp/pipeline": "^1",
+ "amphp/serialization": "^1",
+ "amphp/sync": "^2",
+ "php": ">=8.1",
+ "revolt/event-loop": "^1 || ^0.2.3"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "amphp/phpunit-util": "^3",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "5.22.1"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/functions.php",
+ "src/Internal/functions.php"
+ ],
+ "psr-4": {
+ "Amp\\ByteStream\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ }
+ ],
+ "description": "A stream abstraction to make working with non-blocking I/O simple.",
+ "homepage": "https://amphp.org/byte-stream",
+ "keywords": [
+ "amp",
+ "amphp",
+ "async",
+ "io",
+ "non-blocking",
+ "stream"
+ ],
+ "support": {
+ "issues": "https://github.com/amphp/byte-stream/issues",
+ "source": "https://github.com/amphp/byte-stream/tree/v2.1.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2025-03-16T17:10:27+00:00"
+ },
+ {
+ "name": "amphp/cache",
+ "version": "v2.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/cache.git",
+ "reference": "46912e387e6aa94933b61ea1ead9cf7540b7797c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/cache/zipball/46912e387e6aa94933b61ea1ead9cf7540b7797c",
+ "reference": "46912e387e6aa94933b61ea1ead9cf7540b7797c",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/amp": "^3",
+ "amphp/serialization": "^1",
+ "amphp/sync": "^2",
+ "php": ">=8.1",
+ "revolt/event-loop": "^1 || ^0.2"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "amphp/phpunit-util": "^3",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "^5.4"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Amp\\Cache\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ },
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ },
+ {
+ "name": "Daniel Lowrey",
+ "email": "rdlowrey@php.net"
+ }
+ ],
+ "description": "A fiber-aware cache API based on Amp and Revolt.",
+ "homepage": "https://amphp.org/cache",
+ "support": {
+ "issues": "https://github.com/amphp/cache/issues",
+ "source": "https://github.com/amphp/cache/tree/v2.0.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2024-04-19T03:38:06+00:00"
+ },
+ {
+ "name": "amphp/dns",
+ "version": "v2.4.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/dns.git",
+ "reference": "ec5948bfafac808f410406e18bc7b52a2d4629b7"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/dns/zipball/ec5948bfafac808f410406e18bc7b52a2d4629b7",
+ "reference": "ec5948bfafac808f410406e18bc7b52a2d4629b7",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/amp": "^3",
+ "amphp/byte-stream": "^2",
+ "amphp/cache": "^2",
+ "amphp/parser": "^1",
+ "amphp/process": "^2",
+ "daverandom/libdns": "^2.0.2",
+ "ext-filter": "*",
+ "ext-json": "*",
+ "php": ">=8.1",
+ "revolt/event-loop": "^1 || ^0.2"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "amphp/phpunit-util": "^3",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "6.16.1"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/functions.php"
+ ],
+ "psr-4": {
+ "Amp\\Dns\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Chris Wright",
+ "email": "addr@daverandom.com"
+ },
+ {
+ "name": "Daniel Lowrey",
+ "email": "rdlowrey@php.net"
+ },
+ {
+ "name": "Bob Weinand",
+ "email": "bobwei9@hotmail.com"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ },
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ }
+ ],
+ "description": "Async DNS resolution for Amp.",
+ "homepage": "https://github.com/amphp/dns",
+ "keywords": [
+ "amp",
+ "amphp",
+ "async",
+ "client",
+ "dns",
+ "resolve"
+ ],
+ "support": {
+ "issues": "https://github.com/amphp/dns/issues",
+ "source": "https://github.com/amphp/dns/tree/v2.4.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2026-07-26T17:53:54+00:00"
+ },
+ {
+ "name": "amphp/file",
+ "version": "v3.2.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/file.git",
+ "reference": "28b38a805d2c235bb581d24415e78a42cd03aedc"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/file/zipball/28b38a805d2c235bb581d24415e78a42cd03aedc",
+ "reference": "28b38a805d2c235bb581d24415e78a42cd03aedc",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/amp": "^3",
+ "amphp/byte-stream": "^2",
+ "amphp/cache": "^2",
+ "amphp/parallel": "^2.3",
+ "amphp/sync": "^2",
+ "php": ">=8.1",
+ "revolt/event-loop": "^1"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "amphp/phpunit-util": "^3",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "5.22.2"
+ },
+ "suggest": {
+ "ext-eio": "^2 || ^3",
+ "ext-uv": "^0.3 || ^0.2"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/functions.php"
+ ],
+ "psr-4": {
+ "Amp\\File\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Daniel Lowrey",
+ "email": "rdlowrey@php.net"
+ },
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ }
+ ],
+ "description": "Non-blocking access to the filesystem based on Amp and Revolt.",
+ "homepage": "https://github.com/amphp/file",
+ "keywords": [
+ "amp",
+ "amphp",
+ "async",
+ "disk",
+ "file",
+ "filesystem",
+ "io",
+ "non-blocking",
+ "static"
+ ],
+ "support": {
+ "issues": "https://github.com/amphp/file/issues",
+ "source": "https://github.com/amphp/file/tree/v3.2.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2024-12-06T23:39:02+00:00"
+ },
+ {
+ "name": "amphp/hpack",
+ "version": "v3.2.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/hpack.git",
+ "reference": "291da27078e7e149a9bad4d08ff05bf7d81c89f4"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/hpack/zipball/291da27078e7e149a9bad4d08ff05bf7d81c89f4",
+ "reference": "291da27078e7e149a9bad4d08ff05bf7d81c89f4",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.1"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "http2jp/hpack-test-case": "^1",
+ "nikic/php-fuzzer": "^0.0.11",
+ "phpunit/phpunit": "^7 | ^8 | ^9"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Amp\\Http\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Daniel Lowrey",
+ "email": "rdlowrey@php.net"
+ },
+ {
+ "name": "Bob Weinand"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ },
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ }
+ ],
+ "description": "HTTP/2 HPack implementation.",
+ "homepage": "https://github.com/amphp/hpack",
+ "keywords": [
+ "headers",
+ "hpack",
+ "http-2"
+ ],
+ "support": {
+ "issues": "https://github.com/amphp/hpack/issues",
+ "source": "https://github.com/amphp/hpack/tree/v3.2.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2026-05-03T19:28:59+00:00"
+ },
+ {
+ "name": "amphp/http",
+ "version": "v2.1.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/http.git",
+ "reference": "3680d80bd38b5d6f3c2cef2214ca6dd6cef26588"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/http/zipball/3680d80bd38b5d6f3c2cef2214ca6dd6cef26588",
+ "reference": "3680d80bd38b5d6f3c2cef2214ca6dd6cef26588",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/hpack": "^3",
+ "amphp/parser": "^1.1",
+ "league/uri-components": "^2.4.2 | ^7.1",
+ "php": ">=8.1",
+ "psr/http-message": "^1 | ^2"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "league/uri": "^6.8 | ^7.1",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "^5.26.1"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/functions.php",
+ "src/Internal/constants.php"
+ ],
+ "psr-4": {
+ "Amp\\Http\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ },
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ }
+ ],
+ "description": "Basic HTTP primitives which can be shared by servers and clients.",
+ "support": {
+ "issues": "https://github.com/amphp/http/issues",
+ "source": "https://github.com/amphp/http/tree/v2.1.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2024-11-23T14:57:26+00:00"
+ },
+ {
+ "name": "amphp/http-client",
+ "version": "v5.3.6",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/http-client.git",
+ "reference": "ca155026acafa74a612d776a97202d53077fee86"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/http-client/zipball/ca155026acafa74a612d776a97202d53077fee86",
+ "reference": "ca155026acafa74a612d776a97202d53077fee86",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/amp": "^3",
+ "amphp/byte-stream": "^2",
+ "amphp/hpack": "^3",
+ "amphp/http": "^2",
+ "amphp/pipeline": "^1",
+ "amphp/socket": "^2",
+ "amphp/sync": "^2",
+ "league/uri": "^7",
+ "league/uri-components": "^7",
+ "league/uri-interfaces": "^7.1",
+ "php": ">=8.1",
+ "psr/http-message": "^1 | ^2",
+ "revolt/event-loop": "^1"
+ },
+ "conflict": {
+ "amphp/file": "<3 | >=5"
+ },
+ "require-dev": {
+ "amphp/file": "^3 | ^4",
+ "amphp/http-server": "^3",
+ "amphp/php-cs-fixer-config": "^2",
+ "amphp/phpunit-util": "^3",
+ "ext-json": "*",
+ "kelunik/link-header-rfc5988": "^1",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "6.16.1"
+ },
+ "suggest": {
+ "amphp/file": "Required for file request bodies and HTTP archive logging",
+ "ext-json": "Required for logging HTTP archives",
+ "ext-zlib": "Allows using compression for response bodies."
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/functions.php",
+ "src/Internal/functions.php"
+ ],
+ "psr-4": {
+ "Amp\\Http\\Client\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Daniel Lowrey",
+ "email": "rdlowrey@gmail.com"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ },
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ }
+ ],
+ "description": "An advanced async HTTP client library for PHP, enabling efficient, non-blocking, and concurrent requests and responses.",
+ "homepage": "https://amphp.org/http-client",
+ "keywords": [
+ "async",
+ "client",
+ "concurrent",
+ "http",
+ "non-blocking",
+ "rest"
+ ],
+ "support": {
+ "issues": "https://github.com/amphp/http-client/issues",
+ "source": "https://github.com/amphp/http-client/tree/v5.3.6"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2026-05-15T23:29:38+00:00"
+ },
+ {
+ "name": "amphp/http-client-cookies",
+ "version": "v2.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/http-client-cookies.git",
+ "reference": "4124b37ffa4d15034e70d5d30e6fb9605ed6c1a7"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/http-client-cookies/zipball/4124b37ffa4d15034e70d5d30e6fb9605ed6c1a7",
+ "reference": "4124b37ffa4d15034e70d5d30e6fb9605ed6c1a7",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/amp": "^3",
+ "amphp/dns": "^2",
+ "amphp/http": "^2",
+ "amphp/http-client": "^5",
+ "amphp/sync": "^2",
+ "ext-filter": "*",
+ "php": ">=8.1",
+ "psr/http-message": "^1|^2"
+ },
+ "conflict": {
+ "amphp/file": "<3 || >=4"
+ },
+ "require-dev": {
+ "amphp/file": "^3",
+ "amphp/http-server": "^3",
+ "amphp/php-cs-fixer-config": "^2",
+ "amphp/phpunit-util": "^3",
+ "amphp/socket": "^2",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "^5.6"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Amp\\Http\\Client\\Cookie\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Daniel Lowrey",
+ "email": "rdlowrey@gmail.com"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ }
+ ],
+ "description": "Automatic cookie handling for Amp's HTTP client.",
+ "homepage": "https://github.com/amphp/http-client-cookies",
+ "keywords": [
+ "async",
+ "client",
+ "cookie",
+ "cookies",
+ "http"
+ ],
+ "support": {
+ "issues": "https://github.com/amphp/http-client-cookies/issues",
+ "source": "https://github.com/amphp/http-client-cookies/tree/v2.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2023-10-08T17:29:36+00:00"
+ },
+ {
+ "name": "amphp/http-server",
+ "version": "v3.4.7",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/http-server.git",
+ "reference": "aa96ca38c5f822a827236543d5417d0c2a782ef0"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/http-server/zipball/aa96ca38c5f822a827236543d5417d0c2a782ef0",
+ "reference": "aa96ca38c5f822a827236543d5417d0c2a782ef0",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/amp": "^3",
+ "amphp/byte-stream": "^2",
+ "amphp/cache": "^2",
+ "amphp/hpack": "^3",
+ "amphp/http": "^2",
+ "amphp/pipeline": "^1",
+ "amphp/socket": "^2.1",
+ "amphp/sync": "^2.2",
+ "league/uri": "^7.1",
+ "league/uri-interfaces": "^7.1",
+ "php": ">=8.1",
+ "psr/http-message": "^1 | ^2",
+ "psr/log": "^1 | ^2 | ^3",
+ "revolt/event-loop": "^1"
+ },
+ "require-dev": {
+ "amphp/http-client": "^5",
+ "amphp/log": "^2",
+ "amphp/php-cs-fixer-config": "^2",
+ "amphp/phpunit-util": "^3",
+ "league/uri-components": "^7.1",
+ "monolog/monolog": "^3",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "6.16.1"
+ },
+ "suggest": {
+ "ext-zlib": "Allows GZip compression of response bodies"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/Driver/functions.php",
+ "src/Middleware/functions.php",
+ "src/functions.php"
+ ],
+ "psr-4": {
+ "Amp\\Http\\Server\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Daniel Lowrey",
+ "email": "rdlowrey@php.net"
+ },
+ {
+ "name": "Bob Weinand"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ },
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ }
+ ],
+ "description": "A non-blocking HTTP application server for PHP based on Amp.",
+ "homepage": "https://github.com/amphp/http-server",
+ "keywords": [
+ "amp",
+ "amphp",
+ "async",
+ "http",
+ "non-blocking",
+ "server"
+ ],
+ "support": {
+ "issues": "https://github.com/amphp/http-server/issues",
+ "source": "https://github.com/amphp/http-server/tree/v3.4.7"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2026-10-04T15:14:17+00:00"
+ },
+ {
+ "name": "amphp/log",
+ "version": "v2.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/log.git",
+ "reference": "bf1562b8a18a3f30efa069ed740f412ac70a8a6c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/log/zipball/bf1562b8a18a3f30efa069ed740f412ac70a8a6c",
+ "reference": "bf1562b8a18a3f30efa069ed740f412ac70a8a6c",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/amp": "^3",
+ "amphp/byte-stream": "^2",
+ "monolog/monolog": "^3|^2|^1.23",
+ "php": ">=8.1",
+ "psr/log": "^3|^2|^1"
+ },
+ "require-dev": {
+ "amphp/file": "^3",
+ "amphp/php-cs-fixer-config": "^2",
+ "amphp/phpunit-util": "^3",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "^5.6"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/functions.php"
+ ],
+ "psr-4": {
+ "Amp\\Log\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ }
+ ],
+ "description": "Non-blocking logging for PHP based on Amp, Revolt, and Monolog.",
+ "homepage": "https://github.com/amphp/log",
+ "keywords": [
+ "amp",
+ "amphp",
+ "async",
+ "log",
+ "logger",
+ "logging",
+ "non-blocking"
+ ],
+ "support": {
+ "issues": "https://github.com/amphp/log/issues",
+ "source": "https://github.com/amphp/log/tree/v2.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2023-08-05T18:59:54+00:00"
+ },
+ {
+ "name": "amphp/mysql",
+ "version": "v3.1.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/mysql.git",
+ "reference": "ea3a5e07dc3dddc267669337ded2a98f02d86223"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/mysql/zipball/ea3a5e07dc3dddc267669337ded2a98f02d86223",
+ "reference": "ea3a5e07dc3dddc267669337ded2a98f02d86223",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/amp": "^3",
+ "amphp/parser": "^1.1",
+ "amphp/pipeline": "^1",
+ "amphp/socket": "^2.2",
+ "amphp/sql": "^2",
+ "amphp/sql-common": "^2",
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "amphp/file": "^3 | ^4",
+ "amphp/php-cs-fixer-config": "^2",
+ "amphp/phpunit-util": "^3",
+ "amphp/process": "^2",
+ "ext-mysqli": "*",
+ "ext-openssl": "*",
+ "phpbench/phpbench": "^1.2.6",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "6.16.1"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/functions.php"
+ ],
+ "psr-4": {
+ "Amp\\Mysql\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Bob Weinand",
+ "email": "bobwei9@hotmail.com"
+ },
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ }
+ ],
+ "description": "Asynchronous MySQL client for PHP based on Amp.",
+ "support": {
+ "issues": "https://github.com/amphp/mysql/issues",
+ "source": "https://github.com/amphp/mysql/tree/v3.1.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2026-05-16T19:09:29+00:00"
+ },
+ {
+ "name": "amphp/parallel",
+ "version": "v2.4.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/parallel.git",
+ "reference": "37f5b2754fadc229c00f9416bd68fb8d04529a81"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/parallel/zipball/37f5b2754fadc229c00f9416bd68fb8d04529a81",
+ "reference": "37f5b2754fadc229c00f9416bd68fb8d04529a81",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/amp": "^3",
+ "amphp/byte-stream": "^2",
+ "amphp/cache": "^2",
+ "amphp/parser": "^1",
+ "amphp/pipeline": "^1",
+ "amphp/process": "^2",
+ "amphp/serialization": "^1",
+ "amphp/socket": "^2",
+ "amphp/sync": "^2",
+ "php": ">=8.1",
+ "revolt/event-loop": "^1"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "amphp/phpunit-util": "^3",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "6.16.1"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/Context/functions.php",
+ "src/Context/Internal/functions.php",
+ "src/Ipc/functions.php",
+ "src/Worker/functions.php"
+ ],
+ "psr-4": {
+ "Amp\\Parallel\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ },
+ {
+ "name": "Stephen Coakley",
+ "email": "me@stephencoakley.com"
+ }
+ ],
+ "description": "Parallel processing component for Amp.",
+ "homepage": "https://github.com/amphp/parallel",
+ "keywords": [
+ "async",
+ "asynchronous",
+ "concurrent",
+ "multi-processing",
+ "multi-threading"
+ ],
+ "support": {
+ "issues": "https://github.com/amphp/parallel/issues",
+ "source": "https://github.com/amphp/parallel/tree/v2.4.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2026-05-16T16:54:01+00:00"
+ },
+ {
+ "name": "amphp/parser",
+ "version": "v1.1.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/parser.git",
+ "reference": "3cf1f8b32a0171d4b1bed93d25617637a77cded7"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/parser/zipball/3cf1f8b32a0171d4b1bed93d25617637a77cded7",
+ "reference": "3cf1f8b32a0171d4b1bed93d25617637a77cded7",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.4"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "^5.4"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Amp\\Parser\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ }
+ ],
+ "description": "A generator parser to make streaming parsers simple.",
+ "homepage": "https://github.com/amphp/parser",
+ "keywords": [
+ "async",
+ "non-blocking",
+ "parser",
+ "stream"
+ ],
+ "support": {
+ "issues": "https://github.com/amphp/parser/issues",
+ "source": "https://github.com/amphp/parser/tree/v1.1.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2024-03-21T19:16:53+00:00"
+ },
+ {
+ "name": "amphp/pipeline",
+ "version": "v1.2.7",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/pipeline.git",
+ "reference": "cf2d67696c2015ea7c7fd8f6ec5f8ed7c2d17c17"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/pipeline/zipball/cf2d67696c2015ea7c7fd8f6ec5f8ed7c2d17c17",
+ "reference": "cf2d67696c2015ea7c7fd8f6ec5f8ed7c2d17c17",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/amp": "^3",
+ "php": ">=8.1",
+ "revolt/event-loop": "^1"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "amphp/phpunit-util": "^3",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "6.16.1"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Amp\\Pipeline\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ }
+ ],
+ "description": "Asynchronous iterators and operators.",
+ "homepage": "https://amphp.org/pipeline",
+ "keywords": [
+ "amp",
+ "amphp",
+ "async",
+ "io",
+ "iterator",
+ "non-blocking"
+ ],
+ "support": {
+ "issues": "https://github.com/amphp/pipeline/issues",
+ "source": "https://github.com/amphp/pipeline/tree/v1.2.7"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2026-07-26T14:50:43+00:00"
+ },
+ {
+ "name": "amphp/postgres",
+ "version": "v2.2.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/postgres.git",
+ "reference": "b68c4d5929d0ec1701781dbbb0bb81dd8d0a42d7"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/postgres/zipball/b68c4d5929d0ec1701781dbbb0bb81dd8d0a42d7",
+ "reference": "b68c4d5929d0ec1701781dbbb0bb81dd8d0a42d7",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/amp": "^3",
+ "amphp/pipeline": "^1",
+ "amphp/sql": "^2",
+ "amphp/sql-common": "^2",
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "amphp/phpunit-util": "^3",
+ "ext-pgsql": "*",
+ "ext-pq": "*",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "6.15.1"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/functions.php",
+ "src/Internal/functions.php"
+ ],
+ "psr-4": {
+ "Amp\\Postgres\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ }
+ ],
+ "description": "Asynchronous PostgreSQL client for Amp.",
+ "homepage": "https://amphp.org",
+ "keywords": [
+ "async",
+ "asynchronous",
+ "database",
+ "db",
+ "pgsql",
+ "postgre",
+ "postgresql"
+ ],
+ "support": {
+ "issues": "https://github.com/amphp/postgres/issues",
+ "source": "https://github.com/amphp/postgres/tree/v2.2.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2026-02-25T04:47:18+00:00"
+ },
+ {
+ "name": "amphp/process",
+ "version": "v2.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/process.git",
+ "reference": "583959df17d00304ad7b0b32285373f985935643"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/process/zipball/583959df17d00304ad7b0b32285373f985935643",
+ "reference": "583959df17d00304ad7b0b32285373f985935643",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/amp": "^3",
+ "amphp/byte-stream": "^2",
+ "amphp/sync": "^2",
+ "php": ">=8.1",
+ "revolt/event-loop": "^1 || ^0.2"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "amphp/phpunit-util": "^3",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "6.16.1"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/functions.php"
+ ],
+ "psr-4": {
+ "Amp\\Process\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Bob Weinand",
+ "email": "bobwei9@hotmail.com"
+ },
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ }
+ ],
+ "description": "A fiber-aware process manager based on Amp and Revolt.",
+ "homepage": "https://amphp.org/process",
+ "support": {
+ "issues": "https://github.com/amphp/process/issues",
+ "source": "https://github.com/amphp/process/tree/v2.1.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2026-05-31T15:11:55+00:00"
+ },
+ {
+ "name": "amphp/redis",
+ "version": "v2.0.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/redis.git",
+ "reference": "964bcf6c2574645058371925a3668240a622bdab"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/redis/zipball/964bcf6c2574645058371925a3668240a622bdab",
+ "reference": "964bcf6c2574645058371925a3668240a622bdab",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/amp": "^3",
+ "amphp/byte-stream": "^2",
+ "amphp/cache": "^2",
+ "amphp/parser": "^1",
+ "amphp/pipeline": "^1",
+ "amphp/serialization": "^1",
+ "amphp/socket": "^2",
+ "amphp/sync": "^2",
+ "league/uri": "^7",
+ "php": ">=8.1",
+ "psr/log": "^1|^2|^3",
+ "revolt/event-loop": "^1"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "amphp/phpunit-util": "^3",
+ "amphp/process": "^2",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "5.22"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/functions.php",
+ "src/Internal/functions.php"
+ ],
+ "psr-4": {
+ "Amp\\Redis\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ },
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ }
+ ],
+ "description": "Efficient asynchronous communication with Redis servers, enabling scalable and responsive data storage and retrieval.",
+ "homepage": "https://amphp.org/redis",
+ "keywords": [
+ "amp",
+ "amphp",
+ "async",
+ "client",
+ "redis",
+ "revolt"
+ ],
+ "support": {
+ "issues": "https://github.com/amphp/redis/issues",
+ "source": "https://github.com/amphp/redis/tree/v2.0.4"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2026-03-03T20:52:26+00:00"
+ },
+ {
+ "name": "amphp/serialization",
+ "version": "v1.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/serialization.git",
+ "reference": "fdf2834d78cebb0205fb2672676c1b1eb84371f0"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/serialization/zipball/fdf2834d78cebb0205fb2672676c1b1eb84371f0",
+ "reference": "fdf2834d78cebb0205fb2672676c1b1eb84371f0",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.4"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "ext-json": "*",
+ "ext-zlib": "*",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "6.16.1"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/functions.php"
+ ],
+ "psr-4": {
+ "Amp\\Serialization\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ }
+ ],
+ "description": "Serialization tools for IPC and data storage in PHP.",
+ "homepage": "https://github.com/amphp/serialization",
+ "keywords": [
+ "async",
+ "asynchronous",
+ "serialization",
+ "serialize"
+ ],
+ "support": {
+ "issues": "https://github.com/amphp/serialization/issues",
+ "source": "https://github.com/amphp/serialization/tree/v1.1.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2026-04-05T15:59:53+00:00"
+ },
+ {
+ "name": "amphp/socket",
+ "version": "v2.4.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/socket.git",
+ "reference": "b347be5aff6b2cc025208bb4d896607eb470c018"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/socket/zipball/b347be5aff6b2cc025208bb4d896607eb470c018",
+ "reference": "b347be5aff6b2cc025208bb4d896607eb470c018",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/amp": "^3",
+ "amphp/byte-stream": "^2",
+ "amphp/dns": "^2",
+ "ext-openssl": "*",
+ "kelunik/certificate": "^1.1",
+ "league/uri": "^7",
+ "league/uri-interfaces": "^7",
+ "php": ">=8.1",
+ "revolt/event-loop": "^1"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "amphp/phpunit-util": "^3",
+ "amphp/process": "^2",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "6.16.1"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/functions.php",
+ "src/Internal/functions.php",
+ "src/SocketAddress/functions.php"
+ ],
+ "psr-4": {
+ "Amp\\Socket\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Daniel Lowrey",
+ "email": "rdlowrey@gmail.com"
+ },
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ }
+ ],
+ "description": "Non-blocking socket connection / server implementations based on Amp and Revolt.",
+ "homepage": "https://github.com/amphp/socket",
+ "keywords": [
+ "amp",
+ "async",
+ "encryption",
+ "non-blocking",
+ "sockets",
+ "tcp",
+ "tls"
+ ],
+ "support": {
+ "issues": "https://github.com/amphp/socket/issues",
+ "source": "https://github.com/amphp/socket/tree/v2.4.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2026-08-22T18:13:20+00:00"
+ },
+ {
+ "name": "amphp/sql",
+ "version": "v2.1.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/sql.git",
+ "reference": "258bafe5ecf8a0491d86681f2a2af1dee2933a69"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/sql/zipball/258bafe5ecf8a0491d86681f2a2af1dee2933a69",
+ "reference": "258bafe5ecf8a0491d86681f2a2af1dee2933a69",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/amp": "^3",
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "6.15.1"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Amp\\Sql\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "Asynchronous SQL client for Amp.",
+ "homepage": "https://amphp.org",
+ "keywords": [
+ "async",
+ "asynchronous",
+ "database",
+ "db",
+ "sql"
+ ],
+ "support": {
+ "issues": "https://github.com/amphp/sql/issues",
+ "source": "https://github.com/amphp/sql/tree/v2.1.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2026-02-25T04:44:15+00:00"
+ },
+ {
+ "name": "amphp/sql-common",
+ "version": "v2.0.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/sql-common.git",
+ "reference": "735da17ef0a66e7139c9f7584af5c3f9827f83c0"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/sql-common/zipball/735da17ef0a66e7139c9f7584af5c3f9827f83c0",
+ "reference": "735da17ef0a66e7139c9f7584af5c3f9827f83c0",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/amp": "^3",
+ "amphp/sql": "^2",
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "amphp/phpunit-util": "^3",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "5.23"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Amp\\Sql\\Common\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "Common classes for non-blocking SQL implementations.",
+ "homepage": "https://amphp.org",
+ "keywords": [
+ "async",
+ "asynchronous",
+ "database",
+ "db",
+ "sql"
+ ],
+ "support": {
+ "issues": "https://github.com/amphp/sql-common/issues",
+ "source": "https://github.com/amphp/sql-common/tree/v2.0.4"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2025-12-11T20:05:29+00:00"
+ },
+ {
+ "name": "amphp/sync",
+ "version": "v2.3.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/sync.git",
+ "reference": "217097b785130d77cfcc58ff583cf26cd1770bf1"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/sync/zipball/217097b785130d77cfcc58ff583cf26cd1770bf1",
+ "reference": "217097b785130d77cfcc58ff583cf26cd1770bf1",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/amp": "^3",
+ "amphp/pipeline": "^1",
+ "amphp/serialization": "^1",
+ "php": ">=8.1",
+ "revolt/event-loop": "^1 || ^0.2"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "amphp/phpunit-util": "^3",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "5.23"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/functions.php"
+ ],
+ "psr-4": {
+ "Amp\\Sync\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ },
+ {
+ "name": "Stephen Coakley",
+ "email": "me@stephencoakley.com"
+ }
+ ],
+ "description": "Non-blocking synchronization primitives for PHP based on Amp and Revolt.",
+ "homepage": "https://github.com/amphp/sync",
+ "keywords": [
+ "async",
+ "asynchronous",
+ "mutex",
+ "semaphore",
+ "synchronization"
+ ],
+ "support": {
+ "issues": "https://github.com/amphp/sync/issues",
+ "source": "https://github.com/amphp/sync/tree/v2.3.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2024-08-03T19:31:26+00:00"
+ },
+ {
+ "name": "amphp/websocket",
+ "version": "v2.0.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/websocket.git",
+ "reference": "963904b6a883c4b62d9222d1d9749814fac96a3b"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/websocket/zipball/963904b6a883c4b62d9222d1d9749814fac96a3b",
+ "reference": "963904b6a883c4b62d9222d1d9749814fac96a3b",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/amp": "^3",
+ "amphp/byte-stream": "^2",
+ "amphp/parser": "^1",
+ "amphp/pipeline": "^1",
+ "amphp/socket": "^2",
+ "php": ">=8.1",
+ "revolt/event-loop": "^1"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "amphp/phpunit-util": "^3",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "^5.18"
+ },
+ "suggest": {
+ "ext-zlib": "Required for compression"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/functions.php"
+ ],
+ "psr-4": {
+ "Amp\\Websocket\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ },
+ {
+ "name": "Bob Weinand",
+ "email": "bobwei9@hotmail.com"
+ }
+ ],
+ "description": "Shared code for websocket servers and clients.",
+ "homepage": "https://github.com/amphp/websocket",
+ "keywords": [
+ "amp",
+ "amphp",
+ "async",
+ "http",
+ "non-blocking",
+ "websocket"
+ ],
+ "support": {
+ "issues": "https://github.com/amphp/websocket/issues",
+ "source": "https://github.com/amphp/websocket/tree/v2.0.4"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2024-10-28T21:28:45+00:00"
+ },
+ {
+ "name": "amphp/websocket-client",
+ "version": "v2.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/websocket-client.git",
+ "reference": "dc033fdce0af56295a23f63ac4f579b34d470d6c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/websocket-client/zipball/dc033fdce0af56295a23f63ac4f579b34d470d6c",
+ "reference": "dc033fdce0af56295a23f63ac4f579b34d470d6c",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/amp": "^3",
+ "amphp/byte-stream": "^2.1",
+ "amphp/http": "^2.1",
+ "amphp/http-client": "^5",
+ "amphp/socket": "^2.2",
+ "amphp/websocket": "^2",
+ "league/uri": "^7.1",
+ "php": ">=8.1",
+ "psr/http-message": "^1|^2",
+ "revolt/event-loop": "^1"
+ },
+ "require-dev": {
+ "amphp/http-server": "^3",
+ "amphp/php-cs-fixer-config": "^2",
+ "amphp/phpunit-util": "^3",
+ "amphp/websocket-server": "^3|^4",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "~5.26.1",
+ "psr/log": "^1"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/functions.php"
+ ],
+ "psr-4": {
+ "Amp\\Websocket\\Client\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Bob Weinand",
+ "email": "bobwei9@hotmail.com"
+ },
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ }
+ ],
+ "description": "Async WebSocket client for PHP based on Amp.",
+ "keywords": [
+ "amp",
+ "amphp",
+ "async",
+ "client",
+ "http",
+ "non-blocking",
+ "websocket"
+ ],
+ "support": {
+ "issues": "https://github.com/amphp/websocket-client/issues",
+ "source": "https://github.com/amphp/websocket-client/tree/v2.0.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2025-08-24T17:25:34+00:00"
+ },
+ {
+ "name": "bacon/bacon-qr-code",
+ "version": "v3.1.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Bacon/BaconQrCode.git",
+ "reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2",
+ "reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2",
+ "shasum": ""
+ },
+ "require": {
+ "dasprid/enum": "^1.0.3",
+ "ext-iconv": "*",
+ "php": "^8.1"
+ },
+ "require-dev": {
+ "phly/keep-a-changelog": "^2.12",
+ "phpunit/phpunit": "^10.5.11 || ^11.0.4",
+ "spatie/phpunit-snapshot-assertions": "^5.1.5",
+ "spatie/pixelmatch-php": "^1.2.0",
+ "squizlabs/php_codesniffer": "^3.9"
+ },
+ "suggest": {
+ "ext-imagick": "to generate QR code images"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "BaconQrCode\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-2-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Ben Scholzen 'DASPRiD'",
+ "email": "mail@dasprids.de",
+ "homepage": "https://dasprids.de/",
+ "role": "Developer"
+ }
+ ],
+ "description": "BaconQrCode is a QR code generator for PHP.",
+ "homepage": "https://github.com/Bacon/BaconQrCode",
+ "support": {
+ "issues": "https://github.com/Bacon/BaconQrCode/issues",
+ "source": "https://github.com/Bacon/BaconQrCode/tree/v3.1.1"
+ },
+ "time": "2026-04-05T21:06:35+00:00"
+ },
+ {
+ "name": "danog/async-orm",
+ "version": "1.1.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/danog/AsyncOrm.git",
+ "reference": "495f335d8ddb9dae8b773025911191f31c45e9ea"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/danog/AsyncOrm/zipball/495f335d8ddb9dae8b773025911191f31c45e9ea",
+ "reference": "495f335d8ddb9dae8b773025911191f31c45e9ea",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/mysql": "^3.0",
+ "amphp/postgres": "^2.0",
+ "amphp/redis": "^2.0",
+ "amphp/sync": "^2.2",
+ "php": ">=8.2.4",
+ "revolt/event-loop": "^1.0.6",
+ "symfony/polyfill-php83": "^1.32"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2.0.1",
+ "amphp/process": "^2.0.2",
+ "danog/phpdoc": "^0.1.22",
+ "friendsofphp/php-cs-fixer": "^3.52.1",
+ "infection/infection": "^0.28.1",
+ "phpunit/phpunit": "^11.0.9",
+ "vimeo/psalm": "dev-master"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "danog\\AsyncOrm\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "Apache-2.0"
+ ],
+ "authors": [
+ {
+ "name": "Daniil Gentili",
+ "email": "daniil@daniil.it"
+ },
+ {
+ "name": "Alexander Pankratov",
+ "email": "alexander@i-c-a.su"
+ }
+ ],
+ "description": "Async ORM based on AMPHP v3 and fibers.",
+ "support": {
+ "issues": "https://github.com/danog/AsyncOrm/issues",
+ "source": "https://github.com/danog/AsyncOrm/tree/1.1.4"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/danog",
+ "type": "github"
+ }
+ ],
+ "time": "2025-07-12T18:39:01+00:00"
+ },
+ {
+ "name": "danog/better-prometheus",
+ "version": "0.1.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/danog/better-prometheus.git",
+ "reference": "74e19343be41905572290878ca9fd0ef08ba0c5e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/danog/better-prometheus/zipball/74e19343be41905572290878ca9fd0ef08ba0c5e",
+ "reference": "74e19343be41905572290878ca9fd0ef08ba0c5e",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1",
+ "promphp/prometheus_client_php": "^2.10"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "danog/phpdoc": "^0.1.24",
+ "psalm/phar": "dev-master"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "danog\\BetterPrometheus\\": "lib/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "Apache-2.0"
+ ],
+ "authors": [
+ {
+ "name": "Daniil Gentili",
+ "email": "daniil@daniil.it"
+ }
+ ],
+ "description": "A better Prometheus library for PHP applications",
+ "support": {
+ "issues": "https://github.com/danog/better-prometheus/issues",
+ "source": "https://github.com/danog/better-prometheus/tree/0.1.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/danog",
+ "type": "github"
+ }
+ ],
+ "time": "2024-05-09T19:10:54+00:00"
+ },
+ {
+ "name": "danog/dns-over-https",
+ "version": "1.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/danog/dns-over-https.git",
+ "reference": "142b8d439e7a582d66b13d9f1119820dd3110884"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/danog/dns-over-https/zipball/142b8d439e7a582d66b13d9f1119820dd3110884",
+ "reference": "142b8d439e7a582d66b13d9f1119820dd3110884",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/amp": "^3",
+ "amphp/cache": "^2",
+ "amphp/dns": "^2",
+ "amphp/http-client": "^5",
+ "amphp/parser": "^1",
+ "amphp/socket": "^2",
+ "danog/libdns-json": "^0.2",
+ "daverandom/libdns": "^2.0.1",
+ "ext-filter": "*",
+ "ext-json": "*",
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "amphp/phpunit-util": "^3",
+ "phpunit/phpunit": "^9",
+ "vimeo/psalm": "dev-master"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Amp\\DoH\\": "lib"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Daniil Gentili",
+ "email": "daniil@daniil.it"
+ },
+ {
+ "name": "Chris Wright",
+ "email": "addr@daverandom.com"
+ },
+ {
+ "name": "Daniel Lowrey",
+ "email": "rdlowrey@php.net"
+ },
+ {
+ "name": "Bob Weinand",
+ "email": "bobwei9@hotmail.com"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ },
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ }
+ ],
+ "description": "Async DNS-over-HTTPS resolution for Amp.",
+ "homepage": "https://github.com/danog/dns-over-https",
+ "keywords": [
+ "amp",
+ "amphp",
+ "async",
+ "client",
+ "dns",
+ "dns-over-https",
+ "doh",
+ "https",
+ "resolve"
+ ],
+ "support": {
+ "issues": "https://github.com/danog/dns-over-https/issues",
+ "source": "https://github.com/danog/dns-over-https/tree/1.0.1"
+ },
+ "time": "2024-12-02T16:42:09+00:00"
+ },
+ {
+ "name": "danog/ipc",
+ "version": "1.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/danog/ipc.git",
+ "reference": "505e672b76af6ec623f7ef78efb2416f62ef8e29"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/danog/ipc/zipball/505e672b76af6ec623f7ef78efb2416f62ef8e29",
+ "reference": "505e672b76af6ec623f7ef78efb2416f62ef8e29",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/byte-stream": "^2.1.2",
+ "amphp/parallel": "^2.3.1",
+ "amphp/parser": "^1.1.1",
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "amphp/amp": "v3.x-dev",
+ "amphp/php-cs-fixer-config": "v2.x-dev",
+ "amphp/phpunit-util": "v3.x-dev",
+ "phpunit/phpunit": "^9.6.22",
+ "psalm/phar": "^5.26.1"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "lib/functions.php"
+ ],
+ "psr-4": {
+ "Amp\\Ipc\\": "lib"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Daniil Gentili",
+ "email": "daniil@daniil.it"
+ },
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ },
+ {
+ "name": "Stephen Coakley",
+ "email": "me@stephencoakley.com"
+ }
+ ],
+ "description": "IPC component for Amp.",
+ "homepage": "https://github.com/danog/ipc",
+ "keywords": [
+ "async",
+ "asynchronous",
+ "concurrent",
+ "multi-processing",
+ "multi-threading"
+ ],
+ "support": {
+ "issues": "https://github.com/danog/ipc/issues",
+ "source": "https://github.com/danog/ipc/tree/1.0.1"
+ },
+ "time": "2025-04-18T14:42:00+00:00"
+ },
+ {
+ "name": "danog/libdns-json",
+ "version": "0.2.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/danog/LibDNSJson.git",
+ "reference": "cd2981a386a32ff62d447a9553e203384651ba90"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/danog/LibDNSJson/zipball/cd2981a386a32ff62d447a9553e203384651ba90",
+ "reference": "cd2981a386a32ff62d447a9553e203384651ba90",
+ "shasum": ""
+ },
+ "require": {
+ "daverandom/libdns": "^2.0.1",
+ "ext-json": "*",
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "v2.x-dev",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "^5.1"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "danog\\LibDNSJson\\": "lib"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Daniil Gentili",
+ "email": "daniil@daniil.it"
+ },
+ {
+ "name": "Chris Wright",
+ "email": "addr@daverandom.com"
+ }
+ ],
+ "description": "Encoder/decoder for google's JSON DNS message format based on libdns",
+ "homepage": "https://github.com/danog/libdns-json",
+ "keywords": [
+ "dns",
+ "dns-over-https",
+ "doh",
+ "https",
+ "json",
+ "libdns",
+ "message"
+ ],
+ "support": {
+ "issues": "https://github.com/danog/LibDNSJson/issues",
+ "source": "https://github.com/danog/LibDNSJson/tree/0.2.1"
+ },
+ "time": "2024-12-02T16:40:37+00:00"
+ },
+ {
+ "name": "danog/loop",
+ "version": "1.1.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/danog/loop.git",
+ "reference": "83c42b26c1d8d070c56bc9c03e2e8a147b45c60f"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/danog/loop/zipball/83c42b26c1d8d070c56bc9c03e2e8a147b45c60f",
+ "reference": "83c42b26c1d8d070c56bc9c03e2e8a147b45c60f",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/amp": "^3",
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "amphp/phpunit-util": "^3",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "dev-master"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "danog\\Loop\\": "lib"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Daniil Gentili",
+ "email": "daniil@daniil.it"
+ }
+ ],
+ "description": "Loop abstraction for AMPHP.",
+ "homepage": "https://github.com/danog/loop",
+ "keywords": [
+ "async",
+ "asynchronous",
+ "concurrent",
+ "multi-processing",
+ "multi-threading"
+ ],
+ "support": {
+ "issues": "https://github.com/danog/loop/issues",
+ "source": "https://github.com/danog/loop/tree/1.1.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/danog",
+ "type": "github"
+ }
+ ],
+ "time": "2023-09-30T12:45:07+00:00"
+ },
+ {
+ "name": "danog/madelineproto",
+ "version": "8.7.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/danog/MadelineProto.git",
+ "reference": "7b99dd0f2d964a492c377912ffce9f5ce61aa9ed"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/danog/MadelineProto/zipball/7b99dd0f2d964a492c377912ffce9f5ce61aa9ed",
+ "reference": "7b99dd0f2d964a492c377912ffce9f5ce61aa9ed",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/amp": "^3.1.1",
+ "amphp/byte-stream": "^2.1.2",
+ "amphp/dns": "^2.4.0",
+ "amphp/file": "^3.2.0",
+ "amphp/http": "^2.1.2",
+ "amphp/http-client": "^5.3.4",
+ "amphp/http-client-cookies": "^2",
+ "amphp/http-server": "^3.4.3",
+ "amphp/log": "^2",
+ "amphp/mysql": "^3",
+ "amphp/postgres": "^2.1.1",
+ "amphp/redis": "^2.0.3",
+ "amphp/socket": "^2.3.1",
+ "amphp/websocket-client": "^2.0.2",
+ "bacon/bacon-qr-code": "^3.0.1",
+ "danog/async-orm": "^1.1.4",
+ "danog/better-prometheus": "^0.1.1",
+ "danog/dns-over-https": "^1.0.1",
+ "danog/ipc": "^1.0.1",
+ "danog/loop": "^1.1.1",
+ "danog/primemodule": "^1.0.13",
+ "danog/telegram-entities": "^1.0.5",
+ "danog/tg-dialog-id": "^2",
+ "danog/tg-file-decoder": "^1.0.2",
+ "ext-dom": "*",
+ "ext-fileinfo": "*",
+ "ext-filter": "*",
+ "ext-hash": "*",
+ "ext-json": "*",
+ "ext-xml": "*",
+ "ext-zlib": "*",
+ "league/uri": "^7.5.1",
+ "nikic/php-parser": "^5.6.2",
+ "php-64bit": ">=8.2",
+ "phpseclib/phpseclib": "^3.0.47",
+ "psr/http-factory": "^1.1.0",
+ "psr/log": "^3.0.2",
+ "revolt/event-loop": "^1.0.7",
+ "symfony/polyfill-mbstring": ">=1.32",
+ "symfony/polyfill-php83": "^1.32",
+ "webmozart/assert": "^1.12.1"
+ },
+ "conflict": {
+ "ext-pthreads": "*",
+ "krakjoe/pthreads-polyfill": "*"
+ },
+ "require-dev": {
+ "amphp/phpunit-util": "^3",
+ "bamarni/composer-bin-plugin": "1.8.2",
+ "brianium/paratest": "^6.11.1",
+ "danog/phpdoc": "^0.1.24",
+ "dg/bypass-finals": "dev-master",
+ "ext-ctype": "*",
+ "phpunit/phpunit": "^9.6.29",
+ "quasarstream/webrtc": "^1.0",
+ "revolt/event-loop-adapter-react": "^1.1.1",
+ "sebastian/diff": "^4.0",
+ "symfony/yaml": "^6.4.26",
+ "vimeo/psalm": "dev-master"
+ },
+ "suggest": {
+ "ext-bcmath": "Install the bcmath extension to speed up authorization",
+ "ext-ffi": "Install the primemodule and FFI extensions to speed up MadelineProto (https://prime.madelineproto.xyz)",
+ "ext-gmp": "Install the gmp extension to speed up authorization",
+ "ext-openssl": "Install the openssl extension for faster crypto",
+ "ext-pdo": "Install the pdo extension to store session data on MySQL",
+ "ext-primemodule": "Install the primemodule and FFI extensions to speed up MadelineProto (https://prime.madelineproto.xyz)",
+ "ext-uv": "Install the uv extension to greatly speed up MadelineProto!"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/polyfill.php"
+ ],
+ "psr-4": {
+ "danog\\MadelineProto\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "AGPL-3.0-only"
+ ],
+ "authors": [
+ {
+ "name": "Daniil Gentili",
+ "email": "daniil@daniil.it"
+ }
+ ],
+ "description": "Async PHP client API for the telegram MTProto protocol.",
+ "homepage": "https://docs.madelineproto.xyz",
+ "keywords": [
+ "GB",
+ "Messenger",
+ "audio",
+ "bytes",
+ "client",
+ "files",
+ "mtproto",
+ "php",
+ "protocol",
+ "stickers",
+ "telegram",
+ "video"
+ ],
+ "support": {
+ "issues": "https://github.com/danog/MadelineProto/issues",
+ "source": "https://github.com/danog/MadelineProto/tree/8.7.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/danog",
+ "type": "github"
+ }
+ ],
+ "time": "2026-05-12T07:44:57+00:00"
+ },
+ {
+ "name": "danog/primemodule",
+ "version": "1.0.13",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/danog/PrimeModule.git",
+ "reference": "e7ebfc1ea64c24186e49064cd4b76bc76c28600e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/danog/PrimeModule/zipball/e7ebfc1ea64c24186e49064cd4b76bc76c28600e",
+ "reference": "e7ebfc1ea64c24186e49064cd4b76bc76c28600e",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^8.0"
+ },
+ "require-dev": {
+ "phabel/phabel": "^1"
+ },
+ "suggest": {
+ "ext-primemodule": "Install the native C++ extension for extremely fast factorization (https://prime.madelineproto.xyz)"
+ },
+ "type": "library",
+ "extra": {
+ "phabel": {
+ "revision": 0
+ }
+ },
+ "autoload": {
+ "psr-0": {
+ "danog\\": "lib/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "AGPL-3.0-only"
+ ],
+ "authors": [
+ {
+ "name": "Daniil Gentili",
+ "email": "daniil@daniil.it"
+ }
+ ],
+ "description": "Prime module capable of doing prime factorization of huge numbers very quickly.\"",
+ "support": {
+ "issues": "https://github.com/danog/PrimeModule/issues",
+ "source": "https://github.com/danog/PrimeModule/tree/1.0.13"
+ },
+ "time": "2023-05-27T14:41:40+00:00"
+ },
+ {
+ "name": "danog/telegram-entities",
+ "version": "1.0.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/danog/telegram-entities.git",
+ "reference": "19ab8a48bc4b9519c4493e2c62eb171fba8b58b4"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/danog/telegram-entities/zipball/19ab8a48bc4b9519c4493e2c62eb171fba8b58b4",
+ "reference": "19ab8a48bc4b9519c4493e2c62eb171fba8b58b4",
+ "shasum": ""
+ },
+ "require": {
+ "php-64bit": ">=8.2.4",
+ "symfony/polyfill-mbstring": "*",
+ "webmozart/assert": "^1.11"
+ },
+ "require-dev": {
+ "amphp/http-client": "^5.0",
+ "amphp/php-cs-fixer-config": "^2.0.1",
+ "danog/phpdoc": "^0.1.22",
+ "friendsofphp/php-cs-fixer": "^3.52.1",
+ "infection/infection": "^0.28.1",
+ "phpunit/phpunit": "^11.0.9",
+ "vimeo/psalm": "dev-master"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "danog\\TelegramEntities\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "Apache-2.0"
+ ],
+ "authors": [
+ {
+ "name": "Daniil Gentili",
+ "email": "daniil@daniil.it"
+ }
+ ],
+ "description": "A library to work with Telegram UTF-16 styled text entities.",
+ "support": {
+ "issues": "https://github.com/danog/telegram-entities/issues",
+ "source": "https://github.com/danog/telegram-entities/tree/1.0.5"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/danog",
+ "type": "github"
+ }
+ ],
+ "time": "2025-06-22T12:27:39+00:00"
+ },
+ {
+ "name": "danog/tg-dialog-id",
+ "version": "2.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/danog/tg-dialog-id.git",
+ "reference": "d03c653b2dad43d739675c83a6e8fa14b3b43636"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/danog/tg-dialog-id/zipball/d03c653b2dad43d739675c83a6e8fa14b3b43636",
+ "reference": "d03c653b2dad43d739675c83a6e8fa14b3b43636",
+ "shasum": ""
+ },
+ "require": {
+ "php-64bit": ">=8.1"
+ },
+ "require-dev": {
+ "amphp/file": "^3.1",
+ "amphp/php-cs-fixer-config": "^2.0.1",
+ "danog/phpdoc": "^0.1.22",
+ "infection/infection": "^0.28.1",
+ "phpunit/phpunit": "^11.0.9|^10",
+ "vimeo/psalm": "dev-master"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "danog\\DialogId\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "Apache-2.0"
+ ],
+ "authors": [
+ {
+ "name": "Daniil Gentili",
+ "email": "daniil@daniil.it"
+ }
+ ],
+ "description": "A library to work with Telegram bot API dialog IDs",
+ "support": {
+ "issues": "https://github.com/danog/tg-dialog-id/issues",
+ "source": "https://github.com/danog/tg-dialog-id/tree/2.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/danog",
+ "type": "github"
+ }
+ ],
+ "time": "2025-06-28T15:36:24+00:00"
+ },
+ {
+ "name": "danog/tg-file-decoder",
+ "version": "1.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/danog/tg-file-decoder.git",
+ "reference": "f4da78c28d47b658c4596249f1d6595956f3b796"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/danog/tg-file-decoder/zipball/f4da78c28d47b658c4596249f1d6595956f3b796",
+ "reference": "f4da78c28d47b658c4596249f1d6595956f3b796",
+ "shasum": ""
+ },
+ "require": {
+ "php-64bit": ">=8.2"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "danog/phpdoc": "^0.1.22",
+ "phpunit/phpunit": "^11",
+ "vimeo/psalm": "dev-master"
+ },
+ "type": "project",
+ "autoload": {
+ "psr-4": {
+ "danog\\Decoder\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "AGPL-3.0-only"
+ ],
+ "authors": [
+ {
+ "name": "Daniil Gentili",
+ "email": "daniil@daniil.it"
+ }
+ ],
+ "description": "Decode Telegram bot API file IDs",
+ "keywords": [
+ "audio",
+ "bot api",
+ "file ID",
+ "files",
+ "mtproto",
+ "stickers",
+ "telegram",
+ "video"
+ ],
+ "support": {
+ "issues": "https://github.com/danog/tg-file-decoder/issues",
+ "source": "https://github.com/danog/tg-file-decoder/tree/1.0.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/danog",
+ "type": "github"
+ }
+ ],
+ "time": "2025-05-31T15:14:35+00:00"
+ },
+ {
+ "name": "dasprid/enum",
+ "version": "1.0.7",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/DASPRiD/Enum.git",
+ "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/DASPRiD/Enum/zipball/b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
+ "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.1 <9.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11",
+ "squizlabs/php_codesniffer": "*"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "DASPRiD\\Enum\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-2-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Ben Scholzen 'DASPRiD'",
+ "email": "mail@dasprids.de",
+ "homepage": "https://dasprids.de/",
+ "role": "Developer"
+ }
+ ],
+ "description": "PHP 7.1 enum implementation",
+ "keywords": [
+ "enum",
+ "map"
+ ],
+ "support": {
+ "issues": "https://github.com/DASPRiD/Enum/issues",
+ "source": "https://github.com/DASPRiD/Enum/tree/1.0.7"
+ },
+ "time": "2025-09-16T12:23:56+00:00"
+ },
+ {
+ "name": "daverandom/libdns",
+ "version": "v2.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/DaveRandom/LibDNS.git",
+ "reference": "b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/DaveRandom/LibDNS/zipball/b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a",
+ "reference": "b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a",
+ "shasum": ""
+ },
+ "require": {
+ "ext-ctype": "*",
+ "php": ">=7.1"
+ },
+ "suggest": {
+ "ext-intl": "Required for IDN support"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/functions.php"
+ ],
+ "psr-4": {
+ "LibDNS\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "DNS protocol implementation written in pure PHP",
+ "keywords": [
+ "dns"
+ ],
+ "support": {
+ "issues": "https://github.com/DaveRandom/LibDNS/issues",
+ "source": "https://github.com/DaveRandom/LibDNS/tree/v2.1.0"
+ },
+ "time": "2024-04-12T12:12:48+00:00"
+ },
+ {
+ "name": "kelunik/certificate",
+ "version": "v1.1.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/kelunik/certificate.git",
+ "reference": "7e00d498c264d5eb4f78c69f41c8bd6719c0199e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/kelunik/certificate/zipball/7e00d498c264d5eb4f78c69f41c8bd6719c0199e",
+ "reference": "7e00d498c264d5eb4f78c69f41c8bd6719c0199e",
+ "shasum": ""
+ },
+ "require": {
+ "ext-openssl": "*",
+ "php": ">=7.0"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "phpunit/phpunit": "^6 | 7 | ^8 | ^9"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Kelunik\\Certificate\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ }
+ ],
+ "description": "Access certificate details and transform between different formats.",
+ "keywords": [
+ "DER",
+ "certificate",
+ "certificates",
+ "openssl",
+ "pem",
+ "x509"
+ ],
+ "support": {
+ "issues": "https://github.com/kelunik/certificate/issues",
+ "source": "https://github.com/kelunik/certificate/tree/v1.1.3"
+ },
+ "time": "2023-02-03T21:26:53+00:00"
+ },
+ {
+ "name": "league/uri",
+ "version": "7.8.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/thephpleague/uri.git",
+ "reference": "08cf38e3924d4f56238125547b5720496fac8fd4"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/thephpleague/uri/zipball/08cf38e3924d4f56238125547b5720496fac8fd4",
+ "reference": "08cf38e3924d4f56238125547b5720496fac8fd4",
+ "shasum": ""
+ },
+ "require": {
+ "league/uri-interfaces": "^7.8.1",
+ "php": "^8.1",
+ "psr/http-factory": "^1"
+ },
+ "conflict": {
+ "league/uri-schemes": "^1.0"
+ },
+ "suggest": {
+ "ext-bcmath": "to improve IPV4 host parsing",
+ "ext-dom": "to convert the URI into an HTML anchor tag",
+ "ext-fileinfo": "to create Data URI from file contennts",
+ "ext-gmp": "to improve IPV4 host parsing",
+ "ext-intl": "to handle IDN host with the best performance",
+ "ext-uri": "to use the PHP native URI class",
+ "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain",
+ "league/uri-components": "to provide additional tools to manipulate URI objects components",
+ "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP",
+ "php-64bit": "to improve IPV4 host parsing",
+ "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification",
+ "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "7.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "League\\Uri\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Ignace Nyamagana Butera",
+ "email": "nyamsprod@gmail.com",
+ "homepage": "https://nyamsprod.com"
+ }
+ ],
+ "description": "URI manipulation library",
+ "homepage": "https://uri.thephpleague.com",
+ "keywords": [
+ "URN",
+ "data-uri",
+ "file-uri",
+ "ftp",
+ "hostname",
+ "http",
+ "https",
+ "middleware",
+ "parse_str",
+ "parse_url",
+ "psr-7",
+ "query-string",
+ "querystring",
+ "rfc2141",
+ "rfc3986",
+ "rfc3987",
+ "rfc6570",
+ "rfc8141",
+ "uri",
+ "uri-template",
+ "url",
+ "ws"
+ ],
+ "support": {
+ "docs": "https://uri.thephpleague.com",
+ "forum": "https://thephpleague.slack.com",
+ "issues": "https://github.com/thephpleague/uri-src/issues",
+ "source": "https://github.com/thephpleague/uri/tree/7.8.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sponsors/nyamsprod",
+ "type": "github"
+ }
+ ],
+ "time": "2026-03-15T20:22:25+00:00"
+ },
+ {
+ "name": "league/uri-components",
+ "version": "7.8.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/thephpleague/uri-components.git",
+ "reference": "848ff9db2f0be06229d6034b7c2e33d41b4fd675"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/thephpleague/uri-components/zipball/848ff9db2f0be06229d6034b7c2e33d41b4fd675",
+ "reference": "848ff9db2f0be06229d6034b7c2e33d41b4fd675",
+ "shasum": ""
+ },
+ "require": {
+ "league/uri": "^7.8.1",
+ "php": "^8.1"
+ },
+ "suggest": {
+ "ext-bcmath": "to improve IPV4 host parsing",
+ "ext-fileinfo": "to create Data URI from file contennts",
+ "ext-gmp": "to improve IPV4 host parsing",
+ "ext-intl": "to handle IDN host with the best performance",
+ "ext-mbstring": "to use the sorting algorithm of URLSearchParams",
+ "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain",
+ "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP",
+ "php-64bit": "to improve IPV4 host parsing",
+ "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification",
+ "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "7.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "League\\Uri\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Ignace Nyamagana Butera",
+ "email": "nyamsprod@gmail.com",
+ "homepage": "https://nyamsprod.com"
+ }
+ ],
+ "description": "URI components manipulation library",
+ "homepage": "http://uri.thephpleague.com",
+ "keywords": [
+ "authority",
+ "components",
+ "fragment",
+ "host",
+ "middleware",
+ "modifier",
+ "path",
+ "port",
+ "query",
+ "rfc3986",
+ "scheme",
+ "uri",
+ "url",
+ "userinfo"
+ ],
+ "support": {
+ "docs": "https://uri.thephpleague.com",
+ "forum": "https://thephpleague.slack.com",
+ "issues": "https://github.com/thephpleague/uri-src/issues",
+ "source": "https://github.com/thephpleague/uri-components/tree/7.8.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/nyamsprod",
+ "type": "github"
+ }
+ ],
+ "time": "2026-03-15T20:22:25+00:00"
+ },
+ {
+ "name": "league/uri-interfaces",
+ "version": "7.8.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/thephpleague/uri-interfaces.git",
+ "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/85d5c77c5d6d3af6c54db4a78246364908f3c928",
+ "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928",
+ "shasum": ""
+ },
+ "require": {
+ "ext-filter": "*",
+ "php": "^8.1",
+ "psr/http-message": "^1.1 || ^2.0"
+ },
+ "suggest": {
+ "ext-bcmath": "to improve IPV4 host parsing",
+ "ext-gmp": "to improve IPV4 host parsing",
+ "ext-intl": "to handle IDN host with the best performance",
+ "php-64bit": "to improve IPV4 host parsing",
+ "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification",
+ "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "7.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "League\\Uri\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Ignace Nyamagana Butera",
+ "email": "nyamsprod@gmail.com",
+ "homepage": "https://nyamsprod.com"
+ }
+ ],
+ "description": "Common tools for parsing and resolving RFC3987/RFC3986 URI",
+ "homepage": "https://uri.thephpleague.com",
+ "keywords": [
+ "data-uri",
+ "file-uri",
+ "ftp",
+ "hostname",
+ "http",
+ "https",
+ "parse_str",
+ "parse_url",
+ "psr-7",
+ "query-string",
+ "querystring",
+ "rfc3986",
+ "rfc3987",
+ "rfc6570",
+ "uri",
+ "url",
+ "ws"
+ ],
+ "support": {
+ "docs": "https://uri.thephpleague.com",
+ "forum": "https://thephpleague.slack.com",
+ "issues": "https://github.com/thephpleague/uri-src/issues",
+ "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sponsors/nyamsprod",
+ "type": "github"
+ }
+ ],
+ "time": "2026-03-08T20:05:35+00:00"
+ },
+ {
+ "name": "monolog/monolog",
+ "version": "3.12.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Seldaek/monolog.git",
+ "reference": "d7059e4c691d05ea1222b5124bed0f961be46e15"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Seldaek/monolog/zipball/d7059e4c691d05ea1222b5124bed0f961be46e15",
+ "reference": "d7059e4c691d05ea1222b5124bed0f961be46e15",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1",
+ "psr/log": "^2.0 || ^3.0"
+ },
+ "provide": {
+ "psr/log-implementation": "3.0.0"
+ },
+ "require-dev": {
+ "aws/aws-sdk-php": "^3.0",
+ "doctrine/couchdb": "~1.0@dev",
+ "elasticsearch/elasticsearch": "^7 || ^8",
+ "ext-json": "*",
+ "graylog2/gelf-php": "^1.4.2 || ^2.0",
+ "guzzlehttp/guzzle": "^7.4.5",
+ "guzzlehttp/psr7": "^2.2",
+ "mongodb/mongodb": "^1.8 || ^2.0",
+ "php-amqplib/php-amqplib": "~2.4 || ^3",
+ "php-console/php-console": "^3.1.8",
+ "phpstan/phpstan": "^2",
+ "phpstan/phpstan-deprecation-rules": "^2",
+ "phpstan/phpstan-strict-rules": "^2",
+ "phpunit/phpunit": "^10.5.17 || ^11.0.7",
+ "predis/predis": "^1.1 || ^2",
+ "psr/clock": "^1.0",
+ "rollbar/rollbar": "^4.0",
+ "ruflin/elastica": "^7 || ^8",
+ "symfony/mailer": "^5.4 || ^6",
+ "symfony/mime": "^5.4 || ^6"
+ },
+ "suggest": {
+ "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB",
+ "doctrine/couchdb": "Allow sending log messages to a CouchDB server",
+ "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client",
+ "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)",
+ "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler",
+ "ext-mbstring": "Allow to work properly with unicode symbols",
+ "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)",
+ "ext-openssl": "Required to send log messages using SSL",
+ "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)",
+ "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server",
+ "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)",
+ "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib",
+ "psr/clock": "Required to pass a clock to the Logger and control the timestamp of log records",
+ "rollbar/rollbar": "Allow sending log messages to Rollbar",
+ "ruflin/elastica": "Allow sending log messages to an Elastic Search server"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "3.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Monolog\\": "src/Monolog"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Jordi Boggiano",
+ "email": "j.boggiano@seld.be",
+ "homepage": "https://seld.be"
+ }
+ ],
+ "description": "Sends your logs to files, sockets, inboxes, databases and various web services",
+ "homepage": "https://github.com/Seldaek/monolog",
+ "keywords": [
+ "log",
+ "logging",
+ "psr-3"
+ ],
+ "support": {
+ "issues": "https://github.com/Seldaek/monolog/issues",
+ "source": "https://github.com/Seldaek/monolog/tree/3.12.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/Seldaek",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/monolog/monolog",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-09-29T19:21:12+00:00"
+ },
+ {
+ "name": "nikic/php-parser",
+ "version": "v5.9.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/nikic/PHP-Parser.git",
+ "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/9e33da9553fe7786f0962b35f4e4ecf01be89def",
+ "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def",
+ "shasum": ""
+ },
+ "require": {
+ "ext-json": "*",
+ "ext-tokenizer": "*",
+ "php": ">=7.4"
+ },
+ "require-dev": {
+ "ircmaxell/php-yacc": "^0.0.7",
+ "phpunit/phpunit": "^9.0"
+ },
+ "bin": [
+ "bin/php-parse"
+ ],
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "5.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "PhpParser\\": "lib/PhpParser"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Nikita Popov"
+ }
+ ],
+ "description": "A PHP parser written in PHP",
+ "keywords": [
+ "parser",
+ "php"
+ ],
+ "support": {
+ "issues": "https://github.com/nikic/PHP-Parser/issues",
+ "source": "https://github.com/nikic/PHP-Parser/tree/v5.9.0"
+ },
+ "time": "2026-09-13T18:51:52+00:00"
+ },
+ {
+ "name": "paragonie/constant_time_encoding",
+ "version": "v3.1.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/paragonie/constant_time_encoding.git",
+ "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77",
+ "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^8"
+ },
+ "require-dev": {
+ "infection/infection": "^0",
+ "nikic/php-fuzzer": "^0",
+ "phpunit/phpunit": "^9|^10|^11",
+ "vimeo/psalm": "^4|^5|^6"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "ParagonIE\\ConstantTime\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Paragon Initiative Enterprises",
+ "email": "security@paragonie.com",
+ "homepage": "https://paragonie.com",
+ "role": "Maintainer"
+ },
+ {
+ "name": "Steve 'Sc00bz' Thomas",
+ "email": "steve@tobtu.com",
+ "homepage": "https://www.tobtu.com",
+ "role": "Original Developer"
+ }
+ ],
+ "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)",
+ "keywords": [
+ "base16",
+ "base32",
+ "base32_decode",
+ "base32_encode",
+ "base64",
+ "base64_decode",
+ "base64_encode",
+ "bin2hex",
+ "encoding",
+ "hex",
+ "hex2bin",
+ "rfc4648"
+ ],
+ "support": {
+ "email": "info@paragonie.com",
+ "issues": "https://github.com/paragonie/constant_time_encoding/issues",
+ "source": "https://github.com/paragonie/constant_time_encoding"
+ },
+ "time": "2025-09-24T15:06:41+00:00"
+ },
+ {
+ "name": "paragonie/random_compat",
+ "version": "v9.99.100",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/paragonie/random_compat.git",
+ "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a",
+ "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">= 7"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "4.*|5.*",
+ "vimeo/psalm": "^1"
+ },
+ "suggest": {
+ "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes."
+ },
+ "type": "library",
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Paragon Initiative Enterprises",
+ "email": "security@paragonie.com",
+ "homepage": "https://paragonie.com"
+ }
+ ],
+ "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7",
+ "keywords": [
+ "csprng",
+ "polyfill",
+ "pseudorandom",
+ "random"
+ ],
+ "support": {
+ "email": "info@paragonie.com",
+ "issues": "https://github.com/paragonie/random_compat/issues",
+ "source": "https://github.com/paragonie/random_compat"
+ },
+ "time": "2020-10-15T08:29:30+00:00"
+ },
+ {
+ "name": "phpseclib/phpseclib",
+ "version": "3.0.57",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phpseclib/phpseclib.git",
+ "reference": "d17e0ddaeaf6f22f7e007cbb437d78792fe2a0e4"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/d17e0ddaeaf6f22f7e007cbb437d78792fe2a0e4",
+ "reference": "d17e0ddaeaf6f22f7e007cbb437d78792fe2a0e4",
+ "shasum": ""
+ },
+ "require": {
+ "paragonie/constant_time_encoding": "^1|^2|^3",
+ "paragonie/random_compat": "^1.4|^2.0|^9.99.99",
+ "php": ">=5.6.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "*"
+ },
+ "suggest": {
+ "ext-dom": "Install the DOM extension to load XML formatted public keys.",
+ "ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.",
+ "ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.",
+ "ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.",
+ "ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations."
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "phpseclib/bootstrap.php"
+ ],
+ "psr-4": {
+ "phpseclib3\\": "phpseclib/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Jim Wigginton",
+ "email": "terrafrost@php.net",
+ "role": "Lead Developer"
+ },
+ {
+ "name": "Patrick Monnerat",
+ "email": "pm@datasphere.ch",
+ "role": "Developer"
+ },
+ {
+ "name": "Andreas Fischer",
+ "email": "bantu@phpbb.com",
+ "role": "Developer"
+ },
+ {
+ "name": "Hans-Jรผrgen Petrich",
+ "email": "petrich@tronic-media.com",
+ "role": "Developer"
+ },
+ {
+ "name": "Graham Campbell",
+ "email": "graham@alt-three.com",
+ "role": "Developer"
+ }
+ ],
+ "description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.",
+ "homepage": "http://phpseclib.sourceforge.net",
+ "keywords": [
+ "BigInteger",
+ "aes",
+ "asn.1",
+ "asn1",
+ "blowfish",
+ "crypto",
+ "cryptography",
+ "encryption",
+ "rsa",
+ "security",
+ "sftp",
+ "signature",
+ "signing",
+ "ssh",
+ "twofish",
+ "x.509",
+ "x509"
+ ],
+ "support": {
+ "issues": "https://github.com/phpseclib/phpseclib/issues",
+ "source": "https://github.com/phpseclib/phpseclib/tree/3.0.57"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/terrafrost",
+ "type": "github"
+ },
+ {
+ "url": "https://www.patreon.com/phpseclib",
+ "type": "patreon"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-08-26T12:13:21+00:00"
+ },
+ {
+ "name": "promphp/prometheus_client_php",
+ "version": "v2.15.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/PromPHP/prometheus_client_php.git",
+ "reference": "e153786bda5ca1d07335a2b5f477742c37195c40"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/PromPHP/prometheus_client_php/zipball/e153786bda5ca1d07335a2b5f477742c37195c40",
+ "reference": "e153786bda5ca1d07335a2b5f477742c37195c40",
+ "shasum": ""
+ },
+ "require": {
+ "ext-json": "*",
+ "php": "^8.2"
+ },
+ "replace": {
+ "endclothing/prometheus_client_php": "*",
+ "jimdo/prometheus_client_php": "*",
+ "lkaemmerling/prometheus_client_php": "*"
+ },
+ "require-dev": {
+ "guzzlehttp/guzzle": "^6.3|^7.0",
+ "phpstan/extension-installer": "^1.0",
+ "phpstan/phpstan": "^1.5.4",
+ "phpstan/phpstan-phpunit": "^1.1.0",
+ "phpstan/phpstan-strict-rules": "^1.1.0",
+ "phpunit/phpunit": "^9.4",
+ "predis/predis": "^2.3",
+ "squizlabs/php_codesniffer": "^3.6 || ^4.0",
+ "symfony/polyfill-apcu": "^1.6"
+ },
+ "suggest": {
+ "ext-apc": "Required if using APCu.",
+ "ext-pdo": "Required if using PDO.",
+ "ext-redis": "Required if using Redis.",
+ "predis/predis": "Required if using Predis.",
+ "promphp/prometheus_push_gateway_php": "An easy client for using Prometheus PushGateway.",
+ "symfony/polyfill-apcu": "Required if you use APCu."
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Prometheus\\": "src/Prometheus/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "Apache-2.0"
+ ],
+ "authors": [
+ {
+ "name": "Lukas Kรคmmerling",
+ "email": "kontakt@lukas-kaemmerling.de"
+ }
+ ],
+ "description": "Prometheus instrumentation library for PHP applications.",
+ "support": {
+ "issues": "https://github.com/PromPHP/prometheus_client_php/issues",
+ "source": "https://github.com/PromPHP/prometheus_client_php/tree/v2.15.1"
+ },
+ "time": "2026-05-28T07:51:04+00:00"
+ },
+ {
+ "name": "psr/http-factory",
+ "version": "1.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/http-factory.git",
+ "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
+ "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.1",
+ "psr/http-message": "^1.0 || ^2.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Http\\Message\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories",
+ "keywords": [
+ "factory",
+ "http",
+ "message",
+ "psr",
+ "psr-17",
+ "psr-7",
+ "request",
+ "response"
+ ],
+ "support": {
+ "source": "https://github.com/php-fig/http-factory"
+ },
+ "time": "2024-04-15T12:06:14+00:00"
+ },
+ {
+ "name": "psr/http-message",
+ "version": "2.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/http-message.git",
+ "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71",
+ "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2 || ^8.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Http\\Message\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interface for HTTP messages",
+ "homepage": "https://github.com/php-fig/http-message",
+ "keywords": [
+ "http",
+ "http-message",
+ "psr",
+ "psr-7",
+ "request",
+ "response"
+ ],
+ "support": {
+ "source": "https://github.com/php-fig/http-message/tree/2.0"
+ },
+ "time": "2023-04-04T09:54:51+00:00"
+ },
+ {
+ "name": "psr/log",
+ "version": "3.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/log.git",
+ "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
+ "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.0.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Log\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interface for logging libraries",
+ "homepage": "https://github.com/php-fig/log",
+ "keywords": [
+ "log",
+ "psr",
+ "psr-3"
+ ],
+ "support": {
+ "source": "https://github.com/php-fig/log/tree/3.0.2"
+ },
+ "time": "2024-09-11T13:17:53+00:00"
+ },
+ {
+ "name": "revolt/event-loop",
+ "version": "v1.0.9",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/revoltphp/event-loop.git",
+ "reference": "44061cf513e53c6200372fc935ac42271566295d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/revoltphp/event-loop/zipball/44061cf513e53c6200372fc935ac42271566295d",
+ "reference": "44061cf513e53c6200372fc935ac42271566295d",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "ext-json": "*",
+ "jetbrains/phpstorm-stubs": "^2019.3",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "6.16.*"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "1.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Revolt\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ },
+ {
+ "name": "Cees-Jan Kiewiet",
+ "email": "ceesjank@gmail.com"
+ },
+ {
+ "name": "Christian Lรผck",
+ "email": "christian@clue.engineering"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ }
+ ],
+ "description": "Rock-solid event loop for concurrent PHP applications.",
+ "keywords": [
+ "async",
+ "asynchronous",
+ "concurrency",
+ "event",
+ "event-loop",
+ "non-blocking",
+ "scheduler"
+ ],
+ "support": {
+ "issues": "https://github.com/revoltphp/event-loop/issues",
+ "source": "https://github.com/revoltphp/event-loop/tree/v1.0.9"
+ },
+ "time": "2026-05-16T17:55:38+00:00"
+ },
+ {
+ "name": "symfony/polyfill-mbstring",
+ "version": "v1.43.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-mbstring.git",
+ "reference": "4d5d331bf731e0aedbc6b2f5e61135b7b7996a72"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/4d5d331bf731e0aedbc6b2f5e61135b7b7996a72",
+ "reference": "4d5d331bf731e0aedbc6b2f5e61135b7b7996a72",
+ "shasum": ""
+ },
+ "require": {
+ "ext-iconv": "*",
+ "php": ">=7.2"
+ },
+ "provide": {
+ "ext-mbstring": "*"
+ },
+ "suggest": {
+ "ext-mbstring": "For best performance"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Mbstring\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill for the Mbstring extension",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "mbstring",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.43.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-09-24T15:19:04+00:00"
+ },
+ {
+ "name": "symfony/polyfill-php83",
+ "version": "v1.43.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-php83.git",
+ "reference": "80ccff923a8d61f73ebe9da0aa94e25232f004ce"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/80ccff923a8d61f73ebe9da0aa94e25232f004ce",
+ "reference": "80ccff923a8d61f73ebe9da0aa94e25232f004ce",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Php83\\": ""
+ },
+ "classmap": [
+ "Resources/stubs"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-php83/tree/v1.43.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-09-25T15:50:05+00:00"
+ },
+ {
+ "name": "webmozart/assert",
+ "version": "1.12.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/webmozarts/assert.git",
+ "reference": "9be6926d8b485f55b9229203f962b51ed377ba68"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/webmozarts/assert/zipball/9be6926d8b485f55b9229203f962b51ed377ba68",
+ "reference": "9be6926d8b485f55b9229203f962b51ed377ba68",
+ "shasum": ""
+ },
+ "require": {
+ "ext-ctype": "*",
+ "ext-date": "*",
+ "ext-filter": "*",
+ "php": "^7.2 || ^8.0"
+ },
+ "suggest": {
+ "ext-intl": "",
+ "ext-simplexml": "",
+ "ext-spl": ""
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.10-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Webmozart\\Assert\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Bernhard Schussek",
+ "email": "bschussek@gmail.com"
+ }
+ ],
+ "description": "Assertions to validate method input/output with nice error messages.",
+ "keywords": [
+ "assert",
+ "check",
+ "validate"
+ ],
+ "support": {
+ "issues": "https://github.com/webmozarts/assert/issues",
+ "source": "https://github.com/webmozarts/assert/tree/1.12.1"
+ },
+ "time": "2025-10-29T15:56:20+00:00"
+ },
+ {
+ "name": "wizardloop/broadcastmanager",
+ "version": "3.2.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/WizardLoop/BroadcastManager.git",
+ "reference": "f2792a383416b66bb68e55f09414563e540538d6"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/WizardLoop/BroadcastManager/zipball/f2792a383416b66bb68e55f09414563e540538d6",
+ "reference": "f2792a383416b66bb68e55f09414563e540538d6",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/amp": "^3.0",
+ "danog/madelineproto": "^8.4",
+ "php": "^8.2"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "BroadcastTool\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "AGPL-3.0-only"
+ ],
+ "authors": [
+ {
+ "name": "WizardLoop",
+ "homepage": "https://wizardloop.com"
+ }
+ ],
+ "description": "High-performance Telegram broadcast manager for MadelineProto",
+ "homepage": "https://github.com/WizardLoop/BroadcastManager",
+ "keywords": [
+ "Broadcast",
+ "bot",
+ "madelineproto",
+ "php",
+ "telegram"
+ ],
+ "support": {
+ "docs": "https://github.com/WizardLoop/BroadcastManager#readme",
+ "issues": "https://github.com/WizardLoop/BroadcastManager/issues",
+ "source": "https://github.com/WizardLoop/BroadcastManager"
+ },
+ "funding": [
+ {
+ "url": "https://wizardloop.t.me/",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/WizardLoop",
+ "type": "github"
+ }
+ ],
+ "time": "2026-06-15T14:16:10+00:00"
+ }
+ ],
+ "packages-dev": [
+ {
+ "name": "myclabs/deep-copy",
+ "version": "1.14.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/myclabs/DeepCopy.git",
+ "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae",
+ "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^8.0"
+ },
+ "conflict": {
+ "doctrine/collections": "<1.6.8",
+ "doctrine/common": "<2.13.3 || >=3 <3.2.2"
+ },
+ "require-dev": {
+ "doctrine/collections": "^1.6.8",
+ "doctrine/common": "^2.13.3 || ^3.2.2",
+ "phpspec/prophecy": "^1.10",
+ "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/DeepCopy/deep_copy.php"
+ ],
+ "psr-4": {
+ "DeepCopy\\": "src/DeepCopy/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "Create deep copies (clones) of your objects",
+ "keywords": [
+ "clone",
+ "copy",
+ "duplicate",
+ "object",
+ "object graph"
+ ],
+ "support": {
+ "issues": "https://github.com/myclabs/DeepCopy/issues",
+ "source": "https://github.com/myclabs/DeepCopy/tree/1.14.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/mnapoli",
+ "type": "github"
+ }
+ ],
+ "time": "2026-08-11T10:17:44+00:00"
+ },
+ {
+ "name": "phar-io/manifest",
+ "version": "2.0.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phar-io/manifest.git",
+ "reference": "54750ef60c58e43759730615a392c31c80e23176"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176",
+ "reference": "54750ef60c58e43759730615a392c31c80e23176",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-libxml": "*",
+ "ext-phar": "*",
+ "ext-xmlwriter": "*",
+ "phar-io/version": "^3.0.1",
+ "php": "^7.2 || ^8.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0.x-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Arne Blankerts",
+ "email": "arne@blankerts.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Heuer",
+ "email": "sebastian@phpeople.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "Developer"
+ }
+ ],
+ "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)",
+ "support": {
+ "issues": "https://github.com/phar-io/manifest/issues",
+ "source": "https://github.com/phar-io/manifest/tree/2.0.4"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/theseer",
+ "type": "github"
+ }
+ ],
+ "time": "2024-03-03T12:33:53+00:00"
+ },
+ {
+ "name": "phar-io/version",
+ "version": "3.2.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phar-io/version.git",
+ "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74",
+ "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2 || ^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Arne Blankerts",
+ "email": "arne@blankerts.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Heuer",
+ "email": "sebastian@phpeople.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "Developer"
+ }
+ ],
+ "description": "Library for handling version information and constraints",
+ "support": {
+ "issues": "https://github.com/phar-io/version/issues",
+ "source": "https://github.com/phar-io/version/tree/3.2.1"
+ },
+ "time": "2022-02-21T01:04:05+00:00"
+ },
+ {
+ "name": "phpstan/phpstan",
+ "version": "2.3.1",
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phpstan/phpstan/zipball/6155239b488e95d26d1ebd95c6b4bb294d7f09db",
+ "reference": "6155239b488e95d26d1ebd95c6b4bb294d7f09db",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.4|^8.0"
+ },
+ "conflict": {
+ "phpstan/phpstan-shim": "*"
+ },
+ "bin": [
+ "phpstan",
+ "phpstan.phar"
+ ],
+ "type": "library",
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Ondลej Mirtes"
+ },
+ {
+ "name": "Markus Staab"
+ },
+ {
+ "name": "Vincent Langlet"
+ }
+ ],
+ "description": "PHPStan - PHP Static Analysis Tool",
+ "keywords": [
+ "dev",
+ "static analysis"
+ ],
+ "support": {
+ "docs": "https://phpstan.org/user-guide/getting-started",
+ "forum": "https://github.com/phpstan/phpstan/discussions",
+ "issues": "https://github.com/phpstan/phpstan/issues",
+ "security": "https://github.com/phpstan/phpstan/security/policy",
+ "source": "https://github.com/phpstan/phpstan-src"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/ondrejmirtes",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/phpstan",
+ "type": "github"
+ }
+ ],
+ "time": "2026-10-08T21:09:11+00:00"
+ },
+ {
+ "name": "phpunit/php-code-coverage",
+ "version": "12.5.8",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-code-coverage.git",
+ "reference": "7440368d94738d54f2c1de24dbd3871793bbb0dd"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/7440368d94738d54f2c1de24dbd3871793bbb0dd",
+ "reference": "7440368d94738d54f2c1de24dbd3871793bbb0dd",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-libxml": "*",
+ "ext-xmlwriter": "*",
+ "nikic/php-parser": "^5.9.0",
+ "php": ">=8.3",
+ "phpunit/php-text-template": "^5.0.1",
+ "sebastian/complexity": "^5.0",
+ "sebastian/environment": "^8.1.2",
+ "sebastian/lines-of-code": "^4.0.1",
+ "sebastian/version": "^6.0",
+ "theseer/tokenizer": "^2.0.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^12.5.37"
+ },
+ "suggest": {
+ "ext-pcov": "PHP extension that provides line coverage",
+ "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "12.5.x-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.",
+ "homepage": "https://github.com/sebastianbergmann/php-code-coverage",
+ "keywords": [
+ "coverage",
+ "testing",
+ "xunit"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues",
+ "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy",
+ "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.8"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-10-03T14:03:50+00:00"
+ },
+ {
+ "name": "phpunit/php-file-iterator",
+ "version": "6.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-file-iterator.git",
+ "reference": "a248d1640ab059b075f53a2ef0f9856e864e06b5"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/a248d1640ab059b075f53a2ef0f9856e864e06b5",
+ "reference": "a248d1640ab059b075f53a2ef0f9856e864e06b5",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^12.5.33"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "6.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "FilterIterator implementation that filters files based on a list of suffixes.",
+ "homepage": "https://github.com/sebastianbergmann/php-file-iterator/",
+ "keywords": [
+ "filesystem",
+ "iterator"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues",
+ "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy",
+ "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-08-25T14:40:53+00:00"
+ },
+ {
+ "name": "phpunit/php-invoker",
+ "version": "6.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-invoker.git",
+ "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/12b54e689b07a25a9b41e57736dfab6ec9ae5406",
+ "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.3"
+ },
+ "require-dev": {
+ "ext-pcntl": "*",
+ "phpunit/phpunit": "^12.0"
+ },
+ "suggest": {
+ "ext-pcntl": "*"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "6.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Invoke callables with a timeout",
+ "homepage": "https://github.com/sebastianbergmann/php-invoker/",
+ "keywords": [
+ "process"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-invoker/issues",
+ "security": "https://github.com/sebastianbergmann/php-invoker/security/policy",
+ "source": "https://github.com/sebastianbergmann/php-invoker/tree/6.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2025-02-07T04:58:58+00:00"
+ },
+ {
+ "name": "phpunit/php-text-template",
+ "version": "5.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-text-template.git",
+ "reference": "babab73b7ef9fae406f2eb3ee11323a61caa63d5"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/babab73b7ef9fae406f2eb3ee11323a61caa63d5",
+ "reference": "babab73b7ef9fae406f2eb3ee11323a61caa63d5",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^12.5.37"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "5.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Simple template engine.",
+ "homepage": "https://github.com/sebastianbergmann/php-text-template/",
+ "keywords": [
+ "template"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-text-template/issues",
+ "security": "https://github.com/sebastianbergmann/php-text-template/security/policy",
+ "source": "https://github.com/sebastianbergmann/php-text-template/tree/5.0.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/phpunit/php-text-template",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-10-03T04:51:29+00:00"
+ },
+ {
+ "name": "phpunit/php-timer",
+ "version": "8.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-timer.git",
+ "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc",
+ "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^12.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "8.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Utility class for timing",
+ "homepage": "https://github.com/sebastianbergmann/php-timer/",
+ "keywords": [
+ "timer"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-timer/issues",
+ "security": "https://github.com/sebastianbergmann/php-timer/security/policy",
+ "source": "https://github.com/sebastianbergmann/php-timer/tree/8.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2025-02-07T04:59:38+00:00"
+ },
+ {
+ "name": "phpunit/phpunit",
+ "version": "12.5.39",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/phpunit.git",
+ "reference": "4e8cbead3741cc57f16d142f4c5dc73c2747820a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/4e8cbead3741cc57f16d142f4c5dc73c2747820a",
+ "reference": "4e8cbead3741cc57f16d142f4c5dc73c2747820a",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-filter": "*",
+ "ext-json": "*",
+ "ext-libxml": "*",
+ "ext-mbstring": "*",
+ "ext-xmlwriter": "*",
+ "myclabs/deep-copy": "^1.14.0",
+ "phar-io/manifest": "^2.0.4",
+ "phar-io/version": "^3.2.1",
+ "php": ">=8.3",
+ "phpunit/php-code-coverage": "^12.5.8",
+ "phpunit/php-file-iterator": "^6.0.2",
+ "phpunit/php-invoker": "^6.0.0",
+ "phpunit/php-text-template": "^5.0.1",
+ "phpunit/php-timer": "^8.0.0",
+ "sebastian/cli-parser": "^4.2.1",
+ "sebastian/comparator": "^7.1.8",
+ "sebastian/diff": "^7.0.1",
+ "sebastian/environment": "^8.1.2",
+ "sebastian/exporter": "^7.0.3",
+ "sebastian/global-state": "^8.0.3",
+ "sebastian/object-enumerator": "^7.0.0",
+ "sebastian/recursion-context": "^7.0.1",
+ "sebastian/type": "^6.0.4",
+ "sebastian/version": "^6.0.1",
+ "staabm/side-effects-detector": "^1.0.5"
+ },
+ "bin": [
+ "phpunit"
+ ],
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "12.5-dev"
+ }
+ },
+ "autoload": {
+ "files": [
+ "src/Framework/Assert/Functions.php"
+ ],
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "The PHP Unit Testing framework.",
+ "homepage": "https://phpunit.de/",
+ "keywords": [
+ "phpunit",
+ "testing",
+ "xunit"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/phpunit/issues",
+ "security": "https://github.com/sebastianbergmann/phpunit/security/policy",
+ "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.39"
+ },
+ "funding": [
+ {
+ "url": "https://phpunit.de/sponsoring.html",
+ "type": "other"
+ }
+ ],
+ "time": "2026-10-12T15:27:27+00:00"
+ },
+ {
+ "name": "sebastian/cli-parser",
+ "version": "4.2.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/cli-parser.git",
+ "reference": "7d05781b13f7dec9043a629a21d086ed74582a15"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/7d05781b13f7dec9043a629a21d086ed74582a15",
+ "reference": "7d05781b13f7dec9043a629a21d086ed74582a15",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^12.5.25"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "4.2-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library for parsing CLI options",
+ "homepage": "https://github.com/sebastianbergmann/cli-parser",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/cli-parser/issues",
+ "security": "https://github.com/sebastianbergmann/cli-parser/security/policy",
+ "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-05-17T05:29:34+00:00"
+ },
+ {
+ "name": "sebastian/comparator",
+ "version": "7.1.8",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/comparator.git",
+ "reference": "7c65c1e79836812819705b473a90c12399542485"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/7c65c1e79836812819705b473a90c12399542485",
+ "reference": "7c65c1e79836812819705b473a90c12399542485",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-mbstring": "*",
+ "php": ">=8.3",
+ "sebastian/diff": "^7.0",
+ "sebastian/exporter": "^7.0.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^12.5.25"
+ },
+ "suggest": {
+ "ext-bcmath": "For comparing BcMath\\Number objects"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "7.1-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Jeff Welch",
+ "email": "whatthejeff@gmail.com"
+ },
+ {
+ "name": "Volker Dusch",
+ "email": "github@wallbash.com"
+ },
+ {
+ "name": "Bernhard Schussek",
+ "email": "bschussek@2bepublished.at"
+ }
+ ],
+ "description": "Provides the functionality to compare PHP values for equality",
+ "homepage": "https://github.com/sebastianbergmann/comparator",
+ "keywords": [
+ "comparator",
+ "compare",
+ "equality"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/comparator/issues",
+ "security": "https://github.com/sebastianbergmann/comparator/security/policy",
+ "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.8"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-05-21T04:45:25+00:00"
+ },
+ {
+ "name": "sebastian/complexity",
+ "version": "5.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/complexity.git",
+ "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/bad4316aba5303d0221f43f8cee37eb58d384bbb",
+ "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb",
+ "shasum": ""
+ },
+ "require": {
+ "nikic/php-parser": "^5.0",
+ "php": ">=8.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^12.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "5.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library for calculating the complexity of PHP code units",
+ "homepage": "https://github.com/sebastianbergmann/complexity",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/complexity/issues",
+ "security": "https://github.com/sebastianbergmann/complexity/security/policy",
+ "source": "https://github.com/sebastianbergmann/complexity/tree/5.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2025-02-07T04:55:25+00:00"
+ },
+ {
+ "name": "sebastian/diff",
+ "version": "7.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/diff.git",
+ "reference": "cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6",
+ "reference": "cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^12.5.33",
+ "symfony/process": "^7.4.17"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "7.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Kore Nordmann",
+ "email": "mail@kore-nordmann.de"
+ }
+ ],
+ "description": "Diff implementation",
+ "homepage": "https://github.com/sebastianbergmann/diff",
+ "keywords": [
+ "diff",
+ "udiff",
+ "unidiff",
+ "unified diff"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/diff/issues",
+ "security": "https://github.com/sebastianbergmann/diff/security/policy",
+ "source": "https://github.com/sebastianbergmann/diff/tree/7.0.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/diff",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-08-25T15:35:54+00:00"
+ },
+ {
+ "name": "sebastian/environment",
+ "version": "8.1.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/environment.git",
+ "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/9d32c685773823b1983e256ae4ecd48a10d6e439",
+ "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^12.5.26"
+ },
+ "suggest": {
+ "ext-posix": "*"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "8.1-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Provides functionality to handle HHVM/PHP environments",
+ "homepage": "https://github.com/sebastianbergmann/environment",
+ "keywords": [
+ "Xdebug",
+ "environment",
+ "hhvm"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/environment/issues",
+ "security": "https://github.com/sebastianbergmann/environment/security/policy",
+ "source": "https://github.com/sebastianbergmann/environment/tree/8.1.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/environment",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-05-25T13:40:20+00:00"
+ },
+ {
+ "name": "sebastian/exporter",
+ "version": "7.0.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/exporter.git",
+ "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23",
+ "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23",
+ "shasum": ""
+ },
+ "require": {
+ "ext-mbstring": "*",
+ "php": ">=8.3",
+ "sebastian/recursion-context": "^7.0.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^12.5.25"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "7.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Jeff Welch",
+ "email": "whatthejeff@gmail.com"
+ },
+ {
+ "name": "Volker Dusch",
+ "email": "github@wallbash.com"
+ },
+ {
+ "name": "Adam Harvey",
+ "email": "aharvey@php.net"
+ },
+ {
+ "name": "Bernhard Schussek",
+ "email": "bschussek@gmail.com"
+ }
+ ],
+ "description": "Provides the functionality to export PHP variables for visualization",
+ "homepage": "https://www.github.com/sebastianbergmann/exporter",
+ "keywords": [
+ "export",
+ "exporter"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/exporter/issues",
+ "security": "https://github.com/sebastianbergmann/exporter/security/policy",
+ "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.3"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-05-20T04:37:17+00:00"
+ },
+ {
+ "name": "sebastian/global-state",
+ "version": "8.0.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/global-state.git",
+ "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b164d3274d6537ab462591c5755f76a8f5b1aae9",
+ "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.3",
+ "sebastian/object-reflector": "^5.0",
+ "sebastian/recursion-context": "^7.0.1"
+ },
+ "require-dev": {
+ "ext-dom": "*",
+ "phpunit/phpunit": "^12.5.28"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "8.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Snapshotting of global state",
+ "homepage": "https://www.github.com/sebastianbergmann/global-state",
+ "keywords": [
+ "global state"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/global-state/issues",
+ "security": "https://github.com/sebastianbergmann/global-state/security/policy",
+ "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.3"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-06-01T15:10:33+00:00"
+ },
+ {
+ "name": "sebastian/lines-of-code",
+ "version": "4.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/lines-of-code.git",
+ "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d543b8ef219dcd8da262cbb958639a96bedba10e",
+ "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e",
+ "shasum": ""
+ },
+ "require": {
+ "nikic/php-parser": "^5.7.0",
+ "php": ">=8.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^12.5.25"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "4.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library for counting the lines of code in PHP source code",
+ "homepage": "https://github.com/sebastianbergmann/lines-of-code",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/lines-of-code/issues",
+ "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy",
+ "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-05-19T16:22:07+00:00"
+ },
+ {
+ "name": "sebastian/object-enumerator",
+ "version": "7.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/object-enumerator.git",
+ "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/1effe8e9b8e068e9ae228e542d5d11b5d16db894",
+ "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.3",
+ "sebastian/object-reflector": "^5.0",
+ "sebastian/recursion-context": "^7.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^12.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "7.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Traverses array structures and object graphs to enumerate all referenced objects",
+ "homepage": "https://github.com/sebastianbergmann/object-enumerator/",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/object-enumerator/issues",
+ "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy",
+ "source": "https://github.com/sebastianbergmann/object-enumerator/tree/7.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2025-02-07T04:57:48+00:00"
+ },
+ {
+ "name": "sebastian/object-reflector",
+ "version": "5.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/object-reflector.git",
+ "reference": "4bfa827c969c98be1e527abd576533293c634f6a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/4bfa827c969c98be1e527abd576533293c634f6a",
+ "reference": "4bfa827c969c98be1e527abd576533293c634f6a",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^12.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "5.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Allows reflection of object attributes, including inherited and non-public ones",
+ "homepage": "https://github.com/sebastianbergmann/object-reflector/",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/object-reflector/issues",
+ "security": "https://github.com/sebastianbergmann/object-reflector/security/policy",
+ "source": "https://github.com/sebastianbergmann/object-reflector/tree/5.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2025-02-07T04:58:17+00:00"
+ },
+ {
+ "name": "sebastian/recursion-context",
+ "version": "7.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/recursion-context.git",
+ "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/0b01998a7d5b1f122911a66bebcb8d46f0c82d8c",
+ "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^12.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "7.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Jeff Welch",
+ "email": "whatthejeff@gmail.com"
+ },
+ {
+ "name": "Adam Harvey",
+ "email": "aharvey@php.net"
+ }
+ ],
+ "description": "Provides functionality to recursively process PHP variables",
+ "homepage": "https://github.com/sebastianbergmann/recursion-context",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/recursion-context/issues",
+ "security": "https://github.com/sebastianbergmann/recursion-context/security/policy",
+ "source": "https://github.com/sebastianbergmann/recursion-context/tree/7.0.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-08-13T04:44:59+00:00"
+ },
+ {
+ "name": "sebastian/type",
+ "version": "6.0.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/type.git",
+ "reference": "82ff822c2edc46724be9f7411d3163021f602773"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/82ff822c2edc46724be9f7411d3163021f602773",
+ "reference": "82ff822c2edc46724be9f7411d3163021f602773",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^12.5.25"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "6.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Collection of value objects that represent the types of the PHP type system",
+ "homepage": "https://github.com/sebastianbergmann/type",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/type/issues",
+ "security": "https://github.com/sebastianbergmann/type/security/policy",
+ "source": "https://github.com/sebastianbergmann/type/tree/6.0.4"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/type",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-05-20T06:45:45+00:00"
+ },
+ {
+ "name": "sebastian/version",
+ "version": "6.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/version.git",
+ "reference": "9e935a16187e14f39b0e79910dabd614972cdb26"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/9e935a16187e14f39b0e79910dabd614972cdb26",
+ "reference": "9e935a16187e14f39b0e79910dabd614972cdb26",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "6.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library that helps with managing the version number of Git-hosted PHP projects",
+ "homepage": "https://github.com/sebastianbergmann/version",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/version/issues",
+ "security": "https://github.com/sebastianbergmann/version/security/policy",
+ "source": "https://github.com/sebastianbergmann/version/tree/6.0.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/version",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-10-04T05:50:08+00:00"
+ },
+ {
+ "name": "squizlabs/php_codesniffer",
+ "version": "4.0.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git",
+ "reference": "bbdc3d0532623e21838b7041a4364383a8126f96"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/bbdc3d0532623e21838b7041a4364383a8126f96",
+ "reference": "bbdc3d0532623e21838b7041a4364383a8126f96",
+ "shasum": ""
+ },
+ "require": {
+ "ext-libxml": "*",
+ "ext-simplexml": "*",
+ "ext-tokenizer": "*",
+ "ext-xmlwriter": "*",
+ "php": ">=7.2.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^8.4.0 || ^9.3.4 || ^10.5.32 || 11.3.3 - 11.5.28 || ^11.5.31"
+ },
+ "suggest": {
+ "ext-iconv": "For accurate character length calculation when the checked files contain multi-byte characters.",
+ "ext-pcntl": "For parallel processing support via the --parallel CLI option."
+ },
+ "bin": [
+ "bin/phpcbf",
+ "bin/phpcs"
+ ],
+ "type": "library",
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Greg Sherwood",
+ "role": "Former lead"
+ },
+ {
+ "name": "Juliette Reinders Folmer",
+ "role": "Current lead"
+ },
+ {
+ "name": "Contributors",
+ "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors"
+ }
+ ],
+ "description": "PHP_CodeSniffer tokenizes PHP files and detects violations of a defined set of coding standards.",
+ "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer",
+ "keywords": [
+ "phpcs",
+ "standards",
+ "static analysis"
+ ],
+ "support": {
+ "issues": "https://github.com/PHPCSStandards/PHP_CodeSniffer/issues",
+ "security": "https://github.com/PHPCSStandards/PHP_CodeSniffer/security/policy",
+ "source": "https://github.com/PHPCSStandards/PHP_CodeSniffer",
+ "wiki": "https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/PHPCSStandards",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/jrfnl",
+ "type": "github"
+ },
+ {
+ "url": "https://opencollective.com/php_codesniffer",
+ "type": "open_collective"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/phpcsstandards",
+ "type": "thanks_dev"
+ }
+ ],
+ "time": "2026-08-06T02:45:27+00:00"
+ },
+ {
+ "name": "staabm/side-effects-detector",
+ "version": "1.0.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/staabm/side-effects-detector.git",
+ "reference": "d8334211a140ce329c13726d4a715adbddd0a163"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163",
+ "reference": "d8334211a140ce329c13726d4a715adbddd0a163",
+ "shasum": ""
+ },
+ "require": {
+ "ext-tokenizer": "*",
+ "php": "^7.4 || ^8.0"
+ },
+ "require-dev": {
+ "phpstan/extension-installer": "^1.4.3",
+ "phpstan/phpstan": "^1.12.6",
+ "phpunit/phpunit": "^9.6.21",
+ "symfony/var-dumper": "^5.4.43",
+ "tomasvotruba/type-coverage": "1.0.0",
+ "tomasvotruba/unused-public": "1.0.0"
+ },
+ "type": "library",
+ "autoload": {
+ "classmap": [
+ "lib/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "A static analysis tool to detect side effects in PHP code",
+ "keywords": [
+ "static analysis"
+ ],
+ "support": {
+ "issues": "https://github.com/staabm/side-effects-detector/issues",
+ "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/staabm",
+ "type": "github"
+ }
+ ],
+ "time": "2024-10-20T05:08:20+00:00"
+ },
+ {
+ "name": "theseer/tokenizer",
+ "version": "2.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/theseer/tokenizer.git",
+ "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4",
+ "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-tokenizer": "*",
+ "ext-xmlwriter": "*",
+ "php": "^8.1"
+ },
+ "type": "library",
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Arne Blankerts",
+ "email": "arne@blankerts.de",
+ "role": "Developer"
+ }
+ ],
+ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats",
+ "support": {
+ "issues": "https://github.com/theseer/tokenizer/issues",
+ "source": "https://github.com/theseer/tokenizer/tree/2.0.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/theseer",
+ "type": "github"
+ }
+ ],
+ "time": "2025-12-08T11:19:18+00:00"
+ }
+ ],
+ "aliases": [],
+ "minimum-stability": "stable",
+ "stability-flags": {},
+ "prefer-stable": false,
+ "prefer-lowest": false,
+ "platform": {
+ "php": "~8.3.0 || ~8.4.0 || ~8.5.0",
+ "ext-pdo": "*",
+ "ext-pdo_sqlite": "*",
+ "ext-mbstring": "*"
+ },
+ "platform-dev": {},
+ "plugin-api-version": "2.6.0"
+}
diff --git a/docker-compose.yml b/docker-compose.yml
index e9855b4..ae4cd60 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,10 +1,25 @@
services:
shabbat:
- build: .
- restart: always
+ build:
+ context: .
+ args:
+ BUILD_COMMIT: ${BUILD_COMMIT:-}
+ restart: unless-stopped
init: true
- tty: true
- working_dir: /app
+ env_file: .env
+ environment:
+ DATA_DIR: /app/data
+ SESSION_PATH: /app/runtime/bot.shabbat
volumes:
- - ./:/app
- command: php /app/bot.php
+ - bot-data:/app/data
+ - madeline-session:/app/runtime
+ stop_grace_period: 60s
+ healthcheck:
+ test: ["CMD", "php", "bin/health.php"]
+ interval: 60s
+ timeout: 10s
+ retries: 3
+ start_period: 90s
+volumes:
+ bot-data:
+ madeline-session:
\ No newline at end of file
diff --git a/docs/ADMIN_PANEL.md b/docs/ADMIN_PANEL.md
new file mode 100644
index 0000000..1b14f2a
--- /dev/null
+++ b/docs/ADMIN_PANEL.md
@@ -0,0 +1,81 @@
+# Bot administration
+
+The private `/admin` panel includes statistics, subscriber browsing, broadcasts, managers and permissions, system links and tracking links. Capabilities are checked again when an action runs. Telegram group administrators only receive group management rights and never become bot managers.
+
+## Managers and permissions
+
+Configured `ADMIN` user IDs and usernames resolved by MadelineProto remain immutable recovery administrators. Unresolved names do not grant access. The SDK report-peer user list is kept separate from removable SQLite managers. The orderly application `/restart` handler remains available only to these configured recovery users; dynamic full managers and the system-maintenance capability do not grant SDK restart access. Command menus and the panel show `/restart` only to configured recovery users. View only panels hide the mutating `/setcommands`, `/closegroups` and `/opengroups` commands. Dynamic managers are stored in SQLite by migration `009_admin_roles_links.sql`; they are not added to the server configuration. Existing legacy manager IDs can be imported once as full managers, and removing an imported manager remains effective after restart.
+
+Full managers have all bot administration capabilities. Limited managers receive selected capabilities and may be marked view only. The capabilities are statistics, subscribers, broadcasts, command menu setup, system links, tracking links, adding managers, changing/removing managers, manual group operations and system maintenance. View only managers can inspect permitted information, but cannot send broadcasts, change permissions or links, perform manual lock operations or publish command menus.
+
+From **Managers and permissions**, choose **Add manager**, send a numeric Telegram user ID or forward a message with a visible user ID, select the full/limited role, view only mode and individual permissions, then save. Telegram privacy can hide the original sender of a forwarded message; use the numeric ID in that case. Manager profiles show the complete permission set. Editing or removing a manager requires the corresponding capability. A limited manager cannot grant permissions they lack or manage a manager with wider authority. Nobody can change their own access or edit/remove a configured recovery administrator through the panel.
+
+Editors are actor bound, expire after ten minutes and are replaced when another management editor opens. Save/removal checks the current actor permissions and target revision inside an immediate SQLite transaction. A changed target must be reopened. Manager changes and the audit entry commit together.
+
+## System links
+
+**System links** lists the supported fixed `?start=` routes in pages with native copy buttons. Public links cover the home screen, preferences, language, groups, Shabbat/holidays, daily zmanim, Daf Yomi, Omer, information, support, source, license and version. Private operator routes cover the panel, statistics, subscribers, broadcasts, managers, system links, tracking links and command setup.
+
+Links navigate to a screen; they do not grant a role or immediately send a broadcast, change a group or publish command menus. Every privileged route checks the current bot capability. Group-context and location-selection links retain their original checks.
+
+## Tracking links
+
+**Tracking links** supports creation, pagination, details, copy, refresh, resetting counters and deleting a link. Creation first previews the URL and lets the manager select a public destination menu before explicit save. Supply an unused 1-64 character start parameter using ASCII letters, digits, hyphens or underscores; the first and last character must be alphanumeric. An optional internal label follows a space. Fixed routes and internal selection prefixes are reserved.
+
+Only valid private Telegram `/start parameter` messages count. A URL click that never starts the bot cannot be observed. Counts include total activations, unique users, repeat activations, creation/first/last timestamps and daily activity for the last seven UTC days. Per-link counters distinguish campaign/source URLs. Repeated delivery of the same or older Telegram message ID does not increase counters.
+
+Visitor identifiers in tracking tables are installation-keyed HMAC-SHA256 values; raw visitor IDs and individual click histories are not stored there. The private key remains in the application database and is never displayed in the panel. The ordinary bot settings/subscriber database still contains the IDs it needs to operate. Daily aggregate rows older than 90 days are pruned on the next activation of that link; lifetime total/unique counters remain until reset or deletion. Exact unique tracking grows with campaign visitors. A maximum of 10,000 managed links is enforced. Reset and delete require an explicit actor-bound, expiring confirmation and matching record revision. Deleting a link cascades its hashes and daily aggregates.
+
+Tracking is auxiliary. If tracking storage is unavailable, the runtime should log only the error class and still open the ordinary user home screen. It must not expose raw database errors, tokens or visitor IDs.
+
+## Installation and validation
+
+Stop application writers, back up the database using the documented deployment maintenance workflow, then run migrations before starting this version. Previously applied migrations 001-008 are unchanged. Do not copy a live SQLite file, remove volumes or reset settings.
+
+Tests cover immutable recovery owners, one-time legacy imports, full/limited/view only behavior, delegation restrictions, concurrent revisions, revoked roles, transaction rollback, private context, expiring/replaced/replayed confirmations, reserved links, tracking destinations, redelivered starts, unique/repeat counters, visitor-ID hashing, reset/delete and all six admin languages.
+
+The local reference mapping used `Desktop/newsearch-v2/src/AdminAccessStore.php`, `src/DeepLinkTrackerStore.php` and its manager/system-link/tracker handlers in `bot.php`. The Shabbat implementation uses the existing transactional SQLite database and native MadelineProto markup; unrelated search/payment features are not introduced.
+
+
+## Scheduled broadcasts
+
+Native scheduled broadcasts are attributed to the creating bot manager by an audit record. Before a scheduler tick runs the native queue, the current creator must still hold broadcast mutation permission. Unattributed, revoked or view only due jobs are cancelled; the native queue is re-read and no queue runner executes when cancellation is unconfirmed. An unattributed future job blocks the bulk runner until it is attributed or cancelled, preventing it from becoming due inside a yielded bulk run. Native file persistence and status transitions remain with the pinned BroadcastManager.
+
+Permission work has priority over scheduled sends and destruction work. Shutdown and reentrant ticks cannot start a second native queue run. Intentional Amp cancellation propagates. The guard rechecks at scheduler admission; an already running native broadcast keeps the library cancellation mechanism. The current guard does not claim to recall messages already sent or provide atomic remote revocation across Telegram requests.
+
+
+## Subscriber directory and statistics
+
+The directory combines the existing known-peer registry, registered groups and known user preferences. It supports **All**, **Users**, **Groups**, **Channels** and **Bots**, name/username/ID search, clear search, four sorts (activity, joining, name, ID), previous/next navigation and direct page entry. Search, filter, sort, page and the pending question belong to the viewing operator. Changing another operator's list never changes this view.
+
+Selecting an entry opens the known bot profile: identifier/type/name, optional Telegram username and account metadata, first/last observed activity, observed actions/starts, personal language/location/timezone, registered group state and observed group roles. Missing Telegram metadata and unverified administrator status remain unknown. Group-role observations have their own complete pagination; they are never used to authorize a setting change. Advanced details page every stored profile/preference/group-setting field and provide separate pages for long values, keeping output within Telegram limits.
+
+A user record created during group authorization is not sufficient for a new private broadcast. An actual incoming private update records private-chat eligibility. Migration 010 preserves the pre-upgrade private user registry for compatibility; newly created group-only identities remain ineligible until a private interaction. Unknown peer types are not inferred from signed identifiers. Registered groups retain a generic group category until Telegram identifies the exact subtype.
+
+The statistics panel separates users, bots, basic groups, supergroups, generic registered groups, channels and unknown metadata, plus known total, activity in 24 hours/7 days/30 days, observed starts/actions, group registration/activation/protection/closure/recovery, and pending/failed application jobs. Activity begins with the new observations; missing old history is not reconstructed.
+
+## Broadcast editor and audiences
+
+The general broadcast controls inspected in Desktop/newsearch-v2 are implemented against the existing pinned BroadcastManager backend. The subscriber browsing reference was Desktop/psy new/release-r21-work/src/AdminInsightsTrait.php. This is functional adaptation to the Shabbat data model; unrelated search/marketplace functions are not included and visual identity is not claimed to be a byte-for-byte copy.
+
+Each operator's draft preserves text, UTF-16 entities, reusable media, validated buttons/styles, pin choice, operation, audience, self-destruct hours and schedule. The editor offers new send, editing the last recorded broadcast, private preview, recipient-count confirmation, last data/progress, pause/resume/cancel, delete last/all and unpin. Sending, editing, deletion and job cancellation recheck current mutation capability.
+
+The eight audiences are users, groups, channels, all known eligible peers, groups plus channels, authorized channels, authorized groups, and authorized groups plus channels. Authorized chat audiences start denied and require an explicit manager-controlled flag on a known chat. The profile's authorization button opens a one-use confirmation; the flag, audit and flow consumption commit together. Default group-member rights and Telegram administrator powers are independent of this broadcast audience flag.
+
+Automatic deletion supports off, 1/6/12/24/48-hour presets or another whole number from 1 through 48. Scheduling supports immediate send, +10/+30/+60/+360/+1440 minutes and a custom number of minutes or strict local date. Custom dates use the operator's selected IANA timezone in YYYY-MM-DD HH:MM, DD/MM/YYYY HH:MM or DD.MM.YYYY HH:MM form. Impossible or ambiguous local DST times, past times and times beyond one year are rejected. An expired scheduled choice cannot silently become an immediate send.
+
+Scheduling and automatic deletion apply to new sends. Selecting edit-last clears those new-send options. The confirmation snapshot binds operation, payload, settings, exact recipient snapshot and current personal preference revision. Changes, expiry, replay and lost role invalidate it. Drafts and other input modes remain operator-local; opening another menu cancels its competing question while retaining saved draft content.
+
+The jobs view pages native scheduled sends and self-destruct work, with guarded cancellation. The current creator's capability is checked at scheduled admission as described above. Preview and configuration never broadcast. Native history and queue persistence remain in the documented runtime data directory and must be backed up together with the application database.
+
+## dev.5 navigation and runtime
+
+Broadcast drafts show their content, recipient audience, pin, scheduled time, automatic deletion and button count before final confirmation. The link-button editor asks for a label and then a HTTPS/Telegram URL; it appends two per row. Advanced keyboard input remains optional. Editing content preserves the current keyboard; removing it is explicit. Navigation cancels the short input flow while keeping the saved draft.
+
+Public /stats provides only aggregate group/reminder information. Administrative statistics use audience, activity, group and delivery sections, each with refresh and return controls. A statistics-only manager can read those sections without access to subscriber identities.
+
+The auxiliary MadelineProto broadcast client is shared per event handler. Owner-only /restart has a bounded acknowledgement, stops application work and completes the pinned SDK loop. Docker's restart policy starts the process again. The integration also waits for the pinned SDK's pending API finalizers while the application writer lock remains held; this uses an internal public method of exact version8.7.0 and must be reviewed before any dependency upgrade. It is covered offline and still requires the separate test bot's server acceptance.
+
+## Paged calendar diagnostics
+
+`/testzmanim` is private and requires the current statistics capability. Its compact panel supports next/previous, a clickable page counter for numeric entry, search, category/problem filters and an explicit JSON document export. Permission details are fetched only for the selected registered group; navigation uses the displayed cached snapshot. Exports are bounded, privately authorized and guarded against duplicate callbacks. See [diagnostics](DIAGNOSTICS.md).
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
new file mode 100644
index 0000000..fc1add6
--- /dev/null
+++ b/docs/ARCHITECTURE.md
@@ -0,0 +1,15 @@
+Current dev.9 runtime and migration changes: [delivery safety, independent scheduling, queue retention and verification limits](DEV9_DELIVERY_SAFETY.md). Historical release-specific numbers below apply to their named versions.
+
+# Architecture
+
+The existing `Shabbat` MadelineProto event handler and project identity remain. `Runtime/BotIntegration` connects new commands to services; existing Stars and BroadcastManager capabilities are preserved during integration. All new Telegram effects use the MTProto gateway. Unit tests substitute a fake gateway and fixed UTC clock.
+
+`Calendar/HebcalProvider` obtains explicitly bounded civil-date calendar and batch solar data independently. Calendar Israel/diaspora selection is not inferred from coordinates. `CalendarEngine` merges adjacent prohibited days, computes exact candle/nightfall methods, and separates operational lock/open offsets. `CalendarService` supplies one snapshot to display and planning.
+
+`Storage/Database` uses foreign keys, WAL and FULL synchronous persistence. Typed group and personal settings are independent and revisioned. Selection flows persist scoped tokens. `LockCoordinator` persists and checksums permission snapshots before RPC, verifies readback, keeps original permissions through restarts, and reports conflicts rather than enabling all permissions.
+
+`ReminderPlanner` generates UTC jobs using IANA local times. Missing DST times are skipped; repeated times select the first occurrence once. Outbox jobs have unique logical event keys, stable random IDs, leases, revisions and expiry. Settings changes invalidate routine future jobs while preserving permission restoration debt. Telegram uncertain-response retries cannot promise absolute exactly-once delivery.
+
+The SQLite database stores application data; MadelineProto session storage remains independent. Maintenance and runtime acquire the same instance-writer lock. Migration does not run destructively at startup. Only previously absent registrations from the configured legacy list are imported automatically.
+
+Provider HTTP is asynchronous with HTTPS, body limits and timeouts. Shared and persisted raw caches prevent identical refreshes across groups. Calendar consumers validate actual date coverage. Provider failure is visible and leaves permissions unchanged; it is never interpreted as no event.
diff --git a/docs/BACKUP_RESTORE.md b/docs/BACKUP_RESTORE.md
new file mode 100644
index 0000000..5e3cb6f
--- /dev/null
+++ b/docs/BACKUP_RESTORE.md
@@ -0,0 +1,58 @@
+# Backup, restore and rollback
+
+[Installation](INSTALLATION.md) | [Migration](MIGRATION.md) | [Database](DATABASE.md)
+
+Application SQLite, MadelineProto session storage and BroadcastManager runtime files are separate datasets. Protect and back up all of them. A SQLite-only backup does not contain every dependency's state. Backups include private identifiers, preferences, templates, payment records and original group permissions; keep them out of Git and public archives.
+
+## Application database backup
+
+Run from the application root as the unprivileged application owner with access to the database and destination directory:
+
+```sh
+php bin/backup.php --db=/path/to/shabbat.sqlite --directory=/path/to/backups --keep=7
+```
+
+For the running Docker service, execute as its configured application user; replace the project name only if your deployment uses a different existing name:
+
+```sh
+docker compose -p shabbat-test exec -T shabbat php bin/backup.php --db=/app/data/shabbat.sqlite --directory=/app/data/backups --keep=7
+```
+
+The tool creates a unique SQLite copy using `VACUUM INTO`, including committed WAL data, verifies integrity/foreign keys/schema and writes a SHA-256 manifest. It creates the backup directory when needed. `--keep=7` prunes only this tool's strictly named backups in that directory; choose retention deliberately. It can run online, but database work can be substantial, so schedule it as maintenance rather than an event-loop action. Do not copy only the main file of a live WAL database.
+
+SQLite 3.27 introduced `VACUUM INTO`; this bot's actual supported deployment requirements are stricter. Always pass the PHP-loaded SQLite release gate documented in [installation](INSTALLATION.md).
+
+## Restore and isolated rehearsal
+
+Stop every writer first. The restore tool requires the shared application writer lock and explicit operator attestation. It validates the backup and available manifest, checkpoints the stopped destination, verifies a staged copy, retains the previous database as `*.before-restore-*`, then installs the replacement.
+
+Native maintenance example, from the matching application root:
+
+```sh
+php bin/restore.php --backup=/path/to/backups/shabbat-backup-YYYYMMDD-HHMMSS-XXXXXXXX.sqlite --db=/separate/path/restored.sqlite --writers-stopped
+```
+
+Use an actual filename emitted by the backup tool. The destination above is a separate rehearsal path. For restoring the stopped Docker deployment, the verified backup must be available inside the persistent data volume:
+
+```sh
+docker compose -p shabbat-test stop shabbat
+docker compose -p shabbat-test run --rm --no-deps shabbat php bin/restore.php --backup=/app/data/backups/shabbat-backup-YYYYMMDD-HHMMSS-XXXXXXXX.sqlite --db=/app/data/shabbat.sqlite --writers-stopped
+```
+
+These commands modify the destination database. `--writers-stopped` is an operator assertion, not a command that stops other processes. The tool refuses an observed held writer lock, but every external writer must also be stopped.
+
+The restore output includes its validation result. Compare the migration history and sampled group/template records in the isolated rehearsal. `php bin/health.php --db=/separate/path/restored.sqlite --deep` also checks database integrity, but **still requires recent live permission and notification heartbeats**; a non-running restored copy normally exits 1. That alone is not evidence of database corruption. Do not fabricate heartbeats or start a production-connected bot merely to make an offline check green.
+
+After an explicitly authorized startup, run the real instance's health check:
+
+```sh
+docker compose -p shabbat-test exec -T shabbat php bin/health.php --deep
+```
+
+## Session state and rollback
+
+Stop the bot and all library writers before taking a consistent private snapshot of the session and BroadcastManager state. Preserve the named `bot-data` and `madeline-session` volumes associated with your Compose project. Do not remove volumes during upgrade or rollback. Store an off-server private copy through your server's backup mechanism.
+
+Retain matching application source, `composer.lock`, patch set, database, session and library-state snapshots. Reverting only PHP files after schema changes is not a complete rollback. Before starting an older compatible build, stop the new writer, preserve current evidence, resolve outstanding bot-owned permission changes and restore the reviewed matching state. Run only one instance against a bot/session/database.
+
+Restoring files cannot reverse Telegram permission changes or messages already accepted by Telegram. A restored database may predate current remote permissions; fresh readback and visible conflict resolution remain necessary. Older unverified snapshots require administrator review rather than guessed reopening.
diff --git a/docs/CALENDAR_PRESENTATION.md b/docs/CALENDAR_PRESENTATION.md
new file mode 100644
index 0000000..4b5e0cd
--- /dev/null
+++ b/docs/CALENDAR_PRESENTATION.md
@@ -0,0 +1,34 @@
+# Public calendar and help screens
+
+The Shabbat and holiday view is a single current/upcoming overview. It does not expose previous/next buttons or page numbers. Old calendar callbacks remain compatible and refresh the current overview. The private panel is edited in place; share and back actions remain available.
+
+The primary period is the current or nearest upcoming verified period containing Shabbat. It shows its civil and Hebrew dates, Torah portion, Mevarchim and verified observance names, then every candle-lighting time and the final havdalah. Torah/Haftara passages and descriptive provider memos are omitted from the concise card. Candle labels use the actual local candle-lighting date, rather than the following holy day's date (for example, ืืื ืฉืืฉื). Displayed timestamps use `Y-m-d H:i` in the selected IANA timezone, including daylight-saving transitions, without numeric UTC offset suffixes. The IANA timezone remains visible in the header. The existing curated bilingual Jerusalem label is used only when its identity, coordinates, timezone and standard name match; custom names and unrelated places remain unchanged.
+
+Verified calendar and solar coverage extends from eight days before the current month through forty days after its end (76โ79 days in total). The display does not invent or fetch months-ahead festival times outside this horizon. The overview also lists up to five current/upcoming calendar observances from the current local date within verified coverage. A separate weekday Yom Tov before the nearest Shabbat remains in that list with its verified entrance and final exit. A currently active festival is retained, including after sunset on its eve. A continuous period already shown in the main Shabbat section is not duplicated. It does not repeat the next three Shabbat periods. A Shabbat that is part of a continuous upcoming Yom Tov period remains included in that period's final exit. Festival start/end times require Hebcal's strict `yomtov: true` classification and a matching verified calendar period. Hanukkah and other non-Yom-Tov dates do not acquire festival closing times merely because they are listed as holidays. Consecutive days retain all candle lightings and only their final exit; missing verified festival times are explicitly unavailable. An optional future period ending at the coverage boundary is not shown as a final exit, because the following unverified day might continue the festival. Optional metadata is bounded to protect the primary period and Telegram's message limit.
+
+[Hebcal's calendar API](https://www.hebcal.com/home/195/jewish-calendar-rest-api) documents holiday objects with the structured `yomtov` field. The formatter consumes the same CalendarEngine periods used by scheduling; it does not independently calculate or guess religious times.
+
+Commands, private menu refreshes, inline results, group views, scheduled default `times_alert` messages and group announcement/template previews use the same overview. A template that changes only buttons retains the generated body; intentionally customized text and entities remain respected. Configured group close/open offsets are displayed only when they differ from the candle-lighting/final-havdalah times.
+
+The displayed candle and exit methods reflect the saved settings. The requested fixed-50-minute example does not change the default 8.5-degree policy. Captured Jerusalem data for 2026-10-17 gives 18:41 with 8.5 degrees and 18:55 with fixed 50 minutes; the regression suite verifies both choices in every supported language and every default output path.
+
+No source/update footer is displayed on the Shabbat card. Internal coverage, fetched-at metadata, provider validation and stale-data warnings remain intact. Provider attribution and a Hebcal link remain in the calendar information screen in all six languages and in licensing documentation. Existing animated-emoji presentation and ordinary-emoji fallback apply to the concise card.
+
+## Group permissions
+
+Telegram permits bots to update default permissions in both basic groups and supergroups. The bot needs administrator status and permission to restrict members. Default restrictions apply to ordinary members; administrator messages are not prevented by those defaults.
+
+- [MTProto messages.editChatDefaultBannedRights](https://core.telegram.org/method/messages.editChatDefaultBannedRights) documents group, supergroup and channel peers and permits bots.
+- [Bot API setChatPermissions](https://core.telegram.org/bots/api#setchatpermissions) explicitly supports groups and supergroups and requires can_restrict_members.
+
+Actual availability still depends on the peer type, granted rights and the server response. The information panel explains permission requirements, restoring saved permissions, continuous holy-day periods and separately controlled message cleanup.
+
+## Commands and source
+
+Publishing group and group-administrator command menus with `/setcommands` replaces those scopes with only `/settings`, in all six supported languages and the default-language fallback. Existing private commands remain available. The repository's old and current publishers use global group scopes, so replacing them removes the previously published group menu entries. Private-only operator scopes are not removed. Any independently installed external peer-specific menus are outside this repository's known publication history.
+
+The public menu includes one `/source` command. The source card includes the official [WizardLoop/shabbat repository](https://github.com/WizardLoop/shabbat), running version, commit, application license and the operator's matching build source URL. The official repository is explicitly distinguished from a locally modified build's corresponding source. `/version` and `/license` remain compatible aliases of that complete card.
+
+## Offline verification
+
+The regression suite uses captured official Hebcal responses for Jerusalem in October, the Shavuot-to-Shabbat sequence and a separate weekday Pesach before Shabbat. Additional tests cover Yom Tov classification, final-exit preservation, missing verified festival data, bounded metadata, legacy page offsets, six-language help, same-message edits, exact group command scopes and source aliases. These tests do not send Telegram messages or replace a live bot.
diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md
new file mode 100644
index 0000000..7dc7824
--- /dev/null
+++ b/docs/CONFIGURATION.md
@@ -0,0 +1,36 @@
+# Configuration
+
+[English](../README.md) | [ืขืืจืืช](../readme-he.md) | [Installation](INSTALLATION.md)
+
+The bot reads the private project-root `.env` as raw INI; nonempty process environment values override file values. Maintenance tools using `RuntimePaths::resolve()` also read that file. An explicit supported CLI path such as `--db` overrides the resolved database path. Keep the file as plain `KEY=value` lines, without interpolation or mixed comment conventions. The public [example](../.env.example) contains placeholders only.
+
+| Variable | Requirement / default | Purpose and cautions |
+|---|---|---|
+| `API_ID` | Required | Telegram MTProto application ID from `my.telegram.org`. |
+| `API_HASH` | Required | Matching private MTProto application hash. |
+| `BOT_TOKEN` | Required | Token issued by BotFather for this bot. |
+| `ADMIN` | Required for root operator access | Comma-separated trusted Telegram IDs/usernames resolved through the existing report-peer mechanism. Numeric IDs avoid username reassignment ambiguity. Delegated roles are managed separately in the bot. |
+| `DATA_DIR` | Defaults to project `data`; Compose forces `/app/data` | Durable application and library state directory. |
+| `APP_DB_PATH` | Defaults to `DATA_DIR/shabbat.sqlite`; example `data/shabbat.sqlite` | Application SQLite database. Relative paths resolve against the application root. |
+| `SESSION_PATH` | Runtime fallback is project `bot.shabbat`; example `data/session/bot.shabbat`; Compose forces `/app/runtime/bot.shabbat` | Independent MadelineProto session. Protect the entire session state and avoid concurrent writers. |
+| `LEGACY_DATA_PATH` | Example/default `.` | Old project root or data location used by the supported legacy bridge/import paths. Mount external legacy data read-only during rehearsal. |
+| `HEARTBEAT_PATH` | Defaults to `DATA_DIR/scheduler-heartbeat.json`; example `data/scheduler-heartbeat.json` | Shared heartbeat document containing separate permission and notification timestamps. It is private runtime data. |
+| `GEONAMES_USERNAME` | Optional; required for remote city/ID/GPS timezone lookup | Operator-owned account with web services enabled. Manual coordinates plus an explicit IANA zone remain available without it. |
+| `GEONAMES_ENDPOINT` | `https://secure.geonames.org` | Validated HTTPS provider endpoint. Account/quota errors are provider failures, not empty city lists. |
+| `CUSTOM_EMOJI_ENABLED` | `1`; set `0` for basic emoji | Enables supplied custom icons. Definite eligibility errors also trigger the guarded basic-emoji fallback. Client/account acceptance still needs a live test. |
+| `SOURCE_URL` | Empty in unpublished example; required by deployment/source policy | HTTPS URL to the exact corresponding source of the deployed build. Missing/invalid values produce the source-offer warning; startup does not enforce this policy automatically. |
+| `BUILD_COMMIT` | Optional process/Compose build metadata | Actual matching 40-hex commit for build identity. Not a bot credential and not a replacement for the source fingerprint of local modifications. |
+
+`BUILD_COMMIT` is intentionally not filled in `.env.example`. Do not enter the old repository commit as if it described new unpublished files. Compose's `environment` section overrides the data/session values from its `env_file`. Keep the same Compose project name on upgrades so named volumes remain attached.
+
+## Per-user and per-group preferences
+
+Private preferences and authorized group settings are separate contexts. Each context has a language, location, IANA timezone and calendar methods. Group messages use the group language; a manager's private panel uses their personal preference. Location search/sharing previews do not save settings until the named context is confirmed.
+
+The six catalogs are `he`, `en`, `ru`, `fr`, `es` and `pt-br`. Catalog completeness and placeholder parity are checked automatically; this is not a claim of independent native-speaker review or complete translation of provider scripture/liturgy. Hebrew source text and external provider strings may remain in their source language.
+
+`/language` and `/lang` open the authorized language selector. `/setcommands all all preview` is an operator-only private preview; omitting `preview` writes command menus to Telegram. The menu language for Brazilian Portuguese is Telegram's `pt`, while application preferences use `pt-br`. Group administrator menus expose `/settings`. See the complete command tables in the READMEs and [operator guide](OPERATIONS.md).
+
+New daily-zmanim/daf, tefillin and Omer scheduling modules are opt-in. Group locking, closing/opening notices and other content settings are independently configurable; imported legacy alerts use reviewed legacy settings. See [group settings](GROUP_SETTINGS.md), [methods](ZMANIM_METHODS.md) and [migration](MIGRATION.md).
+
+Never publish the configured `.env`, sessions, private databases or backups. Provider errors and diagnostic exports should be reviewed before sharing because operational data can include group identifiers and preferences.
diff --git a/docs/DATABASE.md b/docs/DATABASE.md
new file mode 100644
index 0000000..c81667f
--- /dev/null
+++ b/docs/DATABASE.md
@@ -0,0 +1,74 @@
+Current dev.9 runtime and migration changes: [delivery safety, independent scheduling, queue retention and verification limits](DEV9_DELIVERY_SAFETY.md). Historical release-specific numbers below apply to their named versions.
+
+# Application database
+
+The application uses PDO SQLite, separate from MadelineProto's session storage. Set `APP_DB_PATH` to a durable volume path. Telegram IDs are decimal TEXT, so group/user IDs are never passed through floating point. A 64-bit PHP runtime is required for MTProto random identifiers.
+
+`Database` enables foreign keys, WAL, FULL synchronous commits and a 100 ms busy timeout. SQLite operations are short and synchronous; a busy error is surfaced to the caller instead of waiting indefinitely inside the event loop. Do not yield, fetch providers, or call Telegram inside `Database::transaction()`. Maintenance runs separately with the same `.writer.lock` as the bot.
+
+`migrations/001_initial.sql` creates:
+
+| Table | Purpose |
+| --- | --- |
+| groups / group_settings | Subscription, typed per-group options and monotonically increasing revision |
+| users / user_preferences | Personal preferences independent of group settings |
+| locations | Verified provider location metadata |
+| message_templates | Text, entities, media and buttons with revision protection |
+| notification_rules | Typed fixed, interval and relative rule storage |
+| calendar_cache | Provider payloads, expiry and explicit coverage bounds |
+| scheduled_jobs | Durable outbox, event uniqueness, UTC run/cutoff times, leases and retries |
+| group_lock_state | Original permission snapshot, exact applied permissions, checksum and recovery state |
+| delivery_log | Each RPC attempt, persistent random ID, outcome and uncertainty |
+| conversation_states | Hashed selection token, user/scope/group/revision/action, expiry and single-use confirmation |
+| audit_log | Settings/template/removal actions without storing secret tokens |
+| legacy_imports | Read-only import provenance and original file hashes |
+| schema_migrations | Applied file checksum and installation time |
+
+JSON is used for bounded lists and structured entities, media, snapshots and provider payloads. Settings and scheduler statuses are typed columns; the bot is not serialized into one document.
+
+Every group change uses an expected revision. Concurrent/stale panels fail with `StaleSettings`. Group changes invalidate older previews and unsent content jobs, retaining delivery history and permission restoration debt. Personal changes cannot alter group records. Token confirmation checks remote administrator rights outside the transaction, then atomically consumes the token and updates the expected settings revision.
+
+Permission snapshots commit durably before Telegram mutation. A pre-existing administrator lock is never claimed by the bot. Restoration returns the captured rights instead of granting every permission. Current rights that differ from both the snapshot and exact bot-applied permissions produce a visible conflict requiring an administrator to resolve it. Missing/corrupt snapshots never trigger invented permissions. The coordinator evaluates the currently desired state, so an old opening record cannot open a group during a new prohibited sequence.
+
+Outbox jobs use a unique group/module/event key and retain the same MTProto `random_id` across retries/restarts. Leases expire and abandoned attempts are marked uncertain. A lost response can still be ambiguous; this is not an absolute exactly-once guarantee. Before RPC the worker rechecks group activation, module, revision, ownership, cutoff and the application's calendar eligibility policy. FLOOD_WAIT is a deferred retry, with a bounded attempt count and expiry; it does not sleep the whole bot.
+
+Use `php bin/health.php --db=/path/to/shabbat.sqlite` to report the SQLite version actually loaded in PHP, integrity, foreign keys, schema, job states and permission states. It does not invoke Telegram.
+
+The local Windows test runtime loads SQLite 3.45.2, which predates the documented WAL-reset fix. Isolated single-writer functional tests do not qualify this runtime for release. Production requires 3.51.3 or a specifically reviewed fixed backport; Docker installs the checksum-verified 3.51.3 source and the release check rejects older unreviewed versions. See [installation](INSTALLATION.md), [migration](MIGRATION.md) and [backup/restore](BACKUP_RESTORE.md).
+
+Migration 002 adds typed per-operator draft state. Migration 003 adds temporary manual override columns, payment intents and a unique donation ledger. Migration 004 expands the six language choices in group/personal/payment tables while retaining revisions, manual overrides, foreign keys, approved intents, delivery records and permission snapshots. Applied migration files are immutable; their stored checksum is verified on subsequent maintenance runs.
+
+All six supported language codes (`he`, `en`, `ru`, `fr`, `es`, `pt-br`) are SQL-constrained independently in group settings, personal preferences and payment intents after migration 004. The migration recreates these typed tables with explicit complete column lists; it does not regenerate schema at runtime or disable foreign-key verification. Manual overrides and the donation ledger are retained with their original identities.
+
+The explicit legacy importer stores only the reviewed admin-state whitelist in `admin_state`/`admin_directories`, with bounded exact bytes and provenance. `INSERT OR IGNORE` preserves existing edited drafts on repeated runs. Canonical positive `ids.txt` users populate `users`/default preferences without granting authorization. Automatic startup compatibility still reads only the old group list. Modern authenticated, expiring conversation tokens are never created from an old private draft/workflow file.
+
+`known_broadcast_peers` (migration 005) stores known delivery audience IDs, reviewed peer type, title and import timestamps independently of `groups` registration. Unknown imported negative dialog IDs cannot become lock targets merely through this table. Explicit import is additive; delivery code must resolve a current Telegram type/permission before filtering or sending. This table is included in the application's SQLite backup. BroadcastManager's own internal records and MadelineProto session storage remain separate dependency state and require their separate stopped-writer snapshots; see [backup/restore](BACKUP_RESTORE.md).
+
+## dev.4 additive migrations
+
+Migrations 001-008 retain their exact original bytes/checksums. Upgrade stopped copies with the maintained backup/migration command; startup only validates and never silently applies schema changes.
+
+| Migration | Added durable state |
+| --- | --- |
+| 009_admin_roles_links | Delegated bot-manager capabilities/revisions, system tracking-link configuration, HMAC visitor/daily counters and replay protection |
+| 010_subscriber_directory | Known peer metadata and observed activity, explicit private-chat eligibility, saved per-manager filters/search/paging, observed group roles, broadcast authorization and per-operator broadcast options |
+| 011_custom_star_amounts | Rebuilds the payment intent and dependent donation tables transactionally to allow integer amounts 1-10000 while preserving every existing intent, approval, charge, identity and FK/uniqueness constraint |
+| 012_tefillin_acknowledgements | Typed enabled/label preferences, template buttons_mode (inherit/custom/none), tefillin rounds, verified message bindings, unique per-user votes, callback deduplication and durable keyboard-edit revisions/retry deadlines |
+
+The directory's observed group roles are informational and never grant authority. Group-manager authorization is still checked freshly against Telegram. Configured recovery owners remain separate from delegated database roles. Private message audiences exclude newly observed group-only users until a real private interaction occurs; migration preserves previously known private registrations.
+
+Acknowledgements are bound to a durable successful tefillin job, its confirmed native message IDs and exact random ID. Registration precedes completion of delivery bookkeeping so recovery can replay safely without resending. Votes and callback IDs are committed in short SQLite transactions; remote markup edits happen afterwards, are coalesced per message and respect durable rate-limit deadlines. Replacing/removing the default keyboard suppresses the acknowledgement.
+
+Template source text/entities remain native, including an explicit empty entity array for plain text. A buttons-only template inherits current generated calendar content. Template preview candidates are transient user/group/revision-bound conversation state until explicitly confirmed.
+
+## Dev.7 additive migrations
+
+Migration **013_permission_recovery.sql** adds a separately checksummed recovery baseline, the last twenty permission operations per group (before/requested/observed state, stage and outcome), and expiring hashed one-use recovery confirmations. Explicit administrator resolutions retain original snapshot evidence and the selected baseline in the audit log. No existing snapshot or earlier migration is rewritten by installation.
+
+Migration **014_diagnostic_panels.sql** adds one expiring diagnostic snapshot per manager, private panel binding, view/search/filter/input state, fenced interaction ownership and export throttling/deduplication. Navigation uses stored data instead of refetching the provider or all Telegram groups. Sensitive configuration/session data is excluded from diagnostic capture.
+
+All writers must stop for backup and migration. A dev.6 database applies 013 and 014; a fresh database applies all fourteen files, and a repeated migration applies none. Startup verifies every migration checksum. See [recovery](PERMISSION_RECOVERY.md), [diagnostics](DIAGNOSTICS.md) and [deployment](SERVER_TEST_DEPLOYMENT.md).
+
+## Permission snapshot provenance (dev.8)
+
+Migration 015 retains all old snapshot/applied bytes and records whether a baseline came from the verified fresh native reader. Existing rows default to legacy unverified status. Original checksums only establish byte consistency. Unresolved legacy cycles cannot restore until the administrator reviews the actual normal permissions through the guarded recovery workflow. Capture evidence and provenance accompany new cycles; recovery audit history retains the former snapshot.
diff --git a/docs/DEPENDENCIES.md b/docs/DEPENDENCIES.md
new file mode 100644
index 0000000..2dcb19e
--- /dev/null
+++ b/docs/DEPENDENCIES.md
@@ -0,0 +1,85 @@
+# Dependency evidence
+
+[Machine-readable inventory](dependencies.json) is generated from composer.lock
+and installed license files with php scripts/dependencies.php. Each package has
+version, commit permalink, direct/indirect runtime/dev scope, SPDX metadata,
+license paths/SHA256 and unresolved findings. LICENSE_TEXT_PRESENT is evidence
+of a matching distributed text, not a complete legal compatibility certification.
+
+MadelineProto has the exact Composer constraint `8.7.0`, resolved to commit
+`7b99dd0f2d964a492c377912ffce9f5ce61aa9ed`, for reviewed patch/source semantics.
+The [official release](https://github.com/danog/MadelineProto/releases/tag/8.7.0)
+is marked pre-release, not stable. On 2026-10-14 the checked official v8 head
+`b1a4c5efee60a87cdbe7bc2b1eb542166ec7e2e3` still lacked the formatted-date wrapper;
+no tested official replacement was selected. See the
+[formatted-date review](MADELINE_FORMATTED_DATE.md) for the exact source comparison.
+BroadcastManager uses ~3.2.2, resolved to 3.2.2, allowing maintenance fixes without
+the previous unrestricted * or an unreviewed 3.3 API change. Lock is real Composer
+output. Direct use of Amp, Amp File, Amp HTTP Client and Revolt is explicit.
+PHP 8.3/8.4/8.5, PDO SQLite and mbstring are required. Composer plugins are disabled.
+
+Reproduce:
+
+ composer validate --strict --no-check-all
+ composer show --locked --format=json
+ composer licenses --format=json
+ composer audit --locked --format=json
+ composer check-platform-reqs
+ php scripts/dependencies.php
+
+`--no-check-all` omits Composer's advice against intentionally exact constraints;
+`--strict` and the normal lock/schema checks remain enabled. It does not disable
+lock consistency validation. The 2026-10-14 pin used the supported metadata-only
+`composer update --lock --no-install --no-scripts --no-plugins --no-audit --no-interaction --no-progress`.
+All 83 package versions and source/dist references stayed unchanged, and a byte
+comparison showed only Composer's `content-hash` changed in `composer.lock`.
+No installation or dependency lifecycle scripts ran for this metadata refresh.
+
+Security audit is separate from license review. [Patch provenance](PATCHES.md)
+and [third-party notices](../THIRD_PARTY_NOTICES.md) describe preserved notices.
+MadelineProto package metadata says AGPL-3.0-only while source grants version 3
+or later: retained and reported. NOT VERIFIED entries require release review.
+
+Hebcal/GeoNames data, texts and graphics have independent terms in third-party
+notices; application AGPL does not relicense them.
+Composer 2.8.8 does not implement licenses --locked. The attempted invocation
+failed; composer-licenses.json records the supported licenses --format=json
+command against the installation made from composer.lock. composer-show.json
+and composer-audit.json record the locked inventory and advisory check.
+
+## SQLite runtime evidence
+
+The Windows test host loads SQLite 3.45.2 through PHP PDO, not a separate CLI.
+Its version is older than the [official WAL-reset fix](https://sqlite.org/wal.html).
+This blocks release-platform approval even though isolated single-writer tests pass.
+The fix is present in 3.51.3+ (specific backports 3.44.6 and 3.50.7 are recognized).
+Docker builds 3.51.3 from a fixed publisher archive and verifies SHA256
+81f5be397049b0cae1b167f2225af7646fc0f82e4a9b3c48c9ea3a533e21d77a before
+compilation, then tests the PHP-loaded version with self-check --release.
+The image build is not locally tested because Docker is unavailable on this host.
+Runtime source is public-domain SQLite; keep its source/terms separate from AGPL.
+
+## Quality tooling and scope
+
+The locked development tools include PHPUnit, PHPStan, and PHP_CodeSniffer
+4.0.4 (BSD-3-Clause). The inventory includes their transitive dependencies and
+distributed license texts. `composer style` enforces PSR-12 errors in new `src`,
+`bin`, `scripts`, and `tests` PHP files. The reviewed immutable patch baseline and
+synthetic fixtures are excluded. `composer style-review` additionally reports
+advisory PSR-12 line-length warnings. No error baseline is applied.
+The preserved legacy `bot.php` is syntax checked, while integration fixtures,
+authorization tests, and manual review cover its changed boundaries.
+
+The publication corresponding-source archive contains the application and the
+57 locked runtime Composer dependency source trees, with their distributed
+licenses and local patch bases/diffs. The 26 development-tool packages are not
+included; in particular, a PHPStan PHAR is not represented as preferred source.
+PHP, SQLite, and operating-system base-image source are outside this collection;
+the Dockerfile retains the pinned upstream build inputs. See
+[publication scope](PUBLICATION_STATUS.md) and the archive manifest.
+
+Amp HTTP Server's public development TLS example keys `tools/tls/localhost.pem`
+and `tools/tls/localhost.key.pem` are omitted by exact reviewed path and SHA-256.
+They are unused by this bot. Their provenance and hashes are retained in the
+archive manifest. Other private-key material or Telegram token patterns fail
+the archive scan. This omission never changes the installed vendor directory.
diff --git a/docs/DEV5_TEST_FEEDBACK.md b/docs/DEV5_TEST_FEEDBACK.md
new file mode 100644
index 0000000..eedbd02
--- /dev/null
+++ b/docs/DEV5_TEST_FEEDBACK.md
@@ -0,0 +1,35 @@
+> Historical implementation record. Referenced `dist/VALIDATION-*` reports belong to earlier private development deliveries and are not bundled here. Current publication checks and limits are described in [publication status](PUBLICATION_STATUS.md).
+
+# dev.5 - requested test feedback
+
+| Request | Implementation | Verification |
+|---|---|---|
+| 1. Manual close/open failure | Valid explicit override no longer depends on a provider fetch; bounded per-group diagnostics distinguish confirmed, pending, protected pre-lock and failures | Offline override/provider failure, permission restoration/races, error formatter and batch isolation tests |
+| 2. Group commands | Only settings for both all-groups and group-admin scopes, in six languages | Command scope and localized publishing tests; no commands published to Telegram here |
+| 3. Single Shabbat overview | No paging controls; actual candle-eve weekday, Hebrew/civil dates, parsha/Mevarchim, continuous exit, nearby observances and next three Shabbat summaries | Recorded complete Hebcal replies, holy-sequence fixtures, bounded UTF-16 rendering |
+| 4. Logic and help | Fresh authorization, verified times and role-appropriate expanded help | Full regression suite and six-language help/navigation tests |
+| 5. Statistics | Public aggregate overview/reminders; admin audience/activity/groups/delivery tabs | No identity leakage; statistics-only role can navigate tabs without subscriber-directory access |
+| 6. Group settings | Four-mode chooser, subcategories, current effective candle setting and contextual return after save/cancel; paired rich-message editor | All six languages, custom input/page return, stale-flow and permissions tests |
+| 7. Source | Correct project URL plus license/version/commit card and optional exact build source link | Group/private language tests; existing version/license aliases preserved |
+| 8. Broadcast | Guided link buttons, preserved designed keyboard after text edits, resumable draft, explicit detailed final confirmation | URL rejection, actor isolation, expiry, cancellation, stale confirmation, rich entity retention |
+| 9. Telegram group support | Basic groups and supergroups supported; bot must have administrative restriction rights | Pinned SDK/native permission regressions and official docs |
+| 10. Languages | Hebrew, English, Russian, French, Spanish, Brazilian Portuguese | Identical locale keys/placeholders and localized output tests |
+| 11. Restart/load | Shared auxiliary IPC client and owner-only orderly restart lifecycle; permission work remains prioritized | Offline lifecycle/IPC reuse, concurrent/large group batches, shutdown and recovery tests |
+
+## What the supplied log establishes
+
+The 918-line supplied extract has one update-loop start and 44 auxiliary IPC connection/destruction cycles. It contains no permission RPC exception and no explicit restart failure trace. The repeated connection cycle matches the previous minute-by-minute broadcast-manager construction. This was corrected in application code. The exact Telegram error behind the reported manual 0-success/1-failure result cannot be established from that extract alone; the new diagnostic message makes the next result actionable without exposing arbitrary exception payloads.
+
+An explicit manual override is deliberately independent of provider availability until its expiry, while fresh group settings/activation, shutdown state and durable original-rights safeguards remain enforced. Expired overrides return control to verified calendar logic.
+
+## Sources and calendar boundaries
+
+Telegram documents [messages.editChatDefaultBannedRights](https://core.telegram.org/method/messages.editChatDefaultBannedRights) for groups and supergroups and explicitly rejects view_messages as a default restriction. [Bot API setChatPermissions](https://core.telegram.org/bots/api#setchatpermissions) likewise permits group and supergroup default permissions and requires restrict_members.
+
+Holiday categorization uses [Hebcal JSON yomtov](https://www.hebcal.com/home/195/jewish-calendar-rest-api), not holiday-name guesses. The verified calendar horizon remains the selected month minus 8 days through its last day plus 40 days. Unavailable distant holiday timings are not invented. Hanukkah observances do not create festival locking intervals. See [calendar presentation](CALENDAR_PRESENTATION.md).
+
+## Acceptance boundaries
+
+Final evidence and archive identity are in the versioned dist/VALIDATION-3.0.0-dev.5.json report. Local tests do not establish Telegram throughput, client appearance, actual server restart, command installation, or Docker image operation. Docker is unavailable on this development host. The loaded local SQLite version intentionally fails the release-platform gate; the pinned Docker SQLite must pass that gate on the isolated test server.
+
+No production data, active bot, real group, commit, tag, pull request or publication was changed. All earlier dev.4 features and the pinned MadelineProto 8.7.0 patches remain included.
diff --git a/docs/DEV6_TEST_FEEDBACK.md b/docs/DEV6_TEST_FEEDBACK.md
new file mode 100644
index 0000000..aa290db
--- /dev/null
+++ b/docs/DEV6_TEST_FEEDBACK.md
@@ -0,0 +1,32 @@
+> Historical implementation record. Referenced `dist/VALIDATION-*` reports belong to earlier private development deliveries and are not bundled here. Current publication checks and limits are described in [publication status](PUBLICATION_STATUS.md).
+
+# Dev.6 - permission conflicts, Shabbat messages and statistics
+
+## 1. Manual and automatic close/open
+
+The reported conflict message is produced by the bot's saved-state comparison, not by a Telegram RPC error proving that an administrator changed permissions. A concrete representation mismatch was reproduced: requests use until_date=0 for permanent default restrictions while Telegram's official TDLib describes the returned permanent default restrictions with INT32_MAX (2147483647). TDLib also serializes granular send restrictions with the legacy send_messages/send_media aggregate flags cleared. Raw comparison could therefore reject a successful close and retain that debt as a later conflict.
+
+The fix uses a reviewed, conservative equivalence for both close and restoration in the shared coordinator. Only the two permanent sentinels are equal; finite expiries remain distinct. Aggregate flags can be redundant only when their corresponding granular restrictions are all explicitly present. Individual differences, reactions/member-tag differences, unknown flags, missing/corrupt snapshots and real administrator changes remain protected. Original snapshot JSON and its checksum are retained byte-for-byte. Compatible persisted dev.5 conflicts can recover on reconciliation without deleting records or guessing the original permissions.
+
+The supplied message alone does not contain current/saved permission values. It establishes the conflict branch, but cannot prove which representation differs on that particular live group. Safe differing-field diagnostics and updated wording distinguish a mismatch from a proven external actor. The same coordinator serves manual and automatic transitions.
+
+Evidence: [Telegram rights](https://core.telegram.org/api/rights), [native constructor](https://core.telegram.org/constructor/chatBannedRights), and the immutable [official TDLib implementation](https://github.com/tdlib/td/blob/ad2178896533ff92831accdf56ea7edd8393f2ca/td/telegram/DialogParticipant.cpp).
+
+## 2. One consistent Shabbat/holiday presentation
+
+Default command, menu, inline and group announcement output shows the nearest verified Shabbat-containing period in the main section. The holiday list starts at the current local date, so a separate Yom Tov before Shabbat remains visible with its verified entrance and final exit. Only strict Yom Tov events receive festival entrance/exit times; continuous Shabbat/festival periods retain one final exit. Later Shabbat summaries, scripture-reference rows, visible source/update footers and numeric timezone offsets are omitted. Times use the selected IANA timezone and Y-m-d H:i. The configured candle offset and havdalah policy are shown; a fixed 50-minute sample does not overwrite existing preferences or the default 8.5-degree policy.
+
+Actual Yom Tov boundaries and every required intermediate candle remain subject to verified data and continuous-period rules. Hanukkah is an observance, not a Yom Tov locking interval. Source attribution remains in the relevant information/license documentation, and internal provenance/freshness validation is unchanged. Custom templates remain administrator-owned; the update does not rewrite their saved text.
+
+Calendar day classification accepts both documented fixture forms of the original Chol Hamoed marker, (CH'M) and (CH''M). This keeps reminder policy aligned with the configured Chol Hamoed setting without promoting those dates to Yom Tov or adding lock periods. An active Yom Tov whose verified final exit falls after civil midnight remains visible until that exit, even when its calendar date is yesterday.
+## 3. Statistics callbacks
+
+Home and statistics screens emitted ps:overview / ps:modules, but the production upgradedCallbacks prefix allowlist omitted ps. Existing controller tests bypassed that adapter boundary and therefore did not detect the missing route. The allowlist is corrected. New regressions use native pinned SDK query wrappers and the actual update-handler method with callbacks read from generated markup, including non-manager private users, navigation, denied contexts and cancellation.
+
+Edits target the original private panel. A missing/deleted panel produces a localized callback response without new-message flooding. Group and inline callbacks cannot edit a private user's statistics panel.
+
+## 4. Release and verification
+
+All previous cumulative features remain included. No dependency or schema update is required. The direct /root/shabbat-test upload instructions exclude uploaded ZIP/checksum files from Docker context. Exact tests, fingerprints, archive contents and platform limits belong to dist/VALIDATION-3.0.0-dev.6.json.
+
+No production data, active bot, real Telegram group, commit, tag, push or publication was changed. Docker and Telegram acceptance were not executed locally. The host's older SQLite intentionally fails the release-platform gate; the Docker-pinned reviewed version must pass on the test server before startup. Automated tests establish the covered behavior, not a guarantee of indefinite uptime.
diff --git a/docs/DEV8_PERMISSIONS.md b/docs/DEV8_PERMISSIONS.md
new file mode 100644
index 0000000..1c64baf
--- /dev/null
+++ b/docs/DEV8_PERMISSIONS.md
@@ -0,0 +1,38 @@
+> Historical implementation record. Referenced `dist/VALIDATION-*` reports belong to earlier private development deliveries and are not bundled here. Current publication checks and limits are described in [publication status](PUBLICATION_STATUS.md).
+
+# Dev.8 permission reading and existing snapshots
+
+## Evidence and limits
+
+The supplied dev.6 database row was in `conflict`. Its original snapshot allowed all listed actions except editing a member's own tag; its checksum matched. The saved mask therefore explains why restoring it would allow media, invitations and pins even though the administrator reported earlier restrictions. A checksum establishes consistency of saved bytes, not whether the initial Telegram read was accurate. The literal missing-rights fallback cannot by itself produce that row's explicit `edit_rank=true` and `until_date=2147483647`. The exact server-side cause of this older snapshot has not been established.
+
+Independent offline investigation found a real read-integrity defect in the pinned SDK/helper path: `getFullInfo` combines a refreshed full object with earlier peer data, and `getInfo` can return its earlier minimal record after refreshing the peer cache. Re-reading that cache did not establish that permissions came from the just-completed request. An absent optional restriction object in verified complete data can legitimately mean no default restrictions; absence in incomplete or unverified data is not proof of that state.
+
+The separate `manage_topics` discrepancy does not justify ignoring this restriction for non-forum groups. No server normalization rule was established. Actual readback remains subject to comparison and unresolved evidence is retained.
+
+## Implemented protection
+
+MadelineProto remains exactly **8.7.0** at source commit `7b99dd0f2d964a492c377912ffce9f5ce61aa9ed`, with the existing Composer lock. A fifth, separately guarded local patch exposes the fresh raw full-peer response through the existing supported `getFullInfo` helper when caching is disabled. It does not call blacklisted namespace methods, change the public helper signature, or run patches while the bot is active. Its immutable source, diff, reviewed output, manifest and tests accompany the corresponding source.
+
+The permission reader validates matching requested peer, full-peer type/ID and complete non-minimal Chat from that response. It rejects malformed, absent, duplicate, forbidden or mismatched records. It does not substitute cached rights. Both manual and scheduled operations use this reader for capture and verification, and restore the original restriction object rather than constructing a general open mask. Cancellation and Telegram retry deadlines still propagate.
+
+Migration 015 records snapshot provenance. Existing unresolved snapshots from older versions remain intact and are treated as unverified; automatic or explicit restoration of those snapshots is blocked. A new week, restart or matching checksum does not make an older snapshot trustworthy. Completed cycles may take a new verified baseline at the next closure. Fresh captures preserve their original bytes, checksum and capture evidence through the cycle.
+
+The group recovery panel explains the legacy state in all six languages. Restore remains available for verified snapshots. A legacy snapshot requires the administrator to set the intended normal rights in Telegram, refresh the panel and explicitly confirm adoption, outside a protected period. The old snapshot remains as evidence. The diagnostics panel/export reports snapshot provenance without dumping JSON into messages.
+
+## Existing affected test group
+
+1. Deploy dev.8 with the same isolated test bot, `.env` and named volumes, after stopping writers and taking the migration backup.
+2. Outside Shabbat/Yom Tov and any active lock, use Telegram's group permissions to set the actual intended normal state. Do not copy the broadly permissive older snapshot.
+3. Open the bot's group settings, **Resolve permission problem**, then **Refresh**. Check the named group and the current permissions, then choose **Use current permissions** and confirm.
+4. Test a new manual close/open cycle in a disposable group with media, invitations and pinning deliberately restricted beforehand. Verify those exact restrictions after reopening and after a restart. Also check the automatic path at a controlled test boundary.
+
+Do not delete lock rows or adopt a currently locked mask as the normal state. An active close override, religious period, unavailable calendar or uncertain read prevents recovery. A readback conflict after an update can mean Telegram already changed permissions; it is not evidence that nothing happened.
+
+## Validation and live acceptance
+
+The versioned `dist/VALIDATION-3.0.0-dev.8.json` identifies the frozen source, full suite, quality gates, migration tests and extracted archive checks. Native SDK tests use the installed pinned trait with controlled RPC responses; they do not contact Telegram. Patch tests include clean apply, repeat, mismatch and partial installation. Database upgrade tests preserve older snapshots and prove that they cannot trigger unverified restoration.
+
+Docker is unavailable on the Windows development host. Its older local SQLite is intentionally rejected by the release platform gate; the pinned Docker runtime must pass that gate on the server. Live Telegram permissions, Telegram client rendering and actual group behavior still need isolated acceptance. No production data, bot sessions or real groups were used for development tests. No commit, tag, pull request or publication is part of this update.
+
+See [recovery](PERMISSION_RECOVERY.md), [deployment](SERVER_TEST_DEPLOYMENT.md), [patches](PATCHES.md) and the official [restriction flags](https://core.telegram.org/constructor/chatBannedRights), [default-rights method](https://core.telegram.org/method/messages.editChatDefaultBannedRights) and [MadelineProto helper requirement](https://docs.madelineproto.xyz/API_docs/methods/channels.getFullChannel.html).
diff --git a/docs/DEV9_DELIVERY_SAFETY.md b/docs/DEV9_DELIVERY_SAFETY.md
new file mode 100644
index 0000000..9ef7e1a
--- /dev/null
+++ b/docs/DEV9_DELIVERY_SAFETY.md
@@ -0,0 +1,47 @@
+# dev.9 delivery and scheduler safety
+
+This is an unpublished test build. It fixes the timing, backlog and scheduler isolation defects reproduced in the dev.8 read-only audit. MadelineProto remains exactly 8.7.0 at `7b99dd0f2d964a492c377912ffce9f5ce61aa9ed`; the Composer lock is unchanged. Migration 016 is additive; migrations 001-015 and all four MadelineProto patch sets retain their bytes. The separate BroadcastManager patch gains reviewed lifecycle hooks; its previous approved source is preserved.
+
+## Delivery rules and exceptions
+
+Ordinary scheduled Zmanim, Daf, Tefillin, Omer and pre-entry time notices require verified calendar coverage and are suppressed throughout the religious Shabbat/Yom Tov interval and the configured operational closure interval. Manual opening and mode=off do not disable religious quiet. Before entry on an ordinary eve, eligible notifications are allowed until their own cutoff. Consecutive Shabbat/festival days remain one protected sequence. `yomtov=true` identifies a festival requiring this protection; a generic holiday category does not. These classifications follow [Hebcal's documented JSON fields](https://www.hebcal.com/home/195/jewish-calendar-rest-api).
+
+A configured **group-closing notice is the explicit exception**: it is sent after a confirmed close, or at the close transition in messages-only mode, while the group is protected. Opening notices require the quiet period to have ended. Private replies, user-requested administrative operations and explicitly scheduled operator broadcasts are separate from routine calendar notification eligibility.
+
+CalendarService evaluates current state after provider I/O. Outbox eligibility stores short-lived evidence bounded by the next religious/operational transition, job expiry, local day, manual override expiry, custom quiet-hour transition and module-specific solar cutoff. Immediately before the native send call, the gateway rechecks current settings, ownership, time, shutdown, local permission debt and delivery reservations after emoji/topic/media preflight. The same check repeats for the basic-emoji fallback and after persistence. No application await occurs between the final fence and transport entry. Clock rollback, changed settings or expired evidence fail closed.
+
+This is not an atomic guarantee about Telegram receipt time: a request already passed to the SDK/network may be accepted later. Real Telegram latency, server flood restrictions, host suspension and clock accuracy still matter. Configure an appropriate early closing buffer for operational needs. Do not interpret a local test as a zero-delay or 100% uptime guarantee.
+
+## Reminder backlog, quiet hours and uncertainty
+
+Persisted due Tefillin reminders that were never dispatched are coalesced to the latest eligible reminder per group. Actual delivery reservations enforce the configured minimum spacing and daily quota in the group's current timezone across settings revisions. Concurrent workers cannot both reserve the same available spacing slot. Uncertain attempted messages keep their frozen payload/random ID and evidence; later logical messages wait conservatively rather than replaying a burst. Multiple uncertain messages retry in a stable order to avoid mutual blocking. Confirmed sends remain confirmed if follow-up bookkeeping fails.
+
+Quiet hours are rechecked at delivery time for ordinary modules, including Omer, not only when planning. A delayed job may defer until quiet hours end if still valid. Overnight hours and both occurrences of repeated DST times are supported; missing wall-clock times are not guessed. Tefillin also checks current weekday, pause date, Chol Hamoed/Tisha B'Av policy, daylight window and sunset safety. Omer checks the actual restoration gap again at the final send fence.
+
+Migration 016 conservatively marks old attempt/delivery evidence. An unknown old attempt is not silently treated as never dispatched. This can intentionally suppress or defer an uncertain reminder; it cannot infer whether Telegram accepted an old request. The unchanged permission-provenance rules from dev.8 still apply to unresolved older group snapshots.
+
+## Independent scheduling and failure isolation
+
+Production has separate non-overlapping callbacks: permission reconciliation every 15 seconds, notifications every second, broadcasts every 15 seconds. A callback's work time adds to its interval; these are not precise delivery deadlines. Permission reconciliation uses four rolling workers across the keyset traversal: one slow peer occupies one slot while free workers continue to later groups, including across page boundaries. Every launched task is joined before completion. Native peer resolution/reconnection still has SDK-controlled latency; four simultaneous slow peers can occupy all slots, and the health probe reports an overdue completed pass. A detached timeout wrapper is not used to pretend those reads stopped. It queues lightweight close/open jobs without fetching message templates/providers on that path. Already completed restoration/prelocked no-ops avoid unnecessary Telegram permission reads.
+
+Notification planning uses stable keyset traversal of at most 20 active groups per cycle, preserving its cursor for fairness. Disabled modules skip provider planning. Planning and delivery have elapsed-time budgets between operations; a single in-flight request can exceed a budget. Each cycle processes at most 20 outbox jobs and 20 cleanup jobs. Retries persist with backoff. These limits are local work bounds, not a claim that Telegram permits that many sends per group.
+
+An empty/future-only broadcast queue does not start extra permission sweeps. Scheduled broadcasts no longer occupy the permission callback. Manual and scheduled broadcast managers share one cancellation-aware admission queue, so only one native bulk operation runs at a time; authorization is checked again after waiting. Completed in-memory native progress history is capped at the latest 128 fully drained entries while active work and persistent history are preserved. BroadcastManager's optional runtime hooks check current creator authorization, use cancellation-aware waits, cancel remaining queues on shutdown and join launched worker/progress futures. Native `FLOOD_WAIT` is returned to the manager instead of allowing a two-day hidden SDK sleep. The patch is applied offline with exact-file hashes and includes the upstream baseline, prior reviewed source and unified diffs.
+
+Telegram documents per-chat, per-group and bulk message limits; actual MTProto restrictions remain server-controlled. Respect [Telegram's bot limits](https://core.telegram.org/bots/faq#my-bot-is-hitting-limits-how-do-i-avoid-this) and [MadelineProto's flood-wait guidance](https://docs.madelineproto.xyz/docs/FLOOD_WAIT.html). No paid broadcasts are enabled by this update.
+
+Provider requests share a single in-flight result per key and have a short failed-key cooldown. Three host failures trigger a 30-second circuit cooldown below the persistent cache, so previously cached data remains available for coverage validation. Deliberate caller cancellation does not poison shared requests. Provider failure never becomes an invented empty holiday calendar.
+
+## Monitoring and routine history retention
+
+`php bin/health.php` reports separate permission/notification heartbeat ages, completed cycle group counts/durations, due/active/uncertain jobs, oldest due age, recent sends/expiry and maximum recent delivery delay. Notification activity cannot make a stuck permission lane appear fresh. Heartbeat replacement is atomic. Normal polling checks the connection/schema and current metrics without a full database integrity scan; use `php bin/health.php --deep` for explicit full integrity/foreign-key validation during maintenance. A missing, future-dated or older-than-180-second heartbeat marks health unhealthy; operators should investigate before restarting repeatedly.
+
+At most once per minute, bounded maintenance selects up to 200 eligible terminal jobs and removes at most 200 associated delivery-log rows per pass. A job is deleted only after all its logs have been removed. Other supported categories also remove at most 200 eligible rows older than 90 days per pass. It prunes routine terminal jobs/logs, old cache/conversation state and safe routine audit/deletion history. It preserves active work, uncertain dispatch evidence, permission snapshots/operations, unresolved recovery, current last-message pointers, unprocessed bookkeeping and interactive Tefillin acknowledgement rounds/votes. This is **not a globally bounded database-size promise**: retained recovery/interaction history can grow and requires monitoring, backups and an explicit archival policy. No VACUUM or unbounded bulk deletion runs in the scheduler.
+
+## Verification and deployment limits
+
+Regression tests cover slow provider/topic/emoji/media work crossing entry, direct and fallback sends, actual quiet hours, Omer restoration changes, persisted backlog, daily caps/spacing, uncertainty across restart, migration, fair traversal, bounded concurrency and shutdown. Queue tests simulate 1,000 groups with 3,000 due reminders and 180 virtual days with 7,200 deliveries. Application and broadcast load tests use isolated databases, controlled clocks and fake transports; they do not send messages or measure real Telegram capacity.
+
+The original dev.9 validation is historical development evidence. Exact results for this publication copy are recorded separately in the delivered checks report; see [publication status](PUBLICATION_STATUS.md). These simulations are not months of wall-clock soak or proof of unlimited group count. Docker is unavailable on the local Windows host; the pinned Linux image and live disposable-group acceptance must still pass on the test server. The local older SQLite must continue to fail the release-platform gate rather than bypass it.
+
+See [test deployment](SERVER_TEST_DEPLOYMENT.md), [permission recovery](PERMISSION_RECOVERY.md), [database](DATABASE.md), and [BroadcastManager patch provenance](../patches/broadcastmanager/README.md).
\ No newline at end of file
diff --git a/docs/DIAGNOSTICS.md b/docs/DIAGNOSTICS.md
new file mode 100644
index 0000000..a6151a2
--- /dev/null
+++ b/docs/DIAGNOSTICS.md
@@ -0,0 +1,17 @@
+# Calendar and permission diagnostics
+
+`/testzmanim` is a private bot-manager command guarded by the current **statistics** capability. Use `/testzmanim GROUP_ID` to inspect a registered group. Without a group selection, it uses the manager's personal location/settings. Diagnostics do not lock or open a group.
+
+The command opens one bounded, readable panel. Next and Previous edit that message. The middle **page / total** button accepts a page number for quick navigation. Search and category/status filters narrow the cached report; clearing them restores the complete view. Prompts accept plain text without visible workflow tokens and can be cancelled. Refresh explicitly collects a new report.
+
+Report sections include calendar decisions, configured timing/location, delivery/recovery status and, for a selected group, permission diagnostics. Provider or Telegram failures are shown as unavailable sections instead of hiding the rest. The manager sees when the snapshot was collected; cached pages are not described as a live reading.
+
+Navigation, search, filters and page jumps use the same expiring snapshot. They do not repeat the provider and Telegram requests on each button click. Snapshots, query length, page size and JSON export size are bounded. The original panel and current actor authorization are checked on every action. Expired or deleted panels do not trigger replacement-message flooding.
+
+**Export JSON** sends one `.json` document with a short caption only after an explicit click. It does not print JSON in chat. Duplicate/stale export actions and rapid repeats are guarded. Exports contain diagnostic data, never credentials, session contents or private configuration files. Group IDs, locations and permission state remain operational information, so exports are sent only to the authorized private manager.
+
+Dev.7 adds migration 014 for expiring diagnostic snapshots and interaction/export state. Run the stopped-writer backup/migration process from [test deployment](SERVER_TEST_DEPLOYMENT.md). The six supported UI languages include all new actions, prompts and errors.
+
+Transport uses the locked MadelineProto implementation of [messages.sendMedia](https://docs.madelineproto.xyz/API_docs/methods/messages.sendMedia.html) with an in-memory JSON upload. Offline tests verify native payload construction and actual callback/message routes; they do not claim a real Telegram upload or flood-limit acceptance test.
+
+Dev.8 adds snapshot-verification status to the selected group's permissions section and JSON export. The indicator checks the original bytes and capture provenance; a legacy snapshot or checksum failure is not displayed as verified. See [the affected-group procedure](DEV8_PERMISSIONS.md).
diff --git a/docs/FEATURE_PARITY.md b/docs/FEATURE_PARITY.md
new file mode 100644
index 0000000..a887d17
--- /dev/null
+++ b/docs/FEATURE_PARITY.md
@@ -0,0 +1,219 @@
+# Feature parity and source audit
+
+The interface reference is [WizardLoop/JewishPulse](https://github.com/WizardLoop/JewishPulse/tree/d7b06eb1acb206543dcff35a7c14ebce4760b4e5), reviewed on 2026-10-13. Shabbat retains its package name, bot identity, MTProto session, Stars donations, operator tools and BroadcastManager integration. Reference ideas were reimplemented; the unsafe reference location parser, implicit extra candle minute, truncated 8.5 angle and mutable global timezone were not imported.
+
+`UI` below means `src/UI/BotController.php`. Its panel labels use the administrator's personal language; calendar content uses the selected scope's language. Every settings write requires an unexpired user-bound flow and revision, current Telegram administrator permission for a group, and a one-use server-side confirmation. A public inline query never grants group authority.
+
+| Reference source and function | Existing behavior | Implemented equivalent | Scope | Automated evidence |
+|---|---|---|---|---|
+| `src/Handlers/Handlers.php::starthandle` | Private welcome and navigation | `UI::start` / `/start` | Personal | `UITest::testMenusAreBilingualWithStableCallbacksAndNativeColors` |
+| `Handlers.php::BackStart` | Return to welcome | `sb:start` โ `UI::start` | Personal | Menu callback inventory in `UITest` |
+| `Handlers.php::SettingsMenu` | Four personal settings | `UI::panel` (personal and group) | Personal / group admin | Personal/group isolation and language tests |
+| `Handlers.php::SettingsLanguage` | Language selector | `UI::screen(language)`, `/language`, `/lang` | Personal / group admin | `testLanguageCommandAndGroupLanguageAreIndependent` |
+| `src/JewishPulse.php::handleSetLanguage` | Save personal language file | `sb:s:token:l:he/en/ru/fr/es/pt-br` + one-use confirmation | Personal / group admin | `testLanguageCommandAndGroupLanguageAreIndependent` |
+| `src/Locales/Lang.php::getUserLang`, `setUserLang` | Personal `lang.txt` | `SettingsRepository::getUser/updateUser`; group language separate | Personal / group | Isolation tests |
+| `Lang.php::loadTranslations` | HE fallback | `src/UI/Lang::text/validate` | Display | `testTranslationKeysPlaceholdersEscapingAndFallback` |
+| `Lang.php::getAvailableLanguages`, `getLanguageButtons` | Files scanned for two choices | Explicit he/en/ru/fr/es/pt-br registry; six real catalogs and stable identifiers | Display | Menu language test |
+| `Handlers.php::SettingsLocation` | `keyboardButtonSwitchInline`, same peer | `UI::locationScreen`, `GeoNames::search` with token | Personal / group admin | `testTwoGroupLocationFlowsSelectedInReverseOrder` |
+| `JewishPulse.php::onUpdateBotInlineQuery` city branch | Search every non-Shabbat string; IDs `geo_0` | Explicit `loc`, UTF-8 normalization, stable query-bound IDs, paging | Public read / authorized selection | `LocationTest`, two-flow UI test |
+| `JewishPulse.php::handleSaveLocationFromMessage` | Parse forgeable localized text and save | Replaced with `location_choice` โ private preview โ one-use `save` | Personal / group admin | Forged text, wrong user, replay, expiry and admin-loss tests |
+| `Storage/UserLocation.php::getGeonameId/setGeonameId` | Per-user JSON with implicit Jerusalem | SQLite scope settings + complete coordinates and IANA zone | Personal / group | Personal/group isolation, timezone tests |
+| `UserLocation.php::searchGeoNames` | Unencrypted HTTP, errors returned as no results | `src/Location/GeoNames`, verified HTTPS, typed errors, selected-place validation | Provider | All `LocationTest` cases |
+| `Handlers.php::SettingsCandles` | Display 10/15/18/20/22/30/40, save one more | Seven exact values including 40; city custom and validated form | Personal / group admin | `testAllSevenCandleOptionsAreStoredExactly` |
+| `JewishPulse.php::handleSetCandles`; `UserLocation::getCandleSetting/setCandleSetting` | Personal JSON with default 41 | Typed `candle_minutes`; no hidden adjustment | Personal / group | Exact seven-value test |
+| `Handlers.php::SettingsHavdalah` | 8.5ยฐ, 42, 72 | Separate method/value; 8.5ยฐ, 42, 50, 72 and custom minutes | Personal / group admin | `testDegreesRemainFloatAndFiftyMinutesAreAvailable` |
+| `JewishPulse.php::handleSetHavdalah`; `UserLocation::getHavdalahSetting/setHavdalahSetting` | Float saved, later cast to integer | Float preserved end to end | Personal / group | Exact methods test; `CalendarTest` |
+| `Handlers.php::ShabbatTimes` | Private upcoming times | `UI::renderView(shabbat)` from one common calendar | Personal / group preview | Rendering, calendar tests |
+| `Handlers.php::Shabbat_Times`; `JewishPulse.php::shabatCommand` | `/shabat` and share button | `/shabat`, `/shabbat`; group settings on group display | Personal / group | Calendar/UI tests; real Telegram routing requires staging |
+| `Handlers/ShabatTimes.php::parseShabatData` | `/shabbat` current-week response formatting | `CalendarService::snapshot` explicit coverage/current/next event | Calendar | `CalendarTest` before/during/after, Sunday, adjacent holidays |
+| `ShabatTimes.php::getNextShabbatDates` | Date calculation changes global timezone | UTC instants, explicit per-group IANA zones | Calendar | DST and concurrent-zone tests |
+| `JewishPulse.php::onUpdateBotInlineQuery` times branch | Share personal Shabbat times | Reserved aliases plus zmanim/daf/omer; authorized group share snapshot | Read-only | `testGroupShareUsesAuthorizedSnapshotInsteadOfPersonalLocation` |
+| `Handlers.php::DailyPage`; `ShabatTimes.php::getDailyPage` | Today's Daf from Hebcal; mostly hardcoded Hebrew | `/daf` with explicit today/tomorrow/date and trusted provider link | Personal / group | Fixture calendar checks; no real Telegram test |
+| `Handlers.php::AllCommands` | Help categories | `UI` help menu and actual command list | All users | Help translations/key validation |
+| `Handlers.php::BasicRules` | `/`, `!`, `.` prefixes | `UI::handleMessage` supports these prefixes | All users | Parser exercised by UI tests |
+| `Handlers.php::SettingsInfo` | Explain four personal options | Actual personal/group screens and `GROUP_SETTINGS.md` | All users / group admin | Screens and form tests |
+| `Handlers.php::CommandsAllUsers` | Commands and inline help | Localized implemented help commands | All users | Locale validation |
+| `Handlers.php::CloseMsgCommand` | Delete private menu message | Existing Shabbat close handler retained; back/cancel in new screens | Personal | Live deletion not tested |
+| `Handlers.php::StatsGroups`; `JewishPulse.php::StatsGroups` | Dialog scan/count | Known-group SQLite stats plus retained operator statistics | Public / system admin | Storage tests; live dialogs not tested |
+| `Handlers/AdminPanel.php::handle`, `admin_back` | Operator admin panel | Localized OperatorController + guarded legacy routing | System admin only | UIOperatorTest six-language frames and fresh role checks; no live operation |
+| `AdminPanel.php::statUsers` | Dialog statistics | SQLite group counts + known-peer type statistics | System admin only | Six-language statistics frames tested; live peer metadata not tested |
+| `AdminPanel.php::admin_broadcast`, `broadcast_back`, `handle_broadcast`, `back_broadcast` | Compose text/media broadcast | OperatorDraft rich scoped SQLite editor + retained BroadcastManager sending | System admin only | Atomic draft/digest/token/media/style tests; no live broadcast |
+| `AdminPanel.php::type_users`, `typemode1/2/3/4` | Users/channels/groups/all filters | Scoped users/channels/groups/all audience and pin controls | System admin only | Independent operator options and stable callbacks tested; no live broadcast |
+| `JewishPulse.php::handleAdminButtons`, `send_broadcast`, `handleBroadcastProgress` | Dispatch, broadcast and progress | Fresh-role private callbacks + one-use send + verified status formatter patch | System admin only | UIOperatorTest and library patch regression tests; no live broadcast |
+| `Payments/Payments.php::sendDonationOptions`, `handlePreCheckout`, `sendMessageToAdmins`; `JewishPulse.php::Payments`, `onupdateBotPrecheckoutQuery` | Stars support and payment reporting | Validated Stars intents/payment handlers, localized offers and duplicate-safe private operator notices | Personal / system admin report | DonationsTest offline; no real payment was submitted |
+
+## New capability evidence
+
+| New capability | Implementation | Evidence and practical limit |
+|---|---|---|
+| Group language, location, timezone, Israel/diaspora | UI scopes, SettingsRepository and FlowTokenRepository | All six locales, different groups, reversed selection, personal separation, stale/replayed flow tests |
+| Native colored buttons | MTProto `keyboardButtonStyle` in `UI::keyboard` | Installed `TL_telegram_v225.tl` constructors validated; primary/success/danger tested; rendering depends on Telegram client |
+| Full/short zmanim, today/tomorrow/date | `UI::view/renderView`, `CalendarService::zmanim` | Explicit date and missing values tested; polar calculations refuse guesses |
+| Tefillin times preview | `UI::renderView(tefillin)`, `ReminderPlanner` | Calculated jobs without enabling/sending; cutoff, dedup and holiday fixtures in calendar tests |
+| Omer, all 49 deterministic counts | `CalendarService::omer`, `Content/OmerText`, ReminderPlanner | Bundled 49-count and calendar fixtures; actual prayer recitation remains an individual responsibility |
+| Module enable/edit/reset/pause | Scoped UI forms and one-use confirms | Form validation, exact settings, replay/admin loss tests |
+| Rich group message editing and test send | `Content/TemplateEditor`, `TemplateRepository`, `TemplateRenderer` | Separate explicit test confirmation and five-minute rate limit; no production Telegram send |
+| UTC scheduling, recovery, snapshots, outbox | `Scheduling/`, `Runtime/Application` | Fake-gateway storage/scheduler tests; no claim of absolute exactly-once delivery |
+| Legacy membership carried forward | `LegacyImporter::importGroupListAutomatically` | Additive startup group registration; `/settings` opens a known group's panel without re-adding |
+
+## Six-language and operator verification
+
+The canonical locale registry is he, en, ru, fr, es and pt-br. All six catalogs have the same keys, nonempty translations and placeholder multisets. Telegram command language codes use pt for Brazilian Portuguese while application storage retains pt-br. UI tests exercise all six private menus and group saves without changing coordinates, stable callback IDs after language changes, and group content language independent from the administrator panel language.
+
+OperatorController and OperatorDraft replace active Hebrew-only operator frames while preserving the original guarded operation handlers. Subscribers use known database audiences, including separately imported broadcast peers; an explicitly removed Shabbat group is excluded. The original pure custom-button parser is bound through Application, preserving native colors/icons/copy/alerts. See GROUP_SETTINGS.md for confirmed broadcasts, per-operator state and the separate BroadcastManager delivery backend.
+
+## Source and infrastructure findings
+
+JewishPulse's inspected tree declares AGPL-3.0-only. Its README claims GitHub Actions CI, but no workflow files exist in that commit. Its `.github/ISSUE_TEMPLATE ` directory has a trailing space and cannot be checked out normally on Windows. Its Dockerfile uses an unpinned MadelineProto image and copies the complete checkout; these practices were not imported. Its graphic assets and external thumbnail were not reused because this upgrade needs no new graphic asset and their independent provenance was not established.
+
+The original Shabbat runtime was a single 7,195-line `bot.php`, MIT metadata, `danog/madelineproto:^8.6`, unrestricted BroadcastManager `*`, and no lock or automated tests. New documentation does not assert a live feature test where only source was reviewed. Retained donation/operator/broadcast features require an opt-in staging account; no actual Telegram group, active session or production data was changed.
+
+## Original Shabbat function inventory
+
+Each original function is listed below, including helpers. This is an inventory of source and its replacement/retained responsibility, not a statement that Telegram-side behavior was tested.
+
+| Original source function (`bot.php`) | Equivalent responsibility | Scope | Verification |
+|---|---|---|---|
+| `getTestShabbatLockTimes` | CalendarService + Runtime/Application diagnostics | calendar / operator | CalendarTest; live diagnostics not run |
+| `getAlertTestTime` | CalendarService + Runtime/Application diagnostics | calendar / operator | CalendarTest; live diagnostics not run |
+| `getZmanimForCities` | CalendarService + Runtime/Application diagnostics | calendar / operator | CalendarTest; live diagnostics not run |
+| `getShabbatLockTimes` | CalendarService + Runtime/Application diagnostics | calendar / operator | CalendarTest; live diagnostics not run |
+| `getShabbatCheckLock` | CalendarService + Runtime/Application diagnostics | calendar / operator | CalendarTest; live diagnostics not run |
+| `isShabbatOrHolidayNow` | CalendarService + Runtime/Application diagnostics | calendar / operator | CalendarTest; live diagnostics not run |
+| `getReportPeers` | Retained bot/operator integration | system admin / runtime | Live Telegram not run |
+| `onStart` | Retained bot/operator integration | system admin / runtime | Live Telegram not run |
+| `ChannelsLeave` | Retained bot/operator integration | system admin / runtime | Live Telegram not run |
+| `StartCommand` | UI::start/handleMessage/handleCallback/handleInline/renderView | personal / group read | UITest + CalendarTest |
+| `BackCommand` | UI::start/handleMessage/handleCallback/handleInline/renderView | personal / group read | UITest + CalendarTest |
+| `ShabbatTimes` | UI::start/handleMessage/handleCallback/handleInline/renderView | personal / group read | UITest + CalendarTest |
+| `AllCommands` | UI::start/handleMessage/handleCallback/handleInline/renderView | personal / group read | UITest + CalendarTest |
+| `Rules` | UI::start/handleMessage/handleCallback/handleInline/renderView | personal / group read | UITest + CalendarTest |
+| `CommandForAdmins` | UI::start/handleMessage/handleCallback/handleInline/renderView | personal / group read | UITest + CalendarTest |
+| `CommandForAll` | UI::start/handleMessage/handleCallback/handleInline/renderView | personal / group read | UITest + CalendarTest |
+| `shabatCommand` | UI::start/handleMessage/handleCallback/handleInline/renderView | personal / group read | UITest + CalendarTest |
+| `onUpdateBotInlineQuery` | UI::start/handleMessage/handleCallback/handleInline/renderView | personal / group read | UITest + CalendarTest |
+| `closecommand` | Retained bot/operator integration | system admin / runtime | Live Telegram not run |
+| `addgroupCommand` | SQLite membership + UI::panel/screen/changes/moduleDefaults | group admin | UITest + StorageTest; live Telegram not run |
+| `removegroupCommand` | SQLite membership + UI::panel/screen/changes/moduleDefaults | group admin | UITest + StorageTest; live Telegram not run |
+| `grupsettingsCommand` | SQLite membership + UI::panel/screen/changes/moduleDefaults | group admin | UITest + StorageTest; live Telegram not run |
+| `ifNotCommands` | Content/TemplateEditor + TemplateRepository + TemplateRenderer | group admin | Fake Telegram template tests; live not run |
+| `backtosettings` | SQLite membership + UI::panel/screen/changes/moduleDefaults | group admin | UITest + StorageTest; live Telegram not run |
+| `defaultset` | SQLite membership + UI::panel/screen/changes/moduleDefaults | group admin | UITest + StorageTest; live Telegram not run |
+| `defaultset1` | SQLite membership + UI::panel/screen/changes/moduleDefaults | group admin | UITest + StorageTest; live Telegram not run |
+| `infotimes` | SQLite membership + UI::panel/screen/changes/moduleDefaults | group admin | UITest + StorageTest; live Telegram not run |
+| `infomessages` | SQLite membership + UI::panel/screen/changes/moduleDefaults | group admin | UITest + StorageTest; live Telegram not run |
+| `TimesON` | SQLite membership + UI::panel/screen/changes/moduleDefaults | group admin | UITest + StorageTest; live Telegram not run |
+| `TimesOFF` | SQLite membership + UI::panel/screen/changes/moduleDefaults | group admin | UITest + StorageTest; live Telegram not run |
+| `MessagesON` | SQLite membership + UI::panel/screen/changes/moduleDefaults | group admin | UITest + StorageTest; live Telegram not run |
+| `MessagesOFF` | SQLite membership + UI::panel/screen/changes/moduleDefaults | group admin | UITest + StorageTest; live Telegram not run |
+| `parseButtonsold` | Content/TemplateEditor + TemplateRepository + TemplateRenderer | group admin | Fake Telegram template tests; live not run |
+| `parseButtons` | Content/TemplateEditor + TemplateRepository + TemplateRenderer | group admin | Fake Telegram template tests; live not run |
+| `buttonCallbacks` | Content/TemplateEditor + TemplateRepository + TemplateRenderer | group admin | Fake Telegram template tests; live not run |
+| `validateButtonsInput` | Content/TemplateEditor + TemplateRepository + TemplateRenderer | group admin | Fake Telegram template tests; live not run |
+| `openMessageGroup` | Content/TemplateEditor + TemplateRepository + TemplateRenderer | group admin | Fake Telegram template tests; live not run |
+| `closeGroupMessage` | Content/TemplateEditor + TemplateRepository + TemplateRenderer | group admin | Fake Telegram template tests; live not run |
+| `backGroupMessage` | Content/TemplateEditor + TemplateRepository + TemplateRenderer | group admin | Fake Telegram template tests; live not run |
+| `GroupTextSet1` | Content/TemplateEditor + TemplateRepository + TemplateRenderer | group admin | Fake Telegram template tests; live not run |
+| `RemoveText1` | Content/TemplateEditor + TemplateRepository + TemplateRenderer | group admin | Fake Telegram template tests; live not run |
+| `RemoveText1_1` | Content/TemplateEditor + TemplateRepository + TemplateRenderer | group admin | Fake Telegram template tests; live not run |
+| `GroupTextView1` | Content/TemplateEditor + TemplateRepository + TemplateRenderer | group admin | Fake Telegram template tests; live not run |
+| `GroupMsgMedia1` | Content/TemplateEditor + TemplateRepository + TemplateRenderer | group admin | Fake Telegram template tests; live not run |
+| `GroupMsgMediaView1` | Content/TemplateEditor + TemplateRepository + TemplateRenderer | group admin | Fake Telegram template tests; live not run |
+| `RemoveMedia1` | Content/TemplateEditor + TemplateRepository + TemplateRenderer | group admin | Fake Telegram template tests; live not run |
+| `RemoveMedia1_1` | Content/TemplateEditor + TemplateRepository + TemplateRenderer | group admin | Fake Telegram template tests; live not run |
+| `buttonsmanageviewgroup1` | Content/TemplateEditor + TemplateRepository + TemplateRenderer | group admin | Fake Telegram template tests; live not run |
+| `GroupMsgButtonSet1` | Content/TemplateEditor + TemplateRepository + TemplateRenderer | group admin | Fake Telegram template tests; live not run |
+| `RemoveButtons1` | Content/TemplateEditor + TemplateRepository + TemplateRenderer | group admin | Fake Telegram template tests; live not run |
+| `RemoveButtons1_1` | Content/TemplateEditor + TemplateRepository + TemplateRenderer | group admin | Fake Telegram template tests; live not run |
+| `HandleGroupMsgSet1` | Content/TemplateEditor + TemplateRepository + TemplateRenderer | group admin | Fake Telegram template tests; live not run |
+| `view_welcomeMessage_full` | Content/TemplateEditor + TemplateRepository + TemplateRenderer | group admin | Fake Telegram template tests; live not run |
+| `StatsGroups` | SQLite group/service/lock statistics | read-only | Storage fixture assertions |
+| `shabatCronClose` | Runtime/Application::tick and Scheduling lock/outbox state | runtime | CalendarTest + SchedulingTest |
+| `shabatCronOpen` | Runtime/Application::tick and Scheduling lock/outbox state | runtime | CalendarTest + SchedulingTest |
+| `shabatCronAlert` | Runtime/Application::tick and Scheduling lock/outbox state | runtime | CalendarTest + SchedulingTest |
+| `Payments` | Retained Stars donation and payment update handlers | personal / system admin | Source reviewed; real payment not run |
+| `onupdateBotPrecheckoutQuery` | Retained Stars donation and payment update handlers | personal / system admin | Source reviewed; real payment not run |
+| `onUpdateNewMessage` | Retained Stars donation and payment update handlers | personal / system admin | Source reviewed; real payment not run |
+| `closeGroupManually` | Authorized manual permission reconciliation with durable snapshots | system admin | Fake-gateway permission tests; live not run |
+| `openGroupManually` | Authorized manual permission reconciliation with durable snapshots | system admin | Fake-gateway permission tests; live not run |
+| `testzmanim` | CalendarService + Runtime/Application diagnostics | calendar / operator | CalendarTest; live diagnostics not run |
+| `setcommands` | Retained bot/operator integration | system admin / runtime | Live Telegram not run |
+| `getAdminKeyboard` | Retained bot/operator integration | system admin / runtime | Live Telegram not run |
+| `admincommand` | Retained bot/operator integration | system admin / runtime | Live Telegram not run |
+| `addsohe1hazor` | Retained bot/operator integration | system admin / runtime | Live Telegram not run |
+| `addsohe1hazor2` | Retained bot/operator integration | system admin / runtime | Live Telegram not run |
+| `getPlugins` | Retained bot/operator integration | system admin / runtime | Live Telegram not run |
+| `getPluginPaths` | Retained bot/operator integration | system admin / runtime | Live Telegram not run |
+| `reshimamishtamshim` | Retained bot/operator integration | system admin / runtime | Live Telegram not run |
+| `reshimamishtamshim2` | Retained bot/operator integration | system admin / runtime | Live Telegram not run |
+| `reshimamishtamshim3` | Retained bot/operator integration | system admin / runtime | Live Telegram not run |
+| `var_cat_command` | Retained bot/operator integration | system admin / runtime | Live Telegram not run |
+| `adnimhandle` | Retained bot/operator integration | system admin / runtime | Live Telegram not run |
+| `StatsUsers` | Retained bot/operator integration | system admin / runtime | Live Telegram not run |
+| `closeBroadcastMsg` | Retained bot/operator integration | system admin / runtime | Live Telegram not run |
+| `broadcastCommand` | Retained bot/operator integration | system admin / runtime | Live Telegram not run |
+| `deleteLastBroadcast` | Retained bot/operator integration | system admin / runtime | Live Telegram not run |
+| `deleteAllBroadcast` | Retained bot/operator integration | system admin / runtime | Live Telegram not run |
+| `cancelPinned` | Retained bot/operator integration | system admin / runtime | Live Telegram not run |
+| `LastBrodDATA` | Retained bot/operator integration | system admin / runtime | Live Telegram not run |
+| `setBroadcast` | Retained bot/operator integration | system admin / runtime | Live Telegram not run |
+| `handlebroadcast1` | Retained bot/operator integration | system admin / runtime | Live Telegram not run |
+| `hazarashidur` | Retained bot/operator integration | system admin / runtime | Live Telegram not run |
+| `addsoheshidur1forneitza1` | Retained bot/operator integration | system admin / runtime | Live Telegram not run |
+| `addsoheshidur1forneitza2` | Retained bot/operator integration | system admin / runtime | Live Telegram not run |
+| `broadsetsenders` | Retained bot/operator integration | system admin / runtime | Live Telegram not run |
+| `broadsetsenders1` | Retained bot/operator integration | system admin / runtime | Live Telegram not run |
+| `broadsetsenders2` | Retained bot/operator integration | system admin / runtime | Live Telegram not run |
+| `broadsetsenders3` | Retained bot/operator integration | system admin / runtime | Live Telegram not run |
+| `broadsetsenders4` | Retained bot/operator integration | system admin / runtime | Live Telegram not run |
+| `hosafkaf` | Retained bot/operator integration | system admin / runtime | Live Telegram not run |
+| `handlebuttons` | Retained bot/operator integration | system admin / runtime | Live Telegram not run |
+| `parseButtons` | Content/TemplateEditor + TemplateRepository + TemplateRenderer | group admin | Fake Telegram template tests; live not run |
+| `buttonsmanageview` | Retained bot/operator integration | system admin / runtime | Live Telegram not run |
+| `loadButtons` | Retained bot/operator integration | system admin / runtime | Live Telegram not run |
+| `buttonsmanageview2` | Retained bot/operator integration | system admin / runtime | Live Telegram not run |
+| `loadButtons` | Retained bot/operator integration | system admin / runtime | Live Telegram not run |
+| `comingsoon` | Legacy placeholder omitted from new navigation | none | No new inactive menu button |
+| `RunBot` | Runtime/Runner + configured error event handler and graceful stop | runtime | Runner/patch tests; no live bot startup |
+
+## Additional automated integration evidence
+
+`UITemplateTest` verifies private-only previews; a distinct named-group TEST confirmation; five-minute test-send rate limiting; user and administrator rechecks; replay and forwarded callback rejection; UTF-16 formatting entity clipping including emoji; media and buttons preservation; removal of one message component without resetting the rest; independent English/Hebrew editor menus; and rollback of both token consumption and settings revision on template CAS/SQLite write failure. Its gateway deliberately rejects permission RPCs and live HTTP.
+
+`UITest` additionally verifies reply-bound GPS selection for two groups completed in reverse order, wrong-user/unreplied/non-bot prompt rejection, private-panel language versus group-content language, full/short daily views, clearly missing solar values and tefillin jobs calculated in a private preview while the module stays disabled. These fixtures do not establish live Telegram behavior.
+## dev.4 cumulative interface and administration additions
+
+These are functional adaptations of the named local reference projects, with the current bot's authorization and storage boundaries. Visual identity is Shabbat; this table does not claim byte-identical output or live-client acceptance.
+
+| Request/reference | Current implementation | Automated evidence |
+| --- | --- | --- |
+| Exact opening, categorized help, original edit-based navigation | BotController, Lang, Presentation, CallbackUiGateway | PresentationTest, UiNavigationTest, AdminCrossFlowNavigationTest |
+| Twelve supplied custom emoji IDs and native colored buttons, owner-eligibility fallback | Presentation and MadelineGateway including BroadcastManager transport | CustomEmojiSerializationTest, CustomEmojiFallbackTest, PresentationTest, TefillinAckGatewayTest |
+| Simple group settings including all tefillin fields | SettingsWizard and BotController guided picker/input/confirmation | SettingsWizardTest, GuidedSettingsTest |
+| Upcoming complete holy sequences and holiday metadata | ShabbatTimesFormatter plus UI period navigation | ShabbatOverviewTest and existing calendar/formatter tests |
+| Custom Stars amount 1-10000 and old payment preservation | Donations, DonationController, migration 011 | DonationCustomAmountTest, CustomStarsMigrationTest, DonationsTest |
+| Delegated managers and immutable configured recovery owners (newsearch-v2 style) | AdminAccessRepository, AdminController, OperatorController | AdminRolesLinksTest and native bridge navigation tests |
+| Full known subscriber profile, user/group/channel/all filters, search/paging (psy-new style) | SubscriberDirectory, SubscriberController, AdminDirectoryText | SubscriberDirectoryTest, AdminCrossFlowNavigationTest |
+| Expanded rich broadcast drafts, audiences, pin, edit-last, timing, self-destruct and confirmations | BroadcastController, OperatorDraft, AdminIntegration, BroadcastJobs | BroadcastAdvancedTest, BroadcastJobsTest, UIOperatorTest, BroadcastPatchTest |
+| System deep links and tracking-link counters | SystemLinks, TrackingLinkRepository, AdminController | AdminRolesLinksTest |
+| Six-language role-scoped command menus | CommandMenus and current per-target capability filter | CommandMenusTest |
+| Native rich custom messages and independent button modes | TemplateEditor, TemplateRepository, TemplateRenderer, Application | GuidedTemplateTest, UITemplateTest, GroupMessageButtonsIntegrationTest |
+| Default delivered-group share actions | GroupMessageButtons: original shabat/zmanim/daf inline shortcuts | GroupMessageButtonsIntegrationTest; public results use the sharer's preferences |
+| Default blue tefillin count and unique reversible user votes | TefillinAcknowledgements, group callback/cron hooks and migration 012 | TefillinAcknowledgementsTest, TefillinAckGatewayTest, GroupMessageButtonsIntegrationTest |
+| Legacy full keyboard wrapper compatibility | Recognized read-time normalization in TemplateRepository, LegacyImporter and OperatorDraft | LegacyAdminImportTest, BroadcastAdvancedTest, TefillinAcknowledgementsTest |
+
+Private group-view sharing remains a distinct authorized read-only group snapshot. Default public announcement sharing keeps the original personal inline-query semantics. Preview acknowledgement buttons are inert; only verified delivered reminders create countable message bindings.
+
+All rows have offline implementation tests; real Telegram administration, appearance, payments, sending and Docker execution remain separate isolated acceptance work. Current aggregate results are in the version-matching validation report.
+
+## dev.5 test feedback
+
+The [eleven-item report](DEV5_TEST_FEEDBACK.md) maps the latest corrections to code behavior and tests. It supersedes dev.4's period-pagination description: the Shabbat/holiday screen is now a single overview. Group command scopes contain only settings. The fully preserved native rights snapshot has22flags, while Telegram-compatible group-wide selection has21 and excludes view_messages. Detailed manual-operation diagnosis, restart/IPC lifecycle, contextual settings return, grouped statistics and guided broadcast editing are implemented without changing migrations, locked dependencies or patch files.
+
+## dev.6 follow-up corrections
+
+The current [feedback report](DEV6_TEST_FEEDBACK.md) records conservative native-permission equivalence, recovery of compatible saved conflicts, actual SDK callback routing for public statistics, and a consistent nearest-period Shabbat message for public/inline/scheduled defaults. The previous next-three-Shabbat display is superseded by one nearest Shabbat-containing period plus a current/upcoming holiday list; separate weekday Yom Tov before Shabbat retains its verified entrance and final exit. Existing calendar policy, rich custom templates, library patches and all twelve migrations remain intact. New boundary tests exercise the actual handler, not only controller helpers.
+
+## Dev.7 recovery and diagnostics
+
+The [permission recovery flow](PERMISSION_RECOVERY.md) adds conservative automatic recovery and the two explicitly requested administrator choices without discarding original snapshots. The private [testzmanim panel](DIAGNOSTICS.md) replaces JSON message flooding with editable navigation, search, filters, quick page entry and JSON-file export. Both features include all six languages and additive migrations 013-014. Prior calendar, group settings, statistics, custom messages and pinned-library behavior remain covered by the complete regression suite.
diff --git a/docs/FUNCTIONAL_COMPARISON.md b/docs/FUNCTIONAL_COMPARISON.md
new file mode 100644
index 0000000..9ee5f22
--- /dev/null
+++ b/docs/FUNCTIONAL_COMPARISON.md
@@ -0,0 +1,50 @@
+# ืืฉืืืื ืคืื ืงืฆืืื ืืืช ืืืงืจืืช ืืงืืืฆื โ 3.0.0-dev
+
+ืืืืืืฉ ืืืืฉ ืืฉืคืจ ืืช ืืชืืืฉืฉืืช ืืกืืืจื ืืืคืชืืื ืืืช ืืืืคืื ืืจืฆืคื ืฉืืช ืืื. ืืื ื ืืืง ืขื ืฉืขืื ืืืืจืง, ื ืชืื ื ืกืคืง ืืืืงืจืื ืืืืืืืช ืึพSDK ืื ืขืื. ืขืืืื ืืื ืืืฉืืจ ืื ืืื ืืช ืชืคืขืืืืช ืืื Telegram ืืืืชื, ืืืื ืืชืืืืืืช ืืคืขืืื ืืฉื ืืื ืืืืืืงืช.
+
+| ืชืืื | ืืืชื ืืืืช ืืืฉื ื | ืืืืืืฉ ืื ืืืื |
+|---|---|---|
+| ืกืืืจื ืืคืชืืื | ืืืื ืคืขืืื ืงืฆืจ ืืกืืืื ื ืืฆืืื ืืฉืืชืคืื | ืืฆื ืจืฆืื ืืื ืงืืืฆื, ืฆืืืื ืืจืฉืืืช ืืคื ื ืืงืฉื ืืืืืืช ืืชืืฆืื; ืืชืืืฉืฉืืช ืื ืืืจื ืืคืขืื ืืืืืจืช |
+| ืฉืืช ืืื ืจืฆืืคืื | ืชืืืช ืืฆืืืื ืืืืจืืขืื ืืืชืฉืืื ืืืืฆื ืืืืืื | ืจืฆืฃ ืงืืืฉื ืืืืื; ืืงืืืฆื ื ืฉืืจืช ืกืืืจื ืขื ืกืืฃ ืืจืฆืฃ ืืืืคืขื; ืืื ื ืืืืงื ืืื ืืื ืืืฆืืื ืื ืคืจื |
+| ืฉืืงืื ืืจืฉืืืช | ื ืชืื ื ืงืืฆืื ืืืจืฉืืืช ืงืืืืืช | ืฉืืืืจ ืืืืืง ืืืฆืืืื ืืืงืืจื; ืฉืื ืื ืืื ื ืื ืฆืืืื ืืกืจ ืขืืฆืจืื ืฉืืืืจ ืืืืืืื |
+| ืืืื ืืกืืื | ืจืฉืืืช ืืืืื ืงืืืขื | ืื 22 ืืืืืื ืฉื chatBannedRights ืืฉืืื ืื ืขืืื ื ืืชื ืื ืืืืืจื; ืืจืืจืช ืืืืื ืืืืืช 21, ืืืืืื send_reactions ืึพedit_rank; view_messages ื ืฉืืจ ืืคื ืฉืืื |
+| ืฉืื ืื ืืืืื ืืืื ืกืืืจื | ืืื ืคืื ื ืืื | ืืืืคื ืืืฆืืืื ืืืงืืจื, ืฉืืืจื ืฉื ืืืกืื ืืงืืืืช ืืืื ืืงืฉื ืืฉืืืืจ ืื ืืืจื ืืฉื; ืืื ืืืืขืช ืกืืืจื ืืืฉื ืจืง ืขืงื ืืืืคืช ืืกืื |
+| ืืืืงืช ืืืืขืืช ืืชืงืืคืช ืงืืืฉื | ืืื ืืืงืจืืช ืืืืฉืืช | ืืชืืื ื ืคืจืืื ืืืืจืื ืืืืืืื ืืืจืื, ืืืืืื ืืืจืืจืช ืืืื; ืชืืจ ืืืืงื ื ืคืจื ืขื ื ืืืื ืืคืืืืืืช ืื ืืกืืื ืืืืจ ืืืืื |
+| ืชืคืืืื | ืืืืืืืช ืืงืืืืืช ื ืฉืืจืืช ืืืืืื ืืืืจืื | ืชืื ืืช ืขืฉืืจื, ืืืื, ืืคืชืืจืื ืืฆืืขืื; ืืืืงืช ืืงืืืืช ืจืง ืืืืจ ืืฉืืื ืืืฉ ืืืืืช, ืขื ืืืืื ืฉื ืฉืืจืื ืืื ืืคืขืืืช |
+| ืืืืขืช ืืืื ืื | ืคืืืื ืืจืืื ืืืืจืื ืืฉืขื ืืฉื ื | ืืืงืื ืืืืืจ ืืืื ืฉื ืืงืืืฆื ืืืื; 10:00,11:00,12:00,12:30,13:00,13:30,14:00 ืื ืฉืขื ืืืชืืืช |
+| ืชืืื ืืืืขืช ืืืื ืื | ืชืืื ืืืืื ืืคื ืืคืืืื | ืชืืจืืืื ืืืจืืืื ืืขืืจืืื, ืื ืืกื ืืืฆืืื, ืกืืืจื ืืคืชืืื, ืื/ืฉืืช ืจืฆืืคืื ืืืื ื ืืื ืืื, ืคืจืฉื ืืืืจืืื ืื ืชืื ื JSON ืืฉืืขืืชืืื ืืฉืกืืคืงื |
+| ืฉืคืืช | ืืืืฉืง ืืืงืืจื | ืขืืจืืช, ืื ืืืืช, ืจืืกืืช, ืฆืจืคืชืืช, ืกืคืจืืืช ืืคืืจืืืืืืช ืืจืืืืืืช; ืฉืคืช ืงืืืฆื ื ืคืจืืช ืืืขืืคืืช ืืืฉืชืืฉ |
+| ืชืืจืื ืืขืืฆื ืืืืืขื | ืืชืืืื ืืกืจื ืืืขืืคืช ืืืืืขืืช ืฉื MadelineProto ืื ืืืจืช | ืืขืืคืช ืืืคืืกืืช ืืฉืืืืจ ืืฉืืช ืืชืืจืื ืืงืืื ืืืฉืืืื; ืืื ืฉืื ืื ืืืืฉืื ืืื ื ืฉืืช ืืื |
+
+## ืืฉืืขืืช ืฉืชื ืืืจืฉืืืช
+
+ืืืืื chatBannedRights ืืขืจื true ืคืืจืืฉื ืืกืืื. send_reactions ืืื ืข ืืืืจ ืืฉืืื ืชืืืืช ืืืืืืณื ืืืืืขื. edit_rank ืืื ืข ืืืืจ ืืขืจืื ืืช ืืชื ืืืืฉื ืฉืื ืืงืืืฆื. ืืกืจืช ืืื ืืจืฉืืืช ืืืกืืื ืฉืืืจืช ืืช ืขืจืื ืืฆืืืื ืืืงืืจื; ืืื ืืื ื ืืขื ืืงื ืืืืืืืืช ืืจืฉืื ืฉื ืืกืื ืืืืชืืืื.
+
+ืืจืฉืืืช ืื ืืืื ืื ืื ืื ืื Telegram ื ืคืจื. manage_ranks ืืืคืฉืจ ืฉืื ืื ืชืืื ืฉื ืืืจืื; ืืกืืืช edit_rank ืืืืจืื ืืื ื ืืกืืจื ืืจืฉืืช ืื ืื ืื. ืื ืชืืืืืช ืื ืืืืขืืช ืฉื ืื ืืืื ืืื ื ืืืืืืืช ืืืืืกื ืืืืฆืขืืช ืืจืฉืืืช ืืจืืจืช ืืืืื ืฉื ืืืืจืื. ืืืืื ืืืื ืืฆืื ืืช ืื 17 ืืืื chatAdminRights, ืืื ืงืืืื, ืืืจืื ืื ืฉืื ืื ืชืคืงืืืื ืืืืืืื. ืืืืจื ืฉื ืื ืืืืืื ืืื ื ืืืืืื ืฉืื ืกืื ืงืืืฆื ืืงืื ืื ืฉืืืื: ืืืฉืืจ Telegram ืืืืืืช ืืงืจืืื ืืืืืจืช ื ืฉืืจืื ืชื ืื ืืืฆืืื.
+
+ืืงืืจ ืืืืืจืืช: [ืืจืฉืืืช ืืกืืื](https://core.telegram.org/constructor/chatBannedRights), [ืืจืฉืืืช ืื ืื](https://core.telegram.org/constructor/chatAdminRights).
+
+## ืืืื ืืืช ืืืืงื ืืคืืขื
+
+ืืืืืงื ืืืคืืช ืืขืืืื ืื ืจืืืืื ืฉืืืื ืงืืื ืืงืืืฆืืช ืจืฉืืืืช ืืคืขืืืืช, ืืืจื ืื ืืกืช ืืงืืืฉื ืืืชืื ืืืชื ืจืฆืฃ ืคืขืื. ืกืืืจื ืืืงืืืช ืืคื ื ืื ืืกืช ืืงืืืฉื ืืื ื ืืคืขืืื ืืืืงื ืฉื ืืืืขืืช ืืืงืืืืช. ื ืืืงืื ืืชื ืืกืื, ืชืืจืื ืืฉืืืื ืืืงืืจื, ืืฆื ืงืืืฆื ืกืืืจ ืืงืจืืื ืืจืืื ืืืจืฉืืช delete_messages. ืคืชืืื ืืื ืืช, ืืฆื ืืืื, ืืืืื ืืืชื ืื ืฉืื ืื ืืืืจืืช, ืืฆืืื ืืืจืฆืฃ, ืคืงืืขืช ืืขืืืช ืื shutdown ืืื ืขืื ืืงืฉืช ืืืืงื ืืืฉื.
+
+ืืืืขืืช ืขืฆืืืืช ืฉื ืืืื, ืืืืขืืช ืฉืืจืืช, ืืืืขืืช ืขืจืืืืช ืฉืืงืืจื ืืคื ื ืืื ืืกื, ืฉืืืืื ืื ืื ืืืืื ืื ืืืชื ืืืืืื ืืชืืจืืืื ืืืชื ืชืงืื ืื ืืื ื ื ืืฉืืื ืืืขืืืื. ืืื ืกืจืืงืช ืืืกืืืจืื. ืืจืฉืื ืืกืจื, ืืืืืช ืงืฆื ืื ืืฉื ืชืืืืจื ื ืฉืืจืื ืืชืืจ ื ืคืจื, ืขื ืืฉืืื ืืืกืคืจ ื ืืกืืื ืืช ืืืืื. ืืืชืื ืฉืืืืขื ืื ืชืืืืง ืื ืืื ืืจืฉืื, ืืื ืขืืืื, ืื ืฉืืชืงืืคื ืืกืชืืืื ืืคื ื ืื ืืกืืื ืืื.
+
+ืืืืืงืช ืืืืขืืช ืืืืืืช ืฉื ืืืืื ืืืจืื ื ืืจืฉืช ืืกืืจืช ืืขืืืื ืื ืึพTelegram: ืืฆื Bot-to-Bot Communication ืืืื ืืืงืื, ืืจืฉืืช ืื ืื ืืืืืื Group Privacy ืึพBotFather. ืืคืขืืช ืืืชื ืืคืื ื ืืื ื ืืฉื ื ืืช BotFather. ืจืื [ืืชืืขืื ืืจืฉืื](https://core.telegram.org/api/bots/bot-to-bot).
+
+ืืชืคืืืื ื ืฉืืจืช ืืืืืขื ืืืืจืื ื ืฉื ืฉืืื ืืืฆืืื. ืืืืจ ืืืืขื ืืืฉื ื ืฉืืจืื ืืืืื ืืจืง ืืืืืื ืืงืืืืื ื ืื ืกืื ืืืืืงื. ืืฉื ืืืืืงื ืื ืืฉืืืจืช ื ืชืื ื ืื ืืงืื ืืื ื ืืืืืจ ืืืืขื ืืืืืชืช ืืชืืจ ืืฉืืืื; ื ืชืื ื ื ืืงืื ืืกืจืื ืืฉืืืืจืื ืืืชืืืื ืืืชืืขืืช. ืชืืืื ืืื ืืืืื ืืืืืชืื ืืฉืืืจื ืืช ืืืืืขื ืืงืืืืช ืืื ืื ืืฉืช ืื ืืืืืง. ืืืืื ืืืืืื ืื ืืคืฉืจืืช ืื ืืงืื ืืืื ื ืืงืื ืืืชืื. ืืืืืขื ืืืืฉื ืืืื ืช ืืืืืงื.
+
+## ื ืขืืืช MadelineProto ืืชืืจืื ืืขืืฆื
+
+MadelineProto ื ืขืืื ืึพ8.7.0 ืื ืืืจืืฉื ืืืืืืงืช ืืื ืืงืืืฅ ื ืขืืืช ืืชืืืืืช. ืืชืงื ื ืืฉืชืืฉืช ืืืจืกืืืช ืื ืขืืืืช; ืฉืื ืื ืืืื ืฉื ืืืจืกื ืืืืื ืกืงืืจืช ืืงืืจ, ืืชืืืืช ืืืืืงืืช ืชืืงืื ืื ืืืืฉ. ืืฆื ืืกืคืง ืื ืืจืกืช ืงืืึพืฉืืจืืจ, ืืืืืื ื ืฉืืจ ืืจืกืช ืคืืชืื. ืื ืขืืื ืืื ื ืืชืืืืืืช ืืชืืืืืช ืืฉืื ืื ืขืชืืื ืื ืืืืข ืึพTelegram ืื ืึพPHP.
+
+ืชืืงืื ืืชืืจืื ืืืกืืฃ ืืช ืืืืืงื ืฉืืกืจื ืืืขืืคืช ืืืืืขืืช, ืืืจืืช ืฉืืืฉืืช ืืืจ ืงืืืืช ืืกืืืช Telegram ืื ืขืืื. ืืืงืืจ ืืื ืืชืืงื ืืจืง AssertionError ืืคื ื ืืคืขืืช ืืืคื ืืืืืขืืช. ืืชืืงืื ืืฉืืจ ืืช ืืืชืืช ืืืื, ืืืงืืื UTF-16 ืืืืื ืืขืืฆืื ืืืืืช ืืื ืืช ืืืืืงื ืืืช ืืคืขื ืืืฉืืืืช. ืืื ืชืืงืื ืืืฉ ืฉื ืืืง ืืื ืืืงืืจ ืืืืืืง; ืืืงืืจืืช ืืืงืืืืื ืฉื ืืฆืื ืชืืขืื ืืื ืืืคืฆื ืขื ืืขื ืช ืจืืฉืื ืฉืื ืืืืชื. ืชืฆืืืช HTML ืืืืื ืืืฆืื ืืช ืืืงืกื ืืื ืชื ืชืืจืื ืืื ืื; ืชืฆืืื ืืืงืื Telegram ืืืืชื ืืจื ื ืืืงื. ืจืื [ืืื ืืืืงืช ืืชืืจืื ืืืจืืฉืื](MADELINE_FORMATTED_DATE.md).
+## ืืืืืืช ืืืืืงื ืืืคืจืืกื
+
+ืืืชืืื ืคืืขื ืืืืืืจ ืฉื ืึพ60 ืฉื ืืืช, ืืขืืืื ืืจืืืงืช ืขืฉืืื ืืืืกืืฃ ืืฉืืื. ืืฉื ืืกืคืง ืืืื ืื ืขืฉืื ืืขืื ืคืชืืื; ืืงืื ืฉืืืจ ืืช ืืื ืืฉืืืืจ. ืืจืืื ืืฆืืืฆื ืขืืืื ืงืืื ืืื ืฉืื ืื ืืจืฉืืืช ืืืืืช ืืืื ืฉืืืจืช ืืืืขืช ืืืขืืจ. ืืืืืงืืช ืืื ื ืืืขื ืืช ืฉืื ืืงืจื ืืื ืืื ืฉื ื ื ืืืง ืืื ืกืคืง ืื.
+
+ืื ืฉืืื ืช ืงืืืฆื ืืืืืจืฆืื ืืืืชืืืืช ื ืืืงืื ืืคื ื ืืชืืืช ืืืื. 008 ืืืกืืคื ืืงืจืืช ืืืืงื, ื ืชืื ื ื ืืงืื ืืชืื ืืช ืขืฆืืืืช ืืืืืขืช ืืืื ืื, ืืฉืืืจืช ืชืืื ืืฉืืชืฃ ืงืืื ืืขืืชืง ื ืคืจื. ืฉืืข ืืืืืจืฆืืืช ืืงืืืืืช ืื ืฉืื ื.
+
+ืืฉืจืช ืื ืงื ืืืื ืืขืืืจ ืืช ืืืืงืช ืืจืืฉืืช PHP ืืึพSQLite ืืืขืื. SQLite 3.45.2 ืืืืฉื ืืคืืชืื ืืื ื ืขืืืจ ืืช ืฉืขืจ ืืฉืืจืืจ; Docker ืขื SQLite 3.51.3 ืืืื ืื ืืจื ืืืจืฅ. ืคืจืื ืืืืงืืช ืืืจืืืื ืื ื ืืฆืืื ื[ืืื ืืืืืืฉ](IMPLEMENTATION_STATUS.md), ืืฉืืื ืืืืงื ืืฉืจืช ื ืืฆืืื ื[ืืืจืื ืืคืจืืกื ืืืืืงื](SERVER_TEST_DEPLOYMENT.md).
+
+ืื ืฉืื ื production ืื ืงืื ืืืื ืจ ืคืขืื; ืื ื ืฉืืื ืื ื ืืืงื ืืืืขืืช ืืืืชืืืช; ืื ื ืืฆืจื commit ืื tag ืืืฉืืื ืืื ืคืืจืกืื ืฉืื ืืืื.
diff --git a/docs/GROUP_SETTINGS.md b/docs/GROUP_SETTINGS.md
new file mode 100644
index 0000000..cb374c5
--- /dev/null
+++ b/docs/GROUP_SETTINGS.md
@@ -0,0 +1,177 @@
+# Group settings and personal preferences
+
+Use `/settings` in an existing registered group. Membership listed in `data/DBgroups.txt` is inserted additively into SQLite at startup; an existing group does not need `/add` again. The bot sends the authorized panel to the administrator's private chat. For an anonymous group administrator, open the provided `/start g_GROUP` private link and authenticate as your real account; the bot verifies that account against the group. **My groups** lists known database groups only and rechecks current administration, with search and paging instead of scanning every Telegram dialog.
+
+`/preferences` and the **My preferences** start button edit personal preferences. `/language` and `/lang` in private open your language selector; those commands in a group open its language selector after checking administrator permission. Hebrew, English, Russian, French, Spanish and Brazilian Portuguese are available (he/en/ru/fr/es/pt-br). Private panel labels use your language, group messages/previews use the group's language, and changing either language never changes location or calendar methods.
+
+## Panel and required reference settings
+
+Every group panel displays name, location, IANA timezone, current operation mode and next action. It has language, location/timezone, Shabbat/holidays, daily times, Daf Yomi, tefillin, Omer, message design, upcoming actions and status. The reference's four settings are implemented in group scope: **language**, **location**, **candle lighting** and **havdalah**.
+
+- Candle options are the actual 10, 15, 18, 20, 22, 30 and 40 minutes before sunset, a documented city custom, or a validated custom value. Forty minutes appears as an explicit button; no hidden extra minute is added/subtracted.
+- Havdalah is stored as method/value: 8.5 degrees, fixed 42/50/72 minutes after sunset, or validated custom minutes. The 8.5 value remains a float. Fixed 72 minutes is stated explicitly; it is not silently converted from a solar angle.
+- Closure/opening offsets are separate operational minutes before candle lighting/after havdalah. The preview separately displays candles, group close, havdalah and group open.
+- Operation modes are off, messages only, lock only, lock and messages. Shabbat and festival activity are independently configurable. Consecutive prohibited days form one continuous permission interval.
+- The times announcement is independently enabled and scheduled by local time on entry day or minutes before closure.
+
+Location preview and confirm workflows are described in [INLINE_GEONAMES.md](INLINE_GEONAMES.md). Choosing a timezone never changes coordinates. Israel/diaspora calendar convention is an independent explicit choice. **Copy my preferences** is a group-targeted preview and confirmation, not automatic synchronization.
+
+## Module controls
+
+Each new notification module starts disabled for imported/existing groups and remains disabled until explicitly enabled. Its screen offers enable/disable, validated settings, private preview and reset of that module only. Tefillin additionally allows an administrator to pause today's notifications. Reset does not reset another group's preferences or erase a permissions restoration obligation.
+
+Private previews never send to the group. Rich message design has a separate **test** action, an explicit named-group confirmation, a clear TEST prefix and five-minute group rate limit. It is the only UI test-send route; live sending is optional and must be authorized by the operator/user outside this implementation task.
+
+Choose **Settings** inside a module, then a labeled field. Preset buttons cover common times, on/off choices, formats, calendar methods and limits. **Other** asks one question for a custom value; answer with the value itself. Weekdays use translated toggle buttons followed by confirmation. Current values show readable local times, named days and translated options. A missing interval or quiet time is off; the city custom label is reserved for candle lighting. Internal tokens and English setting keys are hidden from the ordinary questions. Each change still previews the named group and requires a one-use confirmation.
+
+The original `/configure TOKEN field=value;field=value` syntax remains an optional advanced compatibility interface. Lists use commas and advanced weekday numbers use 0=Sunday through 6=Saturday. Group settings use local `HH:mm`; the selected IANA zone supplies date/DST behavior. A question opened for another group, cancelled/replaced while authorization waits, or based on an older settings revision cannot apply its old save.
+
+| Module | Supported settings |
+|---|---|
+| Shabbat/holidays | candle minutes, havdalah method/value, close/open offsets, announcement enabled/time/mode/offset, close/open messages, quiet hours, forum target topic |
+| Zmanim | local daily time, sending days, short/full format |
+| Daf | local daily time, sending days, template |
+| Tefillin | days, fixed times, interval/off, window, sunset offsets, maximum/day, minimum spacing, quiet hours, chol hamoed convention, Tisha B'Av policy, safety margin, template, previous-reminder deletion, acknowledgement toggle/custom label |
+| Omer | fixed/nightfall mode, local time, nightfall method/value, offset, ashkenaz/sephard text choice, short/full, day completion, early defer/skip, quiet offset after opening |
+
+Custom minutes must be bounded and whole; hours, weekdays, coordinates, IANA zones, template placeholders and enum choices are validated. Notification intervals are at least 5 minutes and at most 360; daily tefillin messages are bounded at 24. `off` disables the tefillin interval and optional quiet hours/forum topic. Unrecognized or repeated form fields are rejected.
+
+Daily times and Daf expose today, tomorrow, selected date and sharing. Share from a private group view uses a bound snapshot of the named group. Default Share buttons on delivered announcements retain the original public inline queries and use the opening user's preferences; they do not carry group coordinates. Daily times also have full/short display. Missing solar values are labeled unavailable rather than synthesized; fixed 42/50/72 minute nightfall is computed directly from verified sunset. Tefillin's private preview shows calculated eligible times and cutoff, even while the module is disabled, without changing its activation state.
+
+## Permissions, stale actions and recovery
+
+The bot rechecks current administrator permission for every sensitive callback and final save. Flow tokens carry settings revision; an older panel, expired token, other user, replay or forwarded button cannot change settings. Concurrent editors do not overwrite a newer revision. State is scoped to user/group/action; no shared mutable `groupid` is used for new settings.
+
+Permission snapshots are saved durably before restriction, preserved while bot-owned restriction remains active, and restored exactly. A missing/corrupt snapshot or conflicting manual permission edit is a visible issue. Routine notification replanning never deletes an unresolved permission-restoration job. The scheduler reconciles desired state on restart, including an opening missed by more than the former six-minute window.
+
+All automatic sending remains subject to current activation, settings revision and valid delivery window. Omer/Daf progression follows the requested calendar date rather than sent-message count; skipping a Shabbat message does not stop the calendar. Times depend on location, selected method and convention; this software does not provide individual halachic rulings.
+
+
+Permission RPC quota delays (including FLOOD_WAIT) are stored as UTC retry deadlines in group_lock_state.retry_at. Reads, mutations and confirmation reads respect that deadline after restart; quota retries retain the original snapshot and pending restoration. Lease owner/deadline fencing is checked after awaited RPCs and in the final confirmed-state update, so an expired/replaced owner cannot report a successful change. Current calendar/settings intent is checked again immediately before restriction or restoration, outside database transactions. If a new prohibited interval begins while a permission read is waiting, the obsolete restore is deferred with its snapshot retained. These race/recovery behaviors are covered by PermissionRetryTest with isolated SQLite databases and asynchronous fake gateways.
+
+## Example panel (simulation)
+
+```text
+Group One
+๐ Jerusalem
+๐ Asia/Jerusalem
+Mode: Lock and messages
+Next action: [calculated local datetime]
+
+๐ Language
+๐ Location and timezone
+๐ฏ Shabbat and holidays
+๐ Daily times
+๐ Daf Yomi
+๐ Tefillin reminders
+๐พ Counting the Omer
+โ๏ธ Messages and design
+๐ Upcoming actions
+๐ Status and issues
+```
+
+This is a simulation, not a production chat capture. Automated fixture evidence is linked from [FEATURE_PARITY.md](FEATURE_PARITY.md). Telegram administration checks, inline client behavior, permission RPCs, topics, actual payments and broadcasts still require opt-in staging validation.
+## Original button input compatibility
+
+Open **Messages and design**, choose the message, then **Buttons**. The simple link-button path asks for a label and URL separately. Advanced input includes concrete examples for the existing plaintext parser and JSON rows. Type the body directly into the active private question. Candidate content is previewed before explicit save, and cancellation preserves the stored design.
+
+The original parser remains a callable rather than a duplicated implementation. Its advanced syntax supports `Label - https://example.org - style:bg_success - icon:123`, same-row buttons separated by `&&`, and new lines for rows. Original `alert:text`, `popup:text` and `copy:text` actions remain compatible. Native primary/success/danger styles and existing icon IDs are validated and preserved. Invalid URLs/styles, empty parsed keyboards and callback data over 64 bytes are rejected before storage. Existing `/buttons TOKEN` and `/template TOKEN` commands remain compatibility routes.
+
+Text, entities and media edits can inherit default module buttons. An explicitly custom keyboard replaces defaults; explicitly removing buttons selects **None**. Tefillin acknowledgement settings and inherited/custom/none behavior are documented in [TEFILLIN_ACKNOWLEDGEMENTS.md](TEFILLIN_ACKNOWLEDGEMENTS.md). Private preview and TEST acknowledgement controls are inert.
+
+## Operator menus and broadcasts
+
+The private operator interface uses the same six selected languages as personal preferences. Group administrator permission does not grant system operator permission. Operator callbacks require a private chat and a fresh current database capability check; configured recovery owners remain supported, and unrelated callbacks are ignored by the profile handler.
+
+The translated menus retain subscribers and profile details, paginated lists, statistics, rich text/media drafts, custom buttons, pin options, eight audiences (users, channels, groups, all, groups/channels and three explicitly authorized chat variants), private preview, progress, pause/resume/cancel, and deletion/unpin actions. Draft text, entities, media, buttons, pin, audience and list page are scoped to each operator in SQLite. Slash commands are never swallowed by an open draft editor. Existing reviewed draft data remains importable.
+
+The send button opens a recipient-count confirmation. Its expiring one-use token binds the operator, current personal settings revision, full draft digest and selected recipients. A changed draft, another operator, replay, expiry, revoked role or stale revision cannot start that send. Before dispatch, recipients are intersected with current known eligible audiences, permission recovery is prioritized, and shutdown/current operator permission are rechecked. Private preview edits the original operator panel and preserves native button styles and reusable media. See [ADMIN_PANEL.md](ADMIN_PANEL.md) for searchable profiles, explicit audience authorization, scheduled sends, self-destruct and job cancellation.
+
+BroadcastManager's verified public controls keep their original `bm:pause/resume/cancel:ID` callback IDs behind private operator authorization. A separately packaged, version-verified local library patch provides per-instance status/control formatters; all six operator locales have real progress labels. The manager's independent delivery history and scheduled-job files remain a third-party backend under `DATA_DIR`; they require the documented stopped-backup procedure alongside the application SQLite database. No claim of exactly-once BroadcastManager delivery is made.
+
+Offline evidence is in `UIOperatorTest`: all six frames and stable callbacks, separate operators, preserved rich media/buttons/entities, slash-command escape, invalid/empty payloads, atomic SQL rollback, role loss, user mismatch, changed digest, expiry, stale revision and replay. No Telegram broadcast, live payment or live deletion was executed during implementation.
+
+## Group restrictions and message cleanup
+
+The **Group restrictions and cleanup** screen is group-only. A checked restriction means that action is blocked while the bot owns an active closure. The 21 global restriction fields from the locked Telegram layer are selectable. The full 22-field native snapshot is retained for validation and restoration. Telegram explicitly forbids `view_messages` in global default permissions ([official rights documentation](https://core.telegram.org/api/rights)); it is therefore excluded from selection and rejected before any RPC:
+
+| Native field | Member action |
+|---|---|
+| send_messages | Send messages |
+| send_media | Send media |
+| send_stickers | Send stickers |
+| send_gifs | Send GIFs |
+| send_games | Send games |
+| send_inline | Send inline results |
+| embed_links | Show link previews |
+| send_polls | Send polls |
+| change_info | Change group information |
+| invite_users | Invite users |
+| pin_messages | Pin messages |
+| manage_topics | Manage topics |
+| send_photos | Send photos |
+| send_videos | Send videos |
+| send_roundvideos | Send video notes |
+| send_audios | Send audio |
+| send_voices | Send voice messages |
+| send_docs | Send documents |
+| send_plain | Send plain text |
+| edit_rank | Edit the member's own custom tag (rank) |
+| send_reactions | Send reactions |
+
+Default selection blocks all 21 actions except viewing messages. **All**, **None** and **Default** create previews requiring final one-use confirmation. All and Default both exclude the unsupported `view_messages` flag. An older stored mask containing that flag must be reset using Default, All or None before it can be applied; it is never silently discarded. A selection never clears an existing manual restriction or replaces the original restoration snapshot. Changes and resets are isolated to the current group. The Shabbat module reset restores the default mask and switches both holy-period cleanup options off.
+
+Default permission readback is compared by meaning within narrow, verified rules: `until_date=0` and `2147483647` both mean permanent restrictions; arbitrary finite expiries stay distinct. Legacy `send_media` and `send_messages` aggregate bits are redundant only when every corresponding granular send restriction is explicitly blocked on both sides. An individual flag change, including `send_reactions` or `edit_rank`, is still a conflict. Selecting an aggregate for a new lock explicitly includes its granular restrictions. Original native snapshots and their checksums are never rewritten by this comparison.
+
+These rules follow the [official TDLib permanent-default-rights reader](https://github.com/tdlib/td/blob/ad2178896533ff92831accdf56ea7edd8393f2ca/td/telegram/DialogParticipant.cpp#L220-L228) and [granular-rights serializer](https://github.com/tdlib/td/blob/ad2178896533ff92831accdf56ea7edd8393f2ca/td/telegram/DialogParticipant.cpp#L306-L314). The pinned MadelineProto 8.7.0 adapter refreshes full peer information with a zero cache lifetime and reads the updated peer again after the response. Both basic-group and supergroup refresh paths have offline tests using the actual pinned SDK method.
+
+After upgrading from dev.5, a retained prepared/error/conflict/restoring state can recover on the next manual operation or scheduled reconciliation only if fresh current rights match the original snapshot or a known bot-owned mask under those narrow rules. No database reset or new snapshot is needed for that case. Missing/corrupt snapshots, unmatched finite expiries, unknown restrictions and meaningful administrator changes remain protected. A genuine mismatch stops automatic changes and shows a bounded list of differing permission names; it does not establish who changed them. Normalization does not authorize the bot to overwrite that mismatch. A legacy aggregate-only snapshot without explicit granular rights stays strict because its equivalence cannot be proved.
+
+Separate default-off toggles control deletion of received members' messages and received other-bot messages during an active Shabbat/festival closure. This is additional moderation, distinct from changing default member rights. It does not scan old history. Unknown/anonymous sender types are not guessed, and announcements sent by this bot are excluded. The runtime verifies current deletion rights and current closure intent before deletion; missing delivery or permissions can prevent cleanup.
+
+Telegram requires the receiving bot to be an administrator, with **Group Privacy Mode** disabled and **Bot-to-Bot Communication Mode** enabled in BotFather to receive general messages from other bots. The panel states this dependency. A bot cannot delete messages Telegram did not deliver to it. These account settings are not changed by the application or offline tests. See the [official Telegram bot-to-bot documentation](https://core.telegram.org/api/bots/bot-to-bot).
+
+The **Bot administrator rights** screen displays a read-only diagnostic of all 17 native administrator rights, current owner/admin status and effective deletion capability. The result is tied to the requested group and followed by fresh administrator authorization. Unsupported adapters, failed reads or malformed results are displayed as unavailable. Rights are granted in Telegram's group administration settings; the bot cannot promote itself. No menu button grants administrator rights.
+
+The Tefillin screen has a separate default-off **Delete previous reminder after sending a new one** switch and a direct **Messages and design** button. Text, entities, media, custom buttons and native primary/success/danger styles use the same secured rich editor. Resetting Tefillin switches its previous-reminder cleanup off without changing the holy-period cleanup switches.
+
+Stable form identifiers are lock_block_flags=send_messages,send_reactions, delete_holy_user_messages=true, delete_holy_bot_messages=true, and delete_previous=true in the Tefillin form. Duplicate/unknown flags and nonboolean values are rejected. Personal preference forms never expose or accept these group-only settings.
+
+## Shabbat/festival announcement timing and content
+
+The Shabbat screen links directly to an independent announcement screen. Presets are **10:00, 11:00, 12:00, 12:30, 13:00, 13:30 and 14:00**, interpreted in the group's IANA timezone. A preset sets local-time scheduling but does not silently enable announcements. Enablement is a separate confirmed action. For another time, choose the time field and **Other**, then answer with a local time such as **12:17**. To schedule relative to closure, select the minutes-before-closure mode and choose its offset, such as **45**. Each question supplies a preview and confirmation without a visible configure token. The optional advanced form remains compatible.
+
+The private preview uses the group's language and location. The pure ShabbatTimesFormatter renders only this group's civil/Hebrew dates, all candle-lighting times in a consecutive holy sequence, final havdalah and, for locking modes, initial close and final reopen times. Each timestamp carries its actual date and UTC offset, including across daylight-saving transitions. The configured candle offset is identified as a setting; later festival candle lighting is shown at its separately calculated time rather than described universally as before sunset.
+
+The formatter takes the exact scheduled event when provided, so an announcement for a later event cannot display a currently active holiday. Its validated provider metadata includes applicable holidays, Torah portion, Shabbat Mevarchim, Hebrew dates, provider notes and Torah/haftarah references. Unrelated dates are excluded. Missing data is explicit; no four-city schedule or fixed-region table is substituted. Provider strings are escaped and outgoing text is bounded to Telegram's UTF-16 limit. Labels are translated in all six locales; provider notes remain attributed provider content.
+
+Hebcal's documented s=on and mvch=on request Torah portions and Shabbat Mevarchim. See the [official Jewish calendar API](https://www.hebcal.com/home/195/jewish-calendar-rest-api). Calendar data remains separate from operational restrictions and the exact configured candle/havdalah calculations.
+
+Announcement design has its own times_alert rich template, separate from daily zmanim. Migration 008 copies an existing daily-times design once to retain the previous announcement appearance; subsequent edits are independent. Both announcement and Tefillin screens open their exact rich editor directly. Private preview never sends to the group; the explicit TEST route retains named-group confirmation and its rate limit.
+
+Offline coverage is in UITest, UITemplateTest and ShabbatTimesFormatterTest: all native flag labels, all six locales, group-only authorization, role loss during diagnostics, one-use/stale confirmations, independent cleanup switches/designs, all seven presets/custom hours, disabled sending after preset selection, holy sequences, exact scheduled-event selection, timezone transitions, missing data and escaped/bounded metadata. No live deletion or Telegram delivery was performed.
+
+## Navigation and presentation
+
+Callback buttons update the existing private bot panel. Opening a fresh command creates a panel; moving between categories, settings, previews and confirmation edits that panel. A callback failure is answered without sending another menu. Information categories cover personal preferences, calendar, groups, notifications, message design and privacy; private operator controls require the configured operator role.
+
+Primary blue identifies main actions and selected choices, neutral buttons provide routine navigation/information, and danger red marks cancellation, disabled states or destructive actions. User-designed primary/success/danger styles are preserved. Group-specific Shabbat previews and announcements share the grouped formatter, including actual timestamp offsets, intermediate candles, relevant calendar metadata and a visible stale-cache warning.
+
+Simple location prompts hide internal tokens; see [inline and location setup](INLINE_GEONAMES.md). The original advanced commands remain compatible.
+
+
+## Tefillin acknowledgement settings
+
+Acknowledgements are enabled by default for newly delivered tefillin reminders, while the tefillin notification module itself remains opt-in. The module settings expose an acknowledgement on/off toggle and an optional custom one-line label. The default label follows the group's selected language (Hebrew: **ืื ืืชื**); the per-message count is added automatically. A custom label is limited to 48 UTF-16 units and may be reset to the language default.
+
+Users toggle their own mark on an actual delivered reminder. Redelivery of the same callback is idempotent; another deliberate click can remove the mark. State survives restart. The service changes only reply markup and preserves the reminder text, entities, media and other buttons. Verified group/message delivery IDs and the current group's activation/settings guard each counter. Preview/TEST controls do not create a votable round. These are self-reported marks.
+
+A custom keyboard and **None** override inherited acknowledgement buttons. See [TEFILLIN_ACKNOWLEDGEMENTS.md](TEFILLIN_ACKNOWLEDGEMENTS.md) for persistence, transport retries, concurrency and explicit button modes.
+
+## Dev.7 permission recovery
+
+The main group panel and Shabbat/permission categories include **Resolve permission problem**. A private named-group preview compares current/original permissions and offers an explicit choice to adopt the current normal state or restore the preserved original. Each action requires fresh group-admin authority, a five-minute single-use confirmation and unchanged settings/state/remote rights. Active religious or operational closure, manual closure, unavailable calendar verification and shutdown block recovery. See [permission recovery](PERMISSION_RECOVERY.md).
+
+Automatic recovery preserves only verified known results, proven never-dispatched first attempts and additional restrictions on known fields untouched by the bot. Original snapshot bytes remain available. Ambiguous bot-controlled, relaxed, unknown or expiry differences retain administrator review; a new week alone cannot reset them.
+
+Current default Shabbat/holiday output uses local `Y-m-d H:i` without a numeric UTC suffix or source/update footer, as described in [calendar presentation](CALENDAR_PRESENTATION.md). This supersedes older presentation notes above; internal timezone/freshness metadata remains available in diagnostics.
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
new file mode 100644
index 0000000..db834ea
--- /dev/null
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -0,0 +1,41 @@
+# Implementation and acceptance status
+
+Version: **3.0.0-dev.9**. This document describes the current prerelease source, not a production acceptance certificate. The application remains MadelineProto 8.7.0, PHP and SQLite. The publication follow-up updates Composer build tooling, protects immutable file bytes and documents deployment; it does not change bot behavior.
+
+## Implemented paths and limits
+
+| Area | Current implementation | Boundary / acceptance still needed |
+|---|---|---|
+| Shabbat and Yom Tov | Location/timezone-specific calendar; consecutive protected days; configurable candles/havdalah and operational offsets | Provider evidence must be valid; high-latitude missing solar times are not guessed; real timing/readback acceptance remains necessary |
+| Group permissions | Fresh native permission capture, durable original snapshot, guarded close/open, conflict recovery panel | Old unverified snapshots require administrator review; Telegram cannot make concurrent administrator edits atomic with the bot |
+| Group enrollment | `/add`, `/settings`, legacy group-list compatibility and explicit full import | `/add` creates a new group in off mode; first registration through `/settings` currently inherits lock mode. Review the shown mode before enabling a new real group |
+| Calendar views | Nearest Shabbat, upcoming dates and Yom Tov entry/exit, daily zmanim, Daf Yomi and Omer | Provider text and Hebrew ritual text are not completely translated into every interface language |
+| Scheduled content | Per-group opt-in daily modules, fixed/interval/solar tefillin reminders and Omer policies | Personal preferences supply views/settings, not automatic private reminder subscriptions |
+| Delivery safety | Final dispatch eligibility, ordinary holy-period quiet, actual spacing/caps, durable uncertainty, bounded backlog | A configured closing notice is an intentional exception; an already-dispatched network request may arrive after the local boundary |
+| Group presentation | Rich templates with entities/media, independent keyboards, share defaults, tefillin acknowledgement toggles | Telegram/client support, custom emoji eligibility and live media rendering require testing; fallback handles supported definite failures |
+| Moderation | Configurable restriction flags and optional received-message cleanup | Other-bot visibility depends on Telegram delivery rules, BotFather mode/privacy and admin state; deletion also needs delete permission |
+| Languages | Six matching catalogs and localized menus/panels | Key/placeholder parity is not native-speaker certification; provider errors, technical details and liturgy can retain source-language text |
+| Location | Guided inline city/ID/coordinates/timezone/GPS flow with named-context confirmation | GeoNames account/quota needed for remote search/automatic timezone; sharing/search alone never saves settings |
+| Administration | Delegated roles, subscriber directory, statistics, guarded broadcasts, system/tracking links and paged diagnostics | Networked sends, command installation, permission operations and restart have explicit authorization/side effects |
+| Donations | Stars offers and custom 1-10,000 amount flow with payment validation/ledger | Real invoice/payment acceptance has not been established by offline tests |
+| Scheduling and load | Independent permission/notification/broadcast lanes, four permission workers, bounded cycles, backoff/circuit cooldown | Synthetic load is not a Telegram capacity guarantee; concurrent slow native reads can occupy all slots |
+| Persistence | 16 checksum-tracked migrations, explicit backup/import/restore, durable jobs and permission evidence | Startup validates schema rather than migrating; rollback needs compatible code, DB, session and library state |
+| Maintenance | Bounded routine-history cleanup, separate heartbeats, queue metrics and explicit deep integrity check | Retained uncertain/recovery/interactive state may grow; operators still need monitoring, backups and archival policy |
+
+## Verification policy
+
+The existing test suites exercise fake transports, provider fixtures, controlled clocks, isolated databases, migration/restore, patch lifecycle and simulated load. The publication checks must be run against the final isolated source and its own locked Composer installation. Original dev.9 test results are historical evidence and are not automatically attributed to a modified publication tree.
+
+A delivered `checks/PUBLICATION_REPORT.md` and machine-readable report identify the exact publication archives, source fingerprints, commands, actual results, skips and blockers. Those reports are separate delivery artifacts, not runtime source. The [publication status](PUBLICATION_STATUS.md) explains archive scope.
+
+Local Windows limitations include unavailable Docker and POSIX-only/symlink-dependent test coverage. The local PHP-loaded SQLite 3.45.2 is deliberately rejected by the release gate; the Docker target pins SQLite 3.51.3 and must pass that gate. Do not describe an unexecuted CI run, image build or Telegram operation as successful.
+
+## Required live acceptance
+
+Before production cutover, use a separate bot and disposable groups to verify image startup, exact original-permission restoration, conflicts/recovery, automatic transitions, restarts and shutdown, inline/GPS/client behavior, source offer and payment flow where enabled. Exercise both forum and non-forum groups if used. Verify that the correct existing production data is imported; do not substitute the test bot's database.
+
+GitHub publication is separately authorized and gated on the reviewed source and CI. It does not change production, restart the active bot or send Telegram messages.
+
+## Documentation map
+
+[English README](../README.md) ยท [ืขืืจืืช](../readme-he.md) ยท [Installation](INSTALLATION.md) ยท [Configuration](CONFIGURATION.md) ยท [Group settings](GROUP_SETTINGS.md) ยท [Calendar methods](ZMANIM_METHODS.md) ยท [User experience](USER_EXPERIENCE.md) ยท [Administration](ADMIN_PANEL.md) ยท [Migration](MIGRATION.md) ยท [Backup/restore](BACKUP_RESTORE.md) ยท [Delivery safety](DEV9_DELIVERY_SAFETY.md) ยท [Permission recovery](PERMISSION_RECOVERY.md) ยท [Diagnostics](DIAGNOSTICS.md) ยท [Licensing](LICENSING.md) ยท [Release preparation](RELEASE.md)
diff --git a/docs/INLINE_GEONAMES.md b/docs/INLINE_GEONAMES.md
new file mode 100644
index 0000000..329aedb
--- /dev/null
+++ b/docs/INLINE_GEONAMES.md
@@ -0,0 +1,45 @@
+# Simple location selection and inline search
+
+GeoNames supplies place records and IANA timezone identifiers. Hebcal remains the calendar/solar source. Search never replaces a timezone with a current numeric offset, invents solar times, or saves a place merely because an inline result was shared.
+
+## Operator setup and diagnosis
+
+Set `GEONAMES_USERNAME` to your own [GeoNames](https://www.geonames.org/login) account with web services enabled. Empty and `demo` usernames are rejected. Keep the private environment file out of Git/images/archives. The default endpoint is verified HTTPS `https://secure.geonames.org`; TLS verification is not disabled.
+
+Enable the test bot's inline mode with `/setinline` in BotFather, selecting the actual test bot and a friendly placeholder such as โCity, Shabbat, daily times or Dafโ. [Telegram documents this prerequisite](https://core.telegram.org/bots/inline). City search does not require inline location permission or chosen-result feedback.
+
+If Telegram never opens the inline results list, check BotFather inline mode. If the bot shows provider unavailable/configuration missing, check the enabled GeoNames username, account quota, endpoint/network and bot logs privately. HTTP-200 provider status errors are failures rather than empty city lists. Do not publish logs containing credentials.
+
+The old handler recognized `loc CITY` but returned no results for a bare city name. The revised handler also accepts `@BOT_USERNAME ืืจืืฉืืื` and other plain city names. This was verified with fixtures; no live account or client result is claimed.
+
+## Setting a named group or personal location
+
+1. Open **My preferences**, or **My groups โ selected group โ Location and timezone**. Group authority is checked again.
+2. Choose city search, place identifier, coordinates, timezone or GPS. The prompt names the current destination. Workflow tokens stay in server state and button data.
+3. Follow the short prompt: type a city, a positive GeoNames ID, coordinates with an IANA zone, or a timezone search. City inline buttons insert a simple `location ` prefix; type the city after it.
+4. Select a result and inspect the named destination, coordinates, place ID, IANA zone and current UTC offset. Location/calendar convention remain separate settings.
+5. Confirm saving. One-use confirmation binds user, destination, action, revision and expiry; the bot revalidates selected provider data and group administration.
+
+A newly opened natural-input prompt replaces the previous natural-input prompt for that user. Expired prompts and changed settings cannot save. Use the displayed destination before confirmation; navigating another group does not retarget an existing result. The original tokenized commands remain compatibility routes for advanced workflows.
+
+Timezone-only selection preserves coordinates and warns about geographic mismatch. Coordinates with an explicit IANA zone do not need GeoNames; automatic zone resolution does. Curated locations remain available if city search is unavailable.
+
+## Sharing GPS safely
+
+Open the GPS action, then use Telegram **Reply** on that exact bot prompt and attach a location. The simplified prompt's authenticated private message ID is stored with its user, destination, revision and expiry; no long token must be copied. The raw bridge verifies the actual bot sender and private dialog. A random/unreplied location, cross-dialog quote, stale prompt or lost group role is rejected. The existing legacy `GPS TOKEN` reply route remains supported.
+
+## Public inline results and bounds
+
+Bare city or `loc CITY` searches are public read-only information, with city/country/region and GeoNames attribution. They never inherit a group target or save settings. A `location CITY` setup search requires the active owned private city prompt; its result carries a destination-bound choice and explicit private confirmation. Group calendar shares use separate read-only snapshot flows.
+
+GeoNames searches use provider-supported languages he/en/ru/fr/es, with pt-br mapped to pt, normalized UTF-8, ten-result pages and bounded one-hour caches. Selected-place details are fetched only on selection; timezone lookup occurs when necessary. Per-user uncached requests are throttled; cancellation and shared HTTP requests avoid busy retries. The provider's [terms](https://www.geonames.org/export/) and [status errors](https://www.geonames.org/export/webservice-exception.html) apply.
+
+Localized public results use a personal cache; bound results use cache_time=0 and private=true. Recognized calendar queries remain shabat/shabbat/ืฉืืช/zmanim/daf/omer. Public and localized outputs are separated appropriately so Telegram cannot share one user's setup context with another.
+
+## Attribution and evidence
+
+GeoNames data is attributed under CC-BY-4.0; see [upstream readme](https://download.geonames.org/export/dump/readme.txt) and [third-party notices](../THIRD_PARTY_NOTICES.md). No provider logo is reused.
+
+Location, UI navigation, flow and GPS bridge tests use isolated SQLite state and fake Telegram/provider transport. They cover wrong users, expiry, stale revision, admin loss, public sharing, exact reply ownership, callbacks and explicit confirmation. Real inline/client/GPS delivery remains a staging acceptance check.
+
+Public timezone search accepts `tz Jerusalem` or an exact identifier such as `Asia/Jerusalem`. The timezone setup button uses `timezone ` with the active private timezone prompt; confirmation changes only the timezone. GeoNames IDs and coordinates are supported through the simple private buttons and reply-bound GPS action.
diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md
new file mode 100644
index 0000000..e97daa1
--- /dev/null
+++ b/docs/INSTALLATION.md
@@ -0,0 +1,102 @@
+# Installation
+
+[English overview](../README.md) | [ืขืืจืืช](../readme-he.md) | [Configuration](CONFIGURATION.md) | [Migration](MIGRATION.md)
+
+This is the `3.0.0-dev.9` development snapshot. Install the reviewed source package or matching checkout, rather than assuming the public default branch already contains it. Installation does not imply that Docker or Telegram acceptance has passed for your host. See [publication status](PUBLICATION_STATUS.md).
+
+## Requirements and command context
+
+Run the following commands in the unpacked project root on Linux. Docker commands require access to the Docker daemon and Compose v2; that access is privileged. PHP maintenance commands run as the image's non-root user. The only Compose service is `shabbat`; there is no `composer` service.
+
+The Dockerfile pins PHP 8.4.19 and Composer 2.10.3 images by digest and builds checksum-verified SQLite 3.51.3. It installs dependencies from `composer.lock`, applies and verifies the five local patch sets before startup, and runs without root as UID/GID 10001. Image building uses network access but no bot credentials. CI uses the same Composer version, including the upstream [GitHub Actions token-validation and disclosure fix](https://github.com/composer/composer/security/advisories/GHSA-f9f8-rm49-7jv2); authentication and all validation gates remain enabled. Runtime requires a separate configured bot, Telegram access and calendar provider access.
+
+Manual installation requires 64-bit PHP 8.3, 8.4 or 8.5, Composer 2, the extensions required by `composer check-platform-reqs`, and a patched PHP-loaded SQLite version accepted by `php bin/self-check.php --release`. That gate accepts SQLite >=3.51.3 or the explicitly reviewed 3.44.6/3.50.7 backports. A newer standalone `sqlite3` executable does not change the library loaded by PHP PDO. The image is the documented deployment baseline; native process supervision is operator-managed.
+
+## New isolated Docker installation
+
+Use a new Compose project name and a separate test bot. The commands below use `shabbat-test`. Keep an existing deployment's project name unchanged to keep using its named volumes.
+
+```sh
+cp .env.example .env
+chmod 600 .env
+```
+
+Edit `.env` privately before continuing. Configure `API_ID`, `API_HASH`, `BOT_TOKEN`, trusted `ADMIN` IDs, the optional GeoNames account and the exact deployed `SOURCE_URL`. See [all environment variables](CONFIGURATION.md). The example uses plain `KEY=value` lines that both Compose and PHP's raw INI reader can read. Keep explanatory comments in the documentation rather than mixing incompatible comment syntax in the shared file.
+
+```sh
+docker compose -p shabbat-test config --quiet
+docker compose -p shabbat-test build shabbat
+docker compose -p shabbat-test run --rm --no-deps shabbat php scripts/verify-patches.php
+docker compose -p shabbat-test run --rm --no-deps shabbat php bin/self-check.php --release
+docker compose -p shabbat-test run --rm --no-deps shabbat php bin/runtime-permissions.php --require-non-root
+```
+
+These commands validate configuration/build/runtime; they do not log in to Telegram. `config --quiet` avoids printing resolved credentials. For a new database with all writers stopped:
+
+```sh
+docker compose -p shabbat-test run --rm --no-deps shabbat php bin/migrate.php --writers-stopped --db /app/data/shabbat.sqlite
+docker compose -p shabbat-test run --rm --no-deps shabbat php bin/check-calendar.php
+```
+
+Migration creates the schema in the persistent data volume. The calendar diagnostic contacts public providers but does not start the bot, read its private session or send Telegram messages. For an existing database, use the backup-required procedure below instead of the fresh-database command.
+
+The next command starts the configured bot and enables its configured Telegram actions:
+
+```sh
+docker compose -p shabbat-test up -d shabbat
+docker compose -p shabbat-test logs --tail=120 shabbat
+```
+
+After a complete scheduler cycle, check the running instance:
+
+```sh
+docker compose -p shabbat-test exec -T shabbat php bin/health.php --deep
+docker compose -p shabbat-test ps
+```
+
+Health requires recent permission and notification heartbeats, as well as database/schema checks. An offline database alone cannot produce a successful live-health result. `--deep` adds integrity and foreign-key scans; use the ordinary probe for frequent monitoring.
+
+## Updating an existing SQLite deployment
+
+Run as the operator from the new matching source root, retaining the existing private `.env`, Compose project name and volumes. Stop all writers before patching or migration. Do not reuse this procedure as a substitute for [file-based legacy import](MIGRATION.md).
+
+```sh
+docker compose -p shabbat-test stop shabbat
+docker compose -p shabbat-test build shabbat
+docker compose -p shabbat-test run --rm --no-deps shabbat php bin/self-check.php --release
+docker compose -p shabbat-test run --rm --no-deps shabbat php -r 'if (is_dir("/app/data/backups") === false && mkdir("/app/data/backups", 0700, true) === false) { exit(1); }'
+docker compose -p shabbat-test run --rm --no-deps shabbat php bin/migrate.php --writers-stopped --backup-dir /app/data/backups --db /app/data/shabbat.sqlite
+docker compose -p shabbat-test up -d --force-recreate shabbat
+```
+
+Migration verifies a backup before changing an existing database and rejects modified migration checksums. Startup validates the schema; it never silently migrates it. Keep independent private backups of the session and third-party runtime state as described in [backup and restore](BACKUP_RESTORE.md). Never remove named volumes as an upgrade step.
+
+## Manual installation and no-scripts mode
+
+Run as an unprivileged application owner in a separate checkout with writable private data/session directories. Use the supported PHP runtime, not a running bot's dependency tree.
+
+```sh
+composer validate --strict --no-check-all
+composer install --no-dev --no-scripts --no-interaction --prefer-dist --optimize-autoloader
+composer check-platform-reqs
+php scripts/apply-patches.php
+php scripts/verify-patches.php
+php bin/self-check.php --release
+```
+
+`--no-scripts` requires both explicit patch commands before startup. Ordinary `composer install` runs these hooks automatically; never apply patches while the bot is running. A dependency mismatch or partially patched file must stop installation. Normal upgrades use `composer install` with the existing lock; dependency updates are a separate reviewed maintenance task.
+
+Configure `.env`, create the fresh database while writers are stopped, then start only the intended bot:
+
+```sh
+php bin/migrate.php --writers-stopped --db data/shabbat.sqlite
+php bot.php
+```
+
+`php bot.php` is a foreground networked process. It connects to Telegram and may act on registered groups. Use your own process supervisor for long-running native deployments. The [English](../README.md) and [Hebrew](../readme-he.md) guides list the operation commands and their effects.
+
+## Source identity and production acceptance
+
+Docker build identity requires either an explicit 40-hex `BUILD_COMMIT` from the actual reviewed checkout or the retained archive `build-info.json`. The release deployment ZIP includes the actual release commit, `local_changes=false` and its exact application-source fingerprint. Earlier local preparation archives instead record their historical baseline and `local_changes=true`; those are distinct artifacts. Retain archive metadata unchanged when building from a ZIP; for a reviewed Git checkout export `BUILD_COMMIT="$(git rev-parse HEAD)"` before building. A baseline commit alone does not identify unpublished changes. For an existing legacy bot, use [the stopped-writer production cutover guide](PRODUCTION_UPGRADE.md).
+
+Before replacing a live bot, test the image build, exact permission restoration, restart recovery, inline/GPS flows, supported clients, command scopes and donation flows with a separate bot/group. Preserve and rehearse rollback. No installation command should modify real groups as an implicit test.
diff --git a/docs/LICENSING.md b/docs/LICENSING.md
new file mode 100644
index 0000000..1a3d1d3
--- /dev/null
+++ b/docs/LICENSING.md
@@ -0,0 +1,36 @@
+# Licensing and corresponding source
+
+WizardLoop Shabbat uses **AGPL-3.0-only** for the current combined application, as declared by `composer.json` and original application SPDX headers. [LICENSE](../LICENSE) is the complete, unchanged GNU AGPL version 3 text. [The historical MIT grant](../LICENSES/SHABBAT-LEGACY-MIT.txt) retains the original WizardLoop copyright and permission notice; the current policy does not revoke earlier grants or offer the whole new application under an alternative MIT license.
+
+## Preserve component grants
+
+Dependencies, imported fragments and patch baselines keep their original copyright and license notices. MadelineProto's package metadata declares `AGPL-3.0-only`; its source headers and the added formatted-date wrapper permit version 3 or later. These texts are retained unchanged and their common version 3 grant is used by the combined application. The difference is recorded, not silently rewritten.
+
+Hebcal output, GeoNames place data and the Omer count resources retain **CC-BY-4.0**. The Wikisource Omer transcription/editorial resource retains **CC-BY-SA-4.0**, its contributor attribution, pinned revisions and modification notes. The complete Creative Commons legal texts are in [LICENSES](../LICENSES/README.md). API account terms and quotas are separate from these data grants. See [third-party notices](../THIRD_PARTY_NOTICES.md) and [technical provenance](THIRD_PARTY_DETAILS.md).
+
+## Exact source offer
+
+`/source`, with `/license` and `/version` aliases, shows the application identity and source information. `SOURCE_URL` must identify the exact deployed corresponding source, including local application changes and all active patches. A moving upstream branch or the Composer lock alone cannot identify a modified deployment. `BUILD_COMMIT` is build metadata; a fabricated commit does not turn an unpublished working tree into published source.
+
+When conveying a modified covered program, retain notices and the license, identify modifications and provide the required corresponding source. For remote interaction with the modified network program, GNU AGPL section 13 requires the applicable source offer. The complete conditions are in [LICENSE](../LICENSE), including sections 4โ6 and 13, and the [official GNU text](https://www.gnu.org/licenses/agpl-3.0.html). This document records the project's release practice and evidence, not a certification of unrelated contributions or deployments.
+
+## Publication archive scope
+
+The `v3.0.0-dev.9` release provides two separate artifacts:
+
+| Artifact | Scope |
+|---|---|
+| `shabbat-3.0.0-dev.9.zip` | Clean repository source: application, resources, migrations, tests, scripts, Composer manifest/lock, public configuration, Docker files, documentation, licenses and required GitHub files. No installed `vendor` tree. |
+| `shabbat-3.0.0-dev.9-corresponding-source.zip` | Application material plus the 57 locked Composer runtime packages, their source/license notices and the applied reviewed patches. Exact contents, exclusions and hashes are recorded in its manifest. |
+
+The corresponding-source artifact is scoped to the application and its Composer runtime dependencies. Development tools are reproducible from `composer.lock` and are inventoried, but are not bundled in that runtime source artifact; PHPStan's distributed PHAR must not be called its preferred source. The archive is not a complete Docker-image or operating-system source distribution. PHP, SQLite, Composer and base-image/system components are external to that scope; Docker pins and source references describe the build inputs. Any unresolved or omitted preferred-source component must be listed in the publication report before the artifact can be described as complete within its stated scope.
+
+Keep the application source, all five patch sets, immutable bases/intermediates, added files, exact diffs, manifests and notices available together. The two reviewed public Amp HTTP Server TLS example private keys may be omitted from a distribution only with the exact omission records preserved; they are not used by this bot. Other private keys and real runtime credentials do not belong in source archives.
+
+The repository's existing `php scripts/package.php` is a separate packaging tool: with a development installation it builds application, corresponding-source and server-test variants under `dist/`. It copies the installed dependency distributions, including development tools, so its output alone is not evidence that every development tool's preferred source is present. The dedicated publication artifacts above have their own content manifests and checks.
+
+## Evidence and remaining review
+
+The [dependency inventory](dependencies.json) distinguishes 57 runtime packages and 26 development packages, exact versions, metadata, distributed license files and findings. `LICENSE_TEXT_PRESENT` means a recognized license text was found; it is not an authorship or compatibility certificate. Regenerate it with `php scripts/dependencies.php` from the isolated locked installation. Composer security advisories are a separate check.
+
+The original UpdateLoop reference script retains its exact provenance hash but has no standalone license header. The historical formatted-date exports have unresolved standalone grants and are not redistributed. Preserve those findings and review any uncertain contributor or deployment-only source provenance before a stable distribution. The [publication status](PUBLICATION_STATUS.md) and external `checks/PUBLICATION_REPORT.md` identify what was actually checked for this preparation.
diff --git a/docs/MADELINE_FORMATTED_DATE.md b/docs/MADELINE_FORMATTED_DATE.md
new file mode 100644
index 0000000..4ed42b0
--- /dev/null
+++ b/docs/MADELINE_FORMATTED_DATE.md
@@ -0,0 +1,46 @@
+# MadelineProto formatted-date compatibility and version policy
+
+Reviewed 2026-10-14. The selected package is danog/madelineproto 8.7.0, immutable commit 7b99dd0f2d964a492c377912ffce9f5ce61aa9ed. Both composer.json's exact constraint and composer.lock preserve this version; composer install uses the full 83-package lock. At the 2026-10-14 review, GitHub marked 8.7.0 as a pre-release and 8.6.5 as Latest; these are historical observations, not a current release lookup. This project remains a 3.0.0 development snapshot and does not describe the selected dependency as an upstream stable release.
+
+## The concrete error
+
+The package's layer 225 TL schema declares messageEntityFormattedDate#904ac7c7 with UTF-16 offset/length, a Unix timestamp and six boolean flags. The original EventHandler/Message/Entities/MessageEntity.php factory lacks both a FormattedDate wrapper and its dispatch branch. Valid received entities raise AssertionError: Unknown entity type: messageEntityFormattedDate at the original factory line 60. Message::__construct calls that factory before typed event-handler dispatch, so catching an error only inside a bot handler does not repair the missing wrapper.
+
+This is message-formatting metadata. It does not replace the group's IANA timezone, change calendar calculations, reinterpret Hebcal JSON dates or convert the operational Shabbat/festival boundaries.
+
+Before changing vendor, eight valid inputs reproduced the exact failure without creating an API, a session or a Telegram connection. A separate probe assembled explicitly labeled schema wire with the native integer encoder and used the actual locked TL decoder for all 19 valid flag combinations. It was not represented as a complete TL(null) serializer integration. Historical raw investigation output is not included in the publication source. Reproducible regression tests and the source artifacts remain included; the current publication report records fresh checks.
+
+## Available history and reference
+
+The local shabbat repository has 98 reachable commits across all references and reflogs. Searches of file paths, commit messages and content changes found no previous FormattedDate/messageEntityFormattedDate correction or dedicated test. This is the accessible local history; deleted/unavailable remote history is not claimed.
+
+Two local exports contain byte-identical implementations. The labels below are logical local-reference project/file identifiers, not public download URLs:
+
+- local-reference/newsearch-v2/docker/patch-madeline-formatted-date.php
+- local-reference/psy new/release-r21-work/docker/patch-madeline-formatted-date.php
+
+Their script SHA256 is c3091f61a401d8022b8ef1d228cf8c5f31a1094982de69a0be108e21885cb967. Their docker/madeline-formatted-date/FormattedDate.php wrapper SHA256 is a9e3146e49a88bec469e67e53f5e27cffb1b5127cde547590f2e385ef02dd264. The corresponding test is tests/madeline_formatted_date_compatibility_test.php.
+
+These directories have no usable Git history. Their filesystem modification timestamp is 2026-09-03T23:06:59Z; that is not asserted to be a commit or authorship date. The export package declarations conflict (MIT versus proprietary) and no standalone grant/header was found for those reference files. Their behavior and hashes were reviewed, but the original script/wrapper is not redistributed as an audited upstream artifact.
+
+A fresh implementation is based on the official protocol and the locked SDK's entity conventions. Its applied unified diff is a new reviewed correction, not described as the original unchanged historical patch. Native/BotAPI conversion retains the timestamp and UTF-16 offsets; the protocol's unknown-entity rejection remains in place. It does not add a broad catch-and-ignore fallback.
+
+## Official source check and protocol contract
+
+The official v8 head checked on 2026-10-14 was b1a4c5efee60a87cdbe7bc2b1eb542166ec7e2e3 (commit date2026-10-07); its entity factory still lacked this support. No newer compatible official correction was approved during this review. The exact tested 8.7.0 source is retained instead of silently selecting a moving branch.
+
+The supported BotAPI date_time format is r|w?[dD]?[tT]?. Empty formatting is valid. Relative formatting cannot be mixed with other flags; short/long variants of time or date cannot coexist. Telegram controls the actual client rendering. Generated HTML previews may preserve the underlying text without a dynamic date tag, while native entity capture/resend retains the date metadata; neither is claimed as a live client test.
+
+See the [official constructor](https://core.telegram.org/constructor/messageEntityFormattedDate), [date-time formatting](https://core.telegram.org/bots/api#date-time-entity-formatting), [selected release](https://github.com/danog/MadelineProto/releases/tag/8.7.0) and [checked official source](https://github.com/danog/MadelineProto/blob/b1a4c5efee60a87cdbe7bc2b1eb542166ec7e2e3/src/EventHandler/Message/Entities/MessageEntity.php).
+
+## Installation, licensing and future updates
+
+Use the shared [patch workflow](PATCHES.md): install from the lock, apply and verify approved patches before startup, and use the explicit route after --no-scripts. Startup only verifies; never change an active Composer tree or running container. The new class and factory are an approved set: partial, unknown, mismatched or incompatible source must stop the build. A matching already-patched set is a no-op. Corresponding-source archives retain the exact SDK baseline, new class, diff, manifest, rights notices and license.
+
+The supplied Docker layout mounts /app/data and /app/runtime only. It does not mount /app or /app/vendor, so an old named vendor volume cannot hide dependencies in that layout. Patching occurs during image construction. The other project's /opt/theisraelis copy workaround is specific to its broader runtime mounts and is not silently transplanted here.
+
+AGPLv3 allows conveying modified source and the modifications that produce it, subject to its conditions: retain notices/license, identify changes and dates, license the covered combined work appropriately, provide corresponding source with distributed object code, and offer the modified program's source to remote network users where section 13 applies. MadelineProto package metadata says AGPL-3.0-only; existing v3-or-later headers remain untouched, using their common version 3 grant. This supports including the reviewed diffs in the AGPL Shabbat repository; it is not a blanket certification of every contributor, asset or deployment practice. See [third-party notices](../THIRD_PARTY_NOTICES.md) and the [GNU license text](https://www.gnu.org/licenses/agpl-3.0-body.html).
+
+An exact lock prevents accidental version selection; it cannot guarantee an unseen future Telegram/PHP version will stay compatible. A future upgrade requires an explicit constraint/lock change, reviewed version/commit/schema/file signatures, all behavioral tests, a clean install and an isolated target rehearsal. Unknown versions fail closed. Dependency update proposals and CI do not constitute automatic approval to publish or deploy.
+
+No active bot/container, production data or real Telegram message was changed during the investigation. The final applied state and exact complete-suite/archive results are recorded in [implementation status](IMPLEMENTATION_STATUS.md) and the external checks/PUBLICATION_REPORT.md for this preparation. Historical private validation outputs are not bundled. No task commit, local tag or publication is claimed.
diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md
new file mode 100644
index 0000000..fe8ed55
--- /dev/null
+++ b/docs/MIGRATION.md
@@ -0,0 +1,50 @@
+For the running legacy installation at `/root/shabat`, follow the complete [production upgrade procedure](PRODUCTION_UPGRADE.md), including stopped backups, exact group verification and permission recovery.
+
+# Dev.8 to dev.9
+
+Stop all writers and use the verified backup/migrate sequence in [test deployment](SERVER_TEST_DEPLOYMENT.md). Migration 016 adds dispatch timestamps and indexes without changing prior migration files, permission snapshots or job payload/random IDs. Existing attempts that might have reached Telegram are conservatively marked uncertain. This can suppress additional Tefillin messages on the upgrade day rather than duplicate an unknown old delivery. Existing older unverified permission snapshots retain the dev.8 administrator-review requirement.
+
+Rebuild the image for the reviewed BroadcastManager lifecycle patch. Do not modify a live vendor tree or remove data/session volumes. See [runtime changes and retention](DEV9_DELIVERY_SAFETY.md). Historical migration notes follow.
+
+# Migration from the file-based bot
+
+Keep the old bot stopped during explicit import and cutover. Do not run old and new writers together or delete the old MadelineProto session. These commands are operator instructions; development tests use isolated fixtures only.
+
+1. Stop the bot and all maintenance writers. Copy the legacy directory to a protected read-only mount and retain the original files.
+2. Initialize a new application database: `php bin/migrate.php --db=/path/to/shabbat.sqlite --writers-stopped`. For an existing application DB supply an existing `--backup-dir=/path/to/backups`; it is backed up and verified before any migration.
+3. Inspect: `php bin/import-legacy.php --db=/path/to/shabbat.sqlite --legacy=/path/to/old-bot --dry-run`. The source can be the bot root or its `data` directory. With no destination DB, dry-run uses an in-memory database and does not create application records.
+4. Compare report counts, templates, original rights, invalid JSON and recovery warnings with sampled legacy records.
+5. Apply: `php bin/import-legacy.php --db=/path/to/shabbat.sqlite --legacy=/path/to/old-bot --apply --writers-stopped --backup-dir=/path/to/backups`.
+6. Run health checks, test backup/restore on a separate path and verify a staging group before starting the new bot.
+
+The startup compatibility bridge reads only `data/DBgroups.txt` from `LEGACY_DATA_PATH` and adds missing subscribed group records. Existing groups therefore use `/settings` without re-registering using `/add`. It does not migrate schema, overwrite edited preferences, remove groups or restore permissions. Explicit maintenance import is still required to review templates, drafts, snapshots and old lock state.
+
+Import is additive and repeatable. Edited/new database settings and templates are preserved. All new daily zmanim/daf, tefillin and Omer modules remain off for imported groups. Existing alert flags determine the old times and opening/closing message behavior. Personal/private drafts are imported separately and an old selected group file never confers administrator permission.
+
+The pinned legacy source requested Jerusalem GeoName 281184 with `b=18&M=on`. Hebcal's Jerusalem city rule uses forty minutes despite that query value; city-default `candle_minutes=null` preserves that behavior. Legacy closure is ten minutes before candles; opening is at provider havdalah, with no extra offset. The old opening-check helper omitted the closure subtraction; the importer reports that mismatch instead of carrying separate inconsistent engines forward.
+
+`MsgOpener*` / `MsgCloser*` preserve text, MTProto entities, file identifiers, custom buttons/styles/icons. Malformed metadata is reported; original sources are retained. `chatb1.json` is imported with a checksum and a recovery-required status. Global `state_open.txt` and lock dates are provenance for review, never a source of today's event/week. A global state without a group's original snapshot produces a visible review requirement, not a mass opening or guessed permissions.
+
+Migration never invokes Telegram. At runtime current calendar policy and fresh permission reads decide recovery. A malformed/missing snapshot or manual conflict needs an authorized administrator response. Re-import does not erase that obligation.
+
+Schema migration files are immutable once applied: a changed checksum aborts. Rollback after a schema upgrade requires restoring a verified compatible database plus the matching application version. It does not reverse permission RPCs already accepted by Telegram. Resolve outstanding bot-owned locks before decommissioning or removing a group. See [database](DATABASE.md) and [backup/restore](BACKUP_RESTORE.md).
+
+Explicit rich import also reads the canonical root `ids.txt` for known positive int64 user IDs. Duplicates are ignored; negative dialog IDs do not become users or new group registrations. Known users gain no operator/group-administrator authority. Existing personal preferences are preserved. The `idsnew.txt` pagination snapshot is retained as draft bytes but is not a separate authority or subscription source.
+
+The exact reviewed admin draft names are copied additively into `admin_state`, preserving original bytes: private `grs1.txt`, `messagetodelete.txt`, `txt.txt`, `ent.txt`, `media.txt`, and optional per-operator `BUTTONS.txt`, `broadcastsend.txt`, `pinmessage.txt`, `page_var.txt`; global `data/BUTTONS.txt`, `broadcastsend.txt`, `pinmessage.txt`, `startline_cat.txt`, `endline_cat.txt`, `page_var.txt`; and root `ids.txt`, `idsnew.txt`. Per-user buttons take precedence in normalized personal templates, with global legacy buttons as fallback. Existing SQLite drafts/options are never overwritten by re-import. The importer does not read session/auth/config/secret files, call a broadcast RPC, infer roles from old workflow state or create a new authenticated conversation token. Draft values above 1 MiB are reported and retained in the source for review.
+
+Migration `004_expand_languages.sql` is an explicit static table-copy migration, run with stopped writers and a verified backup. It preserves every earlier group/personal field, revisions, imported timestamps, temporary manual overrides, payment IDs/nonce/payer/amount/query/charge bindings and foreign keys while allowing all six configured languages. Delivery jobs/random IDs, permission snapshots, templates, conversations, audit history and operator drafts remain intact. An interrupted transaction rolls back; a migration checksum mismatch aborts. Isolated preservation tests compare every pre-existing row and verify foreign keys after upgrading from 001โ003.
+
+Migration 005 creates `known_broadcast_peers` independently of Shabbat subscriptions. Explicit canonical `ids.txt` import retains valid positive and negative int64 delivery peer IDs with type `unknown`, without calling Telegram. A negative legacy channel/chat ID therefore remains available to the operator's audience workflow without registering it for Shabbat locking. Current per-peer type/title edits are preserved on re-import; actual Telegram peer type and permissions must be refreshed before any delivery. The startup group-list bridge does not import this audience table.
+
+## Migration008: group controls and independent cleanup
+
+Run the numbered migration only during stopped-writer maintenance. It adds group-only lock_block_flags (default21, including send_reactions/edit_rank), two holy-message cleanup switches and tefillin_delete_previous, all cleanup switches defaultfalse. It preserves original lock snapshots while keeping an in-flight previous applied mask, and adds durable deletion work plus confirmed tefillin message pointers. Existing group zmanim rich designs are copied once into independent times_alert templates while their original rows/IDs remain. Sent reminders receive separate replayable bookkeeping; no previously sent job is returned to pending to trigger deletion. The earlier seven migration checksums remain immutable. SQLite backups include these tables; restore the full schema/data together.
+
+## Dev.6 to dev.7
+
+Stop every writer and run the documented verified backup/migration command before starting dev.7. It adds **013_permission_recovery.sql** and **014_diagnostic_panels.sql** while preserving the exact checksums of migrations 001-012, group settings, original permission snapshots, pending conflicts and Telegram session data. The migration does not itself change Telegram permissions or resolve a conflict. Recovery happens only through the reviewed runtime rules or an authorized group-admin confirmation. See [test deployment](SERVER_TEST_DEPLOYMENT.md) and [permission recovery](PERMISSION_RECOVERY.md).
+
+## Dev.6/dev.7 to dev.8
+
+Dev.8 adds migration 015 for snapshot provenance. The first 14 migration files remain unchanged; upgrading dev.6 applies 013-015, while dev.7 applies 015. Stop all writers, preserve the same database/session volumes and run the verified backup/migration command before startup. Migration does not send Telegram requests or alter saved rights. An unresolved older snapshot requires explicit administrator resolution and cannot be automatically restored, even if its checksum is correct. Completed cycles can capture a fresh verified baseline. See the [affected-group procedure](DEV8_PERMISSIONS.md) and [current deployment commands](SERVER_TEST_DEPLOYMENT.md).
diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md
new file mode 100644
index 0000000..c46b9d3
--- /dev/null
+++ b/docs/OPERATIONS.md
@@ -0,0 +1,29 @@
+# Operator commands and Stars donations
+
+The configured operator authorization callback is mandatory for `ManualGroups`. It is a separate bot-operator permission, not a cached group administrator role. Bind it to the project's existing trusted operator IDs and authenticated Telegram sender. Do not expose the service through an ordinary unprotected callback.
+
+`ManualGroups::apply(true, operatorId, ttl)` closes registered groups; `apply(false, ...)` restores bot-owned original permissions. These operations use the same durable permission snapshots and fresh Telegram readback as scheduled locks. Existing administrator locks are retained, conflicts/missing snapshots remain visible, and partial failures are reported per group. They do not send success announcements themselves. Cancellation stops the batch without discarding already persisted snapshots.
+
+The temporary typed override expires after one hour by default, with a maximum of 24 hours. The response includes the expiry and a visible override warning. A manual opening can explicitly override an active festival's group lock; it does not change religious calendar flags/times or authorize routine reminder delivery during a quiet period. The runtime calls `ManualGroups::desired(group, calendarDesired, utcNow)` and resumes the current calendar policy at the exact expiry. An administrator-prelocked group is never opened merely because the operator selected this command.
+
+Migration `003_manual_payments.sql` adds `manual_override_mode` and `manual_override_until` to group settings. The change increments the group's settings revision, revokes old panels and replans future content while retaining permission restoration debt and delivery history.
+
+The donation service preserves the existing 5, 25, 100, 150, 250 and 400 Telegram Stars offers. `Donations::createOffers(userId, language)` exports native MTProto invoices with currency `XTR`, one labeled price, DataJSON `{}`, and native green URL buttons. It does not configure a paid broadcast, subscription, recurring charge, payment-provider token, email, phone or shipping request. The root handler displays the returned text/buttons in private chat.
+
+Each opaque invoice payload has an independently generated random nonce. SQLite stores its exact payer, Stars amount, currency, language, creation/expiry, export status and precheckout reservation. No payer ID or role is inferred from invoice text. `answerPrecheckout` checks all these fields and atomically binds the intent to one query before answering `messages.setBotPrecheckoutResults`. The RPC happens outside the database transaction. An expired, reused, wrong-payer, modified or unknown invoice is declined with an explicit `/donate` invitation to create a new offer.
+
+Old three-part `donate|user|amount` payloads had no durable nonce/approved intent. They cannot be safely authenticated after migration and are explicitly declined instead of being silently accepted. Existing production records/files are not modified by these services or their tests. Operators should reconcile any already accepted, unverifiable legacy payment separately rather than inventing new accounting entries.
+
+`recordPayment` is called only for an authenticated `messageActionPaymentSentMe` update. Its payer comes from the actual raw `from_id.peerUser`, not the embedded invoice payload. It validates exact amount/currency, requires precheckout approval, and records a unique charge and unique intent atomically. Re-delivery after restart returns duplicate without sending another thank-you. A service update arriving after invoice expiry can still be recorded when checkout was approved before expiry. A rejected or failed ledger write is visible and does not become a fake success.
+
+Private database records include payer ID, amount/currency, approved query ID, charge UID and optional service message ID. Charge identifiers and invoice payloads are not returned in the success report or placed in audit/error logs; email and expanded Telegram payment-request information are ignored. Keep the application database and backups private. The `stats` API returns aggregate donation count and Stars only. Define record retention with the operator's accounting needs; this document does not assert legal compliance.
+
+Implementation was checked against the locked MadelineProto TL schema v225: `inputMediaInvoice` requires DataJSON, `payments.exportInvoice` exports the URL, and `messages.setBotPrecheckoutResults` answers precheckout. Presets/native invoice shape come from the preserved WizardLoop implementation; the project retains its historic MIT copyright/permission notice. New service code is AGPL-3.0-only.
+
+Tests use isolated SQLite databases and fake RPC/permission gateways. They cover authorization, snapshot restoration, override expiry, partial failures/cancellation, invoice binding/expiry, replay/conflicting charges, ledger rollback and restart. No Stars charge, real invoice or production Telegram group was used.
+
+Invoice titles, descriptions, prompts and recoverable errors use the same six-locale resources as the rest of the private UI. Migration 004 expands the SQL language constraints without rewriting an existing approved intent or ledger record. Test invoice exports remain fake; no live Telegram invoice or Stars payment was made during development.
+
+Live BroadcastManager status uses the bundled reviewed presentation hook and the six-language `BroadcastStatusFormatter`. Each operator's manager receives its localized initial caption, progress counters/status, and native colored controls with unchanged `bm` operation IDs. The formatter does not send, filter or mutate delivery state. The application verifies operator authorization before control actions. A legacy text-only library summary receives an explicit localized recovery notice rather than being guessed or retranslated; its original file is retained, and structured application progress/audit results remain the current status view. See [patch provenance and installation](../patches/broadcastmanager/README.md).
+
+Composer/Docker/startup patch verification now covers both the MadelineProto cancellation patch and this separate BroadcastManager presentation patch. With `--no-scripts`, run `php scripts/apply-patches.php` then `php scripts/verify-patches.php` before startup. Installation refuses an active writer or unknown/partially patched source. Reflection tests invoke the actual reviewed library formatting methods without constructing a Telegram API; live status editing/colors still require staging verification.
diff --git a/docs/PATCHES.md b/docs/PATCHES.md
new file mode 100644
index 0000000..40d4b61
--- /dev/null
+++ b/docs/PATCHES.md
@@ -0,0 +1,84 @@
+# Offline patch installation
+
+[Review provenance and behavior](../patches/madelineproto/README.md).
+Composer lifecycle scripts and Docker install offline, verify exact hashes,
+and fail on unknown source. Startup verifies only.
+
+With scripts disabled:
+
+ composer install --no-dev --no-scripts --prefer-dist
+ php scripts/apply-patches.php
+ php scripts/verify-patches.php
+ php bin/self-check.php
+
+Do not bypass manifest failures or edit a live container. Review upgrades on an
+isolated installation and prefer a tested official correction when available.
+
+Reference runBot implementations were inspected in importchatsbot/src/bot.php
+and SwiftTool/bot.php. Runner reproduces Revolt error-handler installation,
+credentials/Settings setup and startAndLoopBot. The typed error callback is rebound on handler attachment, after the SDK installs its own startup callback. It uses narrow typed checks for
+completed-operation and ReferenceDatabase errors. It does not swallow every error
+whose text says "The operation was cancelled": the explicit cancellation/shutdown
+requirements forbid that behavior. Unexpected errors exit 1; shutdown flags stop
+new application work. SingleInstance protects the shared runtime directory.
+
+Offline loop tests do not prove behavior against Telegram, real network failure,
+or signal delivery in every container runtime. Verify those using an isolated
+staging bot and test groups before production deployment.
+Mutation also acquires the same APP_DB_PATH + .writer.lock used by Runner and
+SQLite maintenance. An active writer makes application fail before a file is
+changed; read-only verification and an already-patched no-op remain available.
+Stop the isolated bot before applying a required patch. The installer only
+creates a lock file on its configured working data path, never starts Telegram.
+
+The same installer requires the reviewed
+[BroadcastManager status-localization patch](../patches/broadcastmanager/README.md),
+[GeoPoint double-coordinate correction](../patches/madelineproto-geopoint/README.md)
+[formatted-date entity correction](../patches/madelineproto-formatted-date/README.md)
+and [fresh full-peer response correction](../patches/madelineproto-fresh-peer/README.md).
+Each manifest identifies its locked package/commit, target, baseline, diff and
+final hashes. All five patch sets are applied before runtime and verified by
+Runner/self-check; missing required evidence or unknown source fails the build.
+Baseline hashes remain checked when installed vendor source is already patched.
+
+The formatted-date set changes the entity factory and adds one independently
+written class; both must match the approved set. Its pinned TL schema and original
+AGPL license are also checked. A partial factory/class installation fails closed.
+The located reference script is not redistributed: its exports lack usable Git
+history and a consistent standalone license grant. This is a new reviewed
+implementation, documented in [the formatted-date review](MADELINE_FORMATTED_DATE.md).
+
+The [GNU AGPLv3](https://www.gnu.org/licenses/agpl-3.0-body.html) permits conveying
+covered modifications, including diffs, with retained notices, identified changes,
+AGPL licensing and applicable source offers. Exact grants and reference limitations
+are recorded in [third-party notices](../THIRD_PARTY_NOTICES.md). Preparing these
+artifacts does not publish them or authorize deployment.
+
+RuntimePaths resolves DATA_DIR, APP_DB_PATH, SESSION_PATH and HEARTBEAT_PATH once
+before startup and exports them for the preserved legacy handlers. Maintenance
+commands use that same DB default and retain explicit --db overrides.
+
+## Native SDK boundaries
+
+The runtime rebinds the typed Revolt error handler in onStart after MadelineProto
+installs its own callback. The gateway uses the declared messages.getForumTopicsByID
+method with peer/topics. Offline tests check reachable gateway, invoice, and menu
+methods against the locked SDK declarations, effective blacklist, and TL schema.
+
+Fresh permissions use the supported getFullInfo helper with fullInfoCacheTime=0
+and the reviewed per-call raw response field. The reader requires complete matching
+Chat/ChatFull evidence; it never substitutes a second cached getInfo result. Direct
+channels.getFullChannel/messages.getFullChat namespace calls are blacklisted and
+are not used. A nonzero cache policy fails closed. Public full-info helper calls
+have the official RPC setting's five-second minimum automatic flood wait; longer
+waits propagate as typed scheduler retries. Direct namespace calls use the
+per-call floodWaitLimit=0. Permission leases and desired state are fenced again
+after awaited reads.
+
+Decimal random_id, query_id and keyboard style.icon values are validated within
+signed int64 and converted to native integers at the outbound boundary. The
+locked TL serializer interprets eight-byte strings as packed binary; real
+serializer regressions cover eight-digit decimals and signed limits. Stored
+identifiers remain decimal strings, preserving database identity.
+
+Fresh permissions use the supported getFullInfo path with full-info caching disabled and verified per-call raw response evidence. See the fresh-peer patch README and dev.8 investigation for the reproduced stale-cache limitation of the earlier two-helper path. Direct full-info namespace calls are blacklisted in the locked SDK. Public full-info helpers use the official five-second minimum flood-wait setting; longer RateLimitError-family waits become typed retries. Ordinary namespace RPCs retain the verified per-call zero-wait option. Native decimal int64 query/random/icon IDs are validated and converted at the outbound boundary; persisted IDs remain decimal strings.
diff --git a/docs/PERMISSION_RECOVERY.md b/docs/PERMISSION_RECOVERY.md
new file mode 100644
index 0000000..a8933d6
--- /dev/null
+++ b/docs/PERMISSION_RECOVERY.md
@@ -0,0 +1,39 @@
+# Permission recovery
+
+The group settings panel provides **Resolve permission problem**. Open it from the group's main settings or its Shabbat/permissions category. The private panel names the group, shows the saved state and a bounded comparison of current versus original restrictions, and offers a reviewed confirmation for either **Use current permissions as normal** or **Restore permissions saved before the problem**. Navigation edits the same message.
+
+## Fresh reads and older snapshots (dev.8)
+
+All permission reads use the exact matching full-peer response from the supported helper, with a separate immutable patch to pinned MadelineProto 8.7.0. Cached or minimal records cannot authorize capture or readback. Migration 015 preserves older snapshots but labels them unverified. Any unresolved older cycle requires administrator resolution: automatic opening and the restore button cannot use that snapshot. The six-language panel explains how to configure the intended normal permissions in Telegram, refresh and explicitly adopt them outside protected periods. Do not delete the saved row or automatically accept an already opened group. Completed cycles may capture a fresh baseline at the next lock. See [the investigation and recovery steps](DEV8_PERMISSIONS.md).
+
+## Automatic recovery
+
+A completed lock cycle permits a fresh baseline before the next closure. Legitimate weekday changes, such as disabling media, therefore survive the following Shabbat and reopening.
+
+The coordinator recognizes verified equivalent Telegram representations, a current original baseline, and known bot-applied masks. It also reconciles additional restrictions on known permission fields that the bot did not alter, preserving those external restrictions in a separate checksummed recovery baseline. Relaxations remain explicit-review cases because they can also represent a partial unlock. The original snapshot remains available. Changes to bot-controlled fields, unknown flags, expiry discrepancies and partially applied masks do not authorize a guessed reset. A first lock that the durable journal proves was deferred before any Telegram write can retire that unexecuted attempt and begin from fresh normal permissions; this requires matching original/requested evidence and no prior recovery debt. A new week or process restart alone never discards unresolved state.
+
+Permission attempts retain bounded before/requested/observed evidence, stage and outcome. A failed confirmation does not establish that Telegram made no change. The same coordinator and recovery rules serve manual and scheduled operations.
+
+## Administrator resolution
+
+Both choices require a freshly verified group administrator and a short-lived, single-use confirmation bound to the actor, group, settings revision, lock state and observed permissions. If any of those change, refresh and review again. The bot retains the previous evidence for diagnosis.
+
+Adopting current permissions establishes an explicitly approved normal state; it does not send a restriction update to Telegram. A currently closed group cannot silently become an ordinary open-group baseline. Restoration is available only for a verified snapshot and sends the preserved original permissions and verifies a fresh response before marking recovery complete. Neither action resets unrelated group preferences or enables reminders.
+
+Recovery is blocked during religious Shabbat/Yom Tov, configured operational closure windows, active manual closure or shutdown. An operator-open override does not bypass the religious guard. Unknown calendar data cannot grant recovery. A failed manual close can still have an active override until its displayed expiry; resolve that intent before confirming recovery.
+
+A verified calendar deadline also fences the final remote read synchronously, without another asynchronous authorization gap before commit. Per-group leases and revision checks protect against simultaneous scheduler work, changed remote permissions and replayed confirmations. Network calls occur outside database transactions. Rate limits retain their retry deadline instead of sleeping the whole bot. Telegram does not offer a compare-and-swap operation for default restrictions; an administrator change after the final read or during an in-flight update cannot be made atomic with this process. The bot checks the observed result and retains unresolved evidence when verification fails.
+
+## Upgrade and acceptance
+
+Dev.7 adds migration 013 for recovery state and evidence. Existing migrations 001-012 and the MadelineProto 8.7.0 dependency/patch lock remain unchanged. Stop all writers and take the verified migration backup before upgrading. See [test deployment](SERVER_TEST_DEPLOYMENT.md).
+
+Offline tests cover safe automatic recovery, preserved original snapshots, repeated cycles, administrator changes, stale/forged confirmations, role loss, cancellation, timing boundaries and migration preservation. Actual Telegram readback and both recovery choices must also be checked in the isolated test bot. See the versioned validation report for the exact checks that ran.
+
+Native restrictions follow Telegram's [chatBannedRights constructor](https://core.telegram.org/constructor/chatBannedRights) and [default group-rights method](https://core.telegram.org/method/messages.editChatDefaultBannedRights). Individual true flags represent restrictions, so the UI translates them to permitted/blocked rather than showing raw booleans.
+
+Dev.8 adds migration 015 and a fifth fresh-peer patchset. The first 14 migrations, Composer lock and earlier four patchsets remain unchanged. Follow the current deployment instructions rather than historical version-specific notes.
+
+## Final permission-write checks
+
+Ordinary manual and scheduled changes re-read current permissions after any asynchronous desired-state/calendar evaluation. An administrator change during that wait prevents the write and retains evidence. A synchronous final check verifies the same settings, manual-override expiry, next operational time boundary and shutdown state before dispatch. This reduces stale writes; Telegram still has no compare-and-swap API, so a change after the final read or during the request is not atomic. Readback is required and post-write mismatches are explicitly reported as unverified changes, even when the confirmed-change counter is zero.
diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md
new file mode 100644
index 0000000..72a7328
--- /dev/null
+++ b/docs/PRIVACY.md
@@ -0,0 +1,27 @@
+# Privacy
+
+The application stores Telegram user/group identifiers, group titles, settings, chosen locations, message templates (including entities/media identifiers/buttons), jobs, permission snapshots, delivery/audit records and transient conversation tokens. MadelineProto separately maintains the session needed to access Telegram.
+
+City queries are sent to GeoNames with the operator's account. Coordinates/timezone/date requests are sent to Hebcal. Public inline output identifies the displayed location; private group-flow tokens do not expose group IDs or titles in the inline query. Bot operators control storage and provider accounts.
+
+Snapshots remain until restoration or authorized conflict resolution. Removing a group never silently removes an active restoration obligation. Backups contain private application data and require operator access restrictions. Release packaging excludes runtime data, sessions, .env, logs and private backups.
+
+Retention and deletion require operator policy. Audit/delivery rows are currently retained for recovery and diagnosis; no automatic legal-compliance or fixed-retention claim is made. Operators should document their chosen retention, access and deletion process before public deployment.
+
+Stars accounting stores exact payer ID, currency/amount and private unique charge/intent identifiers. These are not included in public statistics or archive fixtures. A confirmed new donation sends one private thank-you and an operator report; duplicate service updates do not repeat the notification. The individual donation language is retained independently of future preference changes.
+
+BroadcastManager retains its own internal job/delivery records in the configured durable DATA_DIR. Application draft/options and subscriber registrations are SQLite; the application's VACUUM INTO backup does not replace a stopped-writer backup of those third-party records or the separate MadelineProto session. Protect all three state sources and document retention before deployment.
+
+## Optional message cleanup metadata
+
+Opt-in Shabbat/festival cleanup stores only the authenticated group/message/sender IDs, original message timestamp, sender type, settings revision, expiry and retry/lease status. The cleanup queue does not store incoming message text or scrape group history. Tefillin replacement stores the IDs of the last confirmed bot delivery and its prior cleanup jobs; it does not identify a prior message by text. These application tables are included in SQLite backups. Disable the relevant group switches to stop pending cleanup, and follow the existing private backup/log retention policy. Telegram must deliver an update before moderation is possible; other-bot visibility requires the documented BotFather settings.
+
+## dev.4 directory, manager and interaction data
+
+The subscriber directory stores only observed/available Telegram peer metadata (ID, title/name/username, language/type and relevant account flags), first/last observation/activity and whether a private chat was actually observed. It stores limited group-administration observations for the known-group profile; these observations are not authorization. Manager capabilities and their changes are kept for access control/audit. Subscriber browsing and private details require the corresponding current bot-manager permission.
+
+Tracking links record aggregate starts and daily unique/repeat counters. Visitor identities use the repository's per-installation HMAC key; replay markers suppress repeated service delivery. Link labels, destination and optional expiry are operator-controlled. They are included in private SQLite backups, never release archives.
+
+The optional tefillin acknowledgement stores the native group/message IDs and selected Telegram user IDs necessary to enforce one vote per user, plus callback deduplication hashes and edit retry metadata. A second click removes that user's vote; public messages show the aggregate count, not a list of voters. It is a voluntary acknowledgement, not proof of religious observance. Disabling/removing the feature stops future interaction, but does not itself erase existing database/backup rows. No automatic retention/purge policy is claimed; operators must define and perform authorized deletion/retention with referential integrity and recovery needs in mind.
+
+Custom invoice amounts do not add profile/customer data to the existing Stars ledger. Pending template/button edits and donation inputs are user-bound and expiring. Archive generation excludes these live states. No public analytics service is added.
diff --git a/docs/PRODUCTION_UPGRADE.md b/docs/PRODUCTION_UPGRADE.md
new file mode 100644
index 0000000..5c7d034
--- /dev/null
+++ b/docs/PRODUCTION_UPGRADE.md
@@ -0,0 +1,450 @@
+# Production upgrade from the legacy file-based bot
+
+[English overview](../README.md) | [ืขืืจืืช](../readme-he.md) | [Import mapping](MIGRATION.md) | [Backup and restore](BACKUP_RESTORE.md) | [Permission recovery](PERMISSION_RECOVERY.md)
+
+This procedure is for the existing Docker Compose installation at **`/root/shabat`** (one `b`), upgrading the file-based implementation at historical baseline `2976ab8339f489d933c7246e0cb27f3b0e4aa697` to **`3.0.0-dev.9`**. The final source directory remains `/root/shabat`. `/root/shabbat-test` is a separate test installation and is not a migration source or destination.
+
+**ืืฉืจืช ืืงืืื `/root/shabat`: ืืื ืื ืืช ืืืจืกื ืืืืฉื ืื ืคืจื, ืขืืฆืจืื ืืช ืืืื ืืืฉื, ืืืืื ืืช ืื ืืชืืงืืื, ืืืืืืื ืืืืืงืื, ืืจืง ืื ืืืืืคืื ืืช ืชืืงืืืช ืืืงืืจ ืืืคืขืืืื ืืืคืข ืืืื. ืืจืฉืืืช ืืฉื ืืช ืขืฉืืืืช ืืืจืืฉ ืืืฉืืจ ืืื ื ืฉื ืื ืื ืงืืืฆื; ืืืืื ืชืงืื ืืื ื ืืืืื ืฉืืืืจ ืืืืืืื ืฉืืื.**
+
+The release is a development prerelease. Complete acceptance with a separate test bot before this production maintenance window. These are operator instructions; writing this document does not execute a deployment. The commands below are for **Bash on the Linux server as root**, with Docker daemon access, Compose v2, `curl`, `unzip`, `tar`, `sha256sum` and sufficient free storage. Every `run ... php ...` maintenance command explicitly replaces the image's normal bot command; it does not log in to Telegram. Only the final `up` starts the new bot.
+
+## 1. Understand what moves and what requires review
+
+The historical Compose service is named `shabbat` and bind-mounts the entire project directory as `/app`. The old bot reads `.env`, stores group settings below `data/`, starts MadelineProto at `bot.shabbat`, and points BroadcastManager at `data/`. Root files such as `ids.txt`, `state_open.txt` and `*_lock*.txt` also matter. A copy of `data/DBgroups.txt` alone is not a backup.
+
+The new Compose service is also `shabbat`, but source/vendor are built into the image. Its separate named volumes hold `/app/data` and `/app/runtime`; the session prefix is `/app/runtime/bot.shabbat`. This procedure uses a distinct production Compose project, so its volumes cannot reuse test or old project state accidentally. All future commands must keep that project name.
+
+| Legacy material | Treatment |
+| --- | --- |
+| Entire old folder, including `.env`, vendor, session files/directories, data and root state | Preserved in a private stopped snapshot and a retained old source directory. |
+| `data/DBgroups.txt` | Unique valid entries become registered groups. Existing new-database settings are preserved on re-import. |
+| Alert flags, opening/closing text, entities, media file IDs and buttons | Imported according to the exact whitelist in [migration](MIGRATION.md); malformed/unsupported records produce warnings. |
+| Root `ids.txt`, operator drafts and supported global draft files | Imported without granting administrator authority. Configure trusted owners through `ADMIN`. |
+| MadelineProto session prefix `bot.shabbat*` | Copied intact to the new session volume while all old writers are stopped. It is not interpreted by the importer. |
+| Entire legacy `data/` | Also copied to the new data volume to retain BroadcastManager history and separate library state. |
+| `chatb1.json` and old global lock/date files | Retained as recovery evidence, never accepted as proof of current Telegram permissions or today's calendar state. |
+
+Imported groups keep the legacy Jerusalem/Asia-Jerusalem policy: city-default candle lighting, closure ten minutes before candles, havdalah at 8.5 degrees without an additional opening delay, Hebrew, and existing alert/message flags. They are **active** (`lock` or `lock_messages`); importing is not an automatic switch to off. Daily zmanim, daf, tefillin and Omer remain off for imported groups. Review these defaults if the server contains local modifications to the old implementation.
+
+**Permission acceptance is a separate gate.** Any imported `chatb1.json` is `legacy_unverified`. Even an otherwise open group without a snapshot receives `recovery_required` when a global old state file exists, including a leftover `open_lock.txt`. Such groups require an authorized group administrator to set/check the intended normal permissions in Telegram, refresh **Resolve permission problem**, and explicitly adopt current permissions outside Shabbat/Yom Tov and all active closure windows. The bot will not infer that a checksum makes an old snapshot safe. Do not delete recovery records or global state files to bypass this review.
+
+The old Composer specification used `^8.6` for MadelineProto and `*` for BroadcastManager, without a committed lock. Therefore the historical Git commit does not identify the server's actual installed dependency versions. Preserve its vendor and image; session compatibility must be checked during controlled startup. Keep original session bytes for rollback. Do not delete a session or create a second production-connected session as a troubleshooting shortcut.
+
+Review any pending broadcasts before the window. Copied `scheduled-broadcasts.json` and `self-destruct-jobs.json`, when present, are library state, not SQLite import records. Old schedules lack the new creator audit binding: future unattributed schedules block processing, and due unattributed schedules are cancelled rather than replayed as authorized new work. Due self-destruction jobs can act after startup. Preserve this state and review it privately; do not promise that interrupted broadcasts resume or that first startup has no external effects.
+
+## 2. Set paths and identify the actual production container
+
+Run the blocks in the same root Bash session. The timestamp creates new backup/staging destinations. Do not reuse a partially completed attempt without inspecting its state.
+
+```sh
+set -euo pipefail
+umask 077
+ORIGINAL_DIR=/root/shabat
+TEST_DIR=/root/shabbat-test
+VERSION=3.0.0-dev.9
+NEW_PROJECT=shabbat-production-dev9
+STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
+UPLOAD_DIR=/root/shabbat-release-upload
+RELEASE_DIR="/root/shabbat-releases/$VERSION-$STAMP"
+BACKUP_DIR="/root/shabbat-backups/$STAMP"
+RETIRED_DIR="/root/shabat-retired-$STAMP"
+test "$(readlink -f -- "$ORIGINAL_DIR")" = /root/shabat
+test "$ORIGINAL_DIR" != "$TEST_DIR"
+test -f "$ORIGINAL_DIR/docker-compose.yml"
+test -f "$ORIGINAL_DIR/bot.php"
+test -f "$ORIGINAL_DIR/.env"
+test -f "$ORIGINAL_DIR/data/DBgroups.txt"
+test ! -e "$RELEASE_DIR"
+test ! -e "$BACKUP_DIR"
+test ! -e "$RETIRED_DIR"
+df -h /root
+du -sh "$ORIGINAL_DIR"
+docker compose version
+docker ps -a --filter label=com.docker.compose.service=shabbat \
+ --format '{{.ID}} {{.Names}} {{.Status}}'
+```
+
+Choose the existing production container from this list; never choose the test container. The next placeholder must be replaced with its actual ID. The inspection prints only mount paths and Compose labels, not resolved environment secrets.
+
+```sh
+OLD_CONTAINER_ID='REPLACE_WITH_VERIFIED_PRODUCTION_CONTAINER_ID'
+docker inspect --format '{{range .Mounts}}{{println .Type .Source "->" .Destination}}{{end}}' "$OLD_CONTAINER_ID"
+docker inspect --format '{{index .Config.Labels "com.docker.compose.project"}} / {{index .Config.Labels "com.docker.compose.service"}}' "$OLD_CONTAINER_ID"
+test "$(docker inspect --format '{{index .Config.Labels "com.docker.compose.service"}}' "$OLD_CONTAINER_ID")" = shabbat
+OLD_PROJECT="$(docker inspect --format '{{index .Config.Labels "com.docker.compose.project"}}' "$OLD_CONTAINER_ID")"
+OLD_IMAGE_ID="$(docker inspect --format '{{.Image}}' "$OLD_CONTAINER_ID")"
+OLD_RESTART_POLICY="$(docker inspect --format '{{.HostConfig.RestartPolicy.Name}}' "$OLD_CONTAINER_ID")"
+test -n "$OLD_PROJECT"
+test "$OLD_PROJECT" != "$NEW_PROJECT"
+test "$OLD_RESTART_POLICY" = always
+install -d -m 700 "$UPLOAD_DIR" "$BACKUP_DIR"
+```
+
+Verify that the production container's `/app` mount is a **bind mount from `/root/shabat`**. The baseline uses restart policy `always`; if the final assertion fails, record/review the actual policy instead of blindly changing it. Any extra mounts, external session/database services, symlinks to private storage, a local SQLite database, custom Compose overrides, or additional production writers require a tailored backup before proceeding. This guide covers the inspected file-based layout. Inventory other services/supervisors that could restart the old bot and suspend them for the window. Do not alter the test installation.
+
+Record the old runtime versions while it is still running; this PHP command only reads Composer metadata:
+
+```sh
+docker exec "$OLD_CONTAINER_ID" php -r '
+require "/app/vendor/autoload.php";
+echo "PHP ", PHP_VERSION, PHP_EOL;
+foreach (["danog/madelineproto", "wizardloop/broadcastmanager"] as $name) {
+ echo $name, " ", Composer\InstalledVersions::getPrettyVersion($name), " ",
+ Composer\InstalledVersions::getReference($name), PHP_EOL;
+}' > "$BACKUP_DIR/old-runtime-versions.txt"
+printf '%s\n' "$OLD_CONTAINER_ID" > "$BACKUP_DIR/old-container-id.txt"
+printf '%s\n' "$OLD_PROJECT" > "$BACKUP_DIR/old-compose-project.txt"
+printf '%s\n' "$OLD_IMAGE_ID" > "$BACKUP_DIR/old-image-id.txt"
+printf '%s\n' "$OLD_RESTART_POLICY" > "$BACKUP_DIR/old-restart-policy.txt"
+```
+
+Ensure storage can hold the complete old folder twice, its saved image, the new image and new data/session volumes. Keep these backup files private and outside every Docker build context.
+
+## 3. Download, verify and build the reviewed release beside production
+
+The deployment asset is `shabbat-3.0.0-dev.9.zip`, with files directly at its root and no installed vendor. The separate corresponding-source ZIP is for source inspection; it is not the deployment archive. These URLs become usable after the release is published.
+
+```sh
+RELEASE_URL=https://github.com/WizardLoop/shabbat/releases/download/v3.0.0-dev.9
+curl --fail --location --proto '=https' --tlsv1.2 \
+ "$RELEASE_URL/shabbat-3.0.0-dev.9.zip" -o "$UPLOAD_DIR/shabbat-3.0.0-dev.9.zip"
+curl --fail --location --proto '=https' --tlsv1.2 \
+ "$RELEASE_URL/SHA256SUMS" -o "$UPLOAD_DIR/SHA256SUMS"
+(
+ cd "$UPLOAD_DIR"
+ awk '$2 == "shabbat-3.0.0-dev.9.zip" { print }' SHA256SUMS > deployment.sha256
+ test -s deployment.sha256
+ sha256sum --check deployment.sha256
+)
+install -d -m 700 "$RELEASE_DIR"
+unzip -q "$UPLOAD_DIR/shabbat-3.0.0-dev.9.zip" -d "$RELEASE_DIR"
+test "$(cat "$RELEASE_DIR/VERSION")" = "$VERSION"
+test -f "$RELEASE_DIR/composer.lock"
+test -f "$RELEASE_DIR/build-info.json"
+cp "$RELEASE_DIR/.env.example" "$RELEASE_DIR/.env"
+chmod 600 "$RELEASE_DIR/.env"
+```
+
+If uploading manually, upload the same two release assets to `UPLOAD_DIR` and begin with the checksum block. Do not unpack over the running old folder or copy the old installed vendor into the new source. Retain the supplied `build-info.json`; it records the release build's actual source identity. Do not substitute the historical baseline SHA or the test build's metadata. Leave `BUILD_COMMIT` absent from the new `.env`; the build block also unsets an inherited shell value so a stale test commit cannot override the archive identity.
+
+Edit the **new** `.env` privately. Copy the existing production `API_ID`, `API_HASH`, `BOT_TOKEN` and trusted `ADMIN` values from the old private file without printing them or putting them in shell history. Retain the same bot identity. Add the GeoNames account and current configuration deliberately; [configuration](CONFIGURATION.md) documents every field. Use plain `KEY=value` syntax supported by both Compose and the PHP reader. Set these public/container paths exactly:
+
+```dotenv
+APP_DB_PATH=/app/data/shabbat.sqlite
+DATA_DIR=/app/data
+SESSION_PATH=/app/runtime/bot.shabbat
+HEARTBEAT_PATH=/app/data/scheduler-heartbeat.json
+LEGACY_DATA_PATH=.
+SOURCE_URL=https://github.com/WizardLoop/shabbat/releases/tag/v3.0.0-dev.9
+```
+
+Compose explicitly fixes `DATA_DIR` and `SESSION_PATH`; the other paths above avoid a stale host/test path in `.env`. The release page must expose the matching corresponding-source asset before production service. Do not use test bot credentials. Never share `docker compose config` output without `--quiet`.
+
+```sh
+new_compose() {
+ docker compose --project-name "$NEW_PROJECT" \
+ --project-directory "$RELEASE_DIR" --file "$RELEASE_DIR/docker-compose.yml" "$@"
+}
+if docker volume inspect "${NEW_PROJECT}_bot-data" >/dev/null 2>&1 \
+ || docker volume inspect "${NEW_PROJECT}_madeline-session" >/dev/null 2>&1; then
+ printf '%s\n' 'STOP: this production project already has volumes; inspect the earlier attempt.' >&2
+ exit 1
+fi
+unset BUILD_COMMIT
+new_compose config --quiet
+new_compose build shabbat
+new_compose run --rm --no-deps shabbat php scripts/verify-patches.php
+new_compose run --rm --no-deps shabbat php bin/self-check.php --release
+new_compose run --rm --no-deps shabbat php bin/runtime-permissions.php --require-non-root
+new_compose run --rm --no-deps shabbat php bin/check-calendar.php
+```
+
+These checks use the new image/volumes and leave the old bot running. The calendar check contacts public providers, not Telegram. Stop on any failure; no production cutover has happened yet. Do not run `up`, `php bot.php`, or a session diagnostic against the production credentials during this stage.
+
+## 4. Stop the single production writer and preserve the entire old installation
+
+Choose a weekday maintenance window outside religious/operational closure periods and pending broadcasts, with a group administrator available for recovery. Complete or review old in-flight work first. From this step until the explicit new startup, production is offline.
+
+```sh
+test "$(readlink -f -- "$ORIGINAL_DIR")" = /root/shabat
+test ! -e "$ORIGINAL_DIR/data/shabbat.sqlite"
+docker update --restart=no "$OLD_CONTAINER_ID"
+docker stop --time 120 "$OLD_CONTAINER_ID"
+test "$(docker inspect --format '{{.State.Running}}' "$OLD_CONTAINER_ID")" = false
+test "$(docker inspect --format '{{.State.Restarting}}' "$OLD_CONTAINER_ID")" = false
+docker inspect --format 'exit={{.State.ExitCode}} oom={{.State.OOMKilled}}' "$OLD_CONTAINER_ID"
+install -d -m 700 "$BACKUP_DIR/legacy"
+cp -a -- "$ORIGINAL_DIR/." "$BACKUP_DIR/legacy/"
+(
+ cd "$ORIGINAL_DIR"
+ find . -type f -print0 | sort -z | xargs -0 sha256sum
+) > "$BACKUP_DIR/legacy-files.sha256"
+(
+ cd "$BACKUP_DIR/legacy"
+ sha256sum --check "$BACKUP_DIR/legacy-files.sha256"
+) > "$BACKUP_DIR/legacy-copy-verification.txt"
+docker image save --output "$BACKUP_DIR/old-image.tar" "$OLD_IMAGE_ID"
+sha256sum "$BACKUP_DIR/old-image.tar" > "$BACKUP_DIR/old-image.tar.sha256"
+```
+
+The restart-policy change prevents the old `restart: always` container from returning after a Docker daemon restart during cutover. It does not stop an external supervisor; verify every other writer is stopped too. An OOM/forced termination or a failed snapshot/hash check requires investigation before continuing. The SQLite-absence check deliberately stops this file-based procedure if an application database already exists.
+
+Keep the old stopped container and image. Do not run `down`, remove volumes, prune images, or replace the original folder yet. Back up the protected directory off-server using your private backup mechanism. Its credentials, session, IDs, drafts and permission snapshots must never become release assets or public issue attachments.
+
+## 5. Copy stopped state into separate new volumes, then preview the import
+
+The root user below is used only for controlled offline copying/import, because the protected legacy snapshot may not be readable by UID 10001. The old snapshot is mounted read-only. Nothing is copied into the new image/build context. The normal bot will run as UID/GID 10001 after ownership is set explicitly.
+
+```sh
+new_compose run --rm --no-deps --user 0:0 \
+ --volume "$BACKUP_DIR/legacy:/legacy:ro" shabbat sh -ec '
+ test -z "$(find /app/data -mindepth 1 -maxdepth 1 -print -quit)"
+ test -z "$(find /app/runtime -mindepth 1 -maxdepth 1 -print -quit)"
+ test ! -e /legacy/data/shabbat.sqlite
+ test ! -e /legacy/data/upgrade-backups
+ cp -a /legacy/data/. /app/data/
+ set -- /legacy/bot.shabbat*
+ test -e "$1"
+ cp -a "$@" /app/runtime/
+ mkdir -m 700 /app/data/upgrade-backups
+'
+new_compose run --rm --no-deps --user 0:0 shabbat \
+ php bin/migrate.php --writers-stopped --db /app/data/shabbat.sqlite \
+ > "$BACKUP_DIR/migrate.json"
+new_compose run --rm --no-deps --user 0:0 \
+ --volume "$BACKUP_DIR/legacy:/legacy:ro" shabbat \
+ php bin/import-legacy.php --db /app/data/shabbat.sqlite --legacy /legacy --dry-run \
+ > "$BACKUP_DIR/import-preview.json"
+```
+
+A missing session prefix, non-empty destination volume, unexpected database, unreadable file or importer error is a stop condition. Do not replace these assertions with deletion commands. Inspect the earlier attempt or custom server layout. No real session has been opened by these maintenance commands.
+
+Read the private JSON preview. Compare its unique group IDs and count with the old subscription list, and sample opening/closing messages, buttons, media IDs, alert flags and private drafts against the snapshot. Inspect **every warning and recovery entry**, not only the count. Invalid IDs, malformed JSON, unsupported buttons, oversized drafts and missing originals must be accounted for. A preview can report the expected fixed Jerusalem timing warning; that does not resolve unrelated warnings. The importer covers the whitelist documented in [migration](MIGRATION.md), not arbitrary local additions to the old bot.
+
+Dry-run is supported and does not apply application records. It is not a full transaction rehearsal: some normalized private-draft handling occurs only during apply. The apply operation makes a verified pre-import SQLite backup but the complete legacy import is not one all-or-nothing transaction. If apply fails, keep the new bot stopped, preserve its report/state, and review or restore the verified pre-import database before retrying. Re-import preserves existing records; it does not repair a malformed record by overwriting an existing preference.
+
+## 6. Apply and verify every registered group before starting the bot
+
+After accepting the preview and planning any required permission review:
+
+```sh
+new_compose run --rm --no-deps --user 0:0 \
+ --volume "$BACKUP_DIR/legacy:/legacy:ro" shabbat \
+ php bin/import-legacy.php --db /app/data/shabbat.sqlite --legacy /legacy \
+ --apply --writers-stopped --backup-dir /app/data/upgrade-backups \
+ > "$BACKUP_DIR/import-apply.json"
+```
+
+The next snippet uses the project's real database API to check schema checksums, integrity, foreign keys, exact group-ID equality and one settings row per group. It prints a private inventory for human review and fails on invalid IDs, missing groups, unexpected groups or an empty source list. It does not call Telegram or invent a new application CLI option.
+
+```sh
+new_compose run --rm --no-deps -T --user 0:0 \
+ --volume "$BACKUP_DIR:/review:ro" shabbat php \
+ > "$BACKUP_DIR/import-verification.json" <<'PHP'
+assertCurrentSchema();
+$checks = $db->integrity();
+if ($checks['integrity_check'] !== ['ok'] || $checks['foreign_key_check'] !== []) {
+ throw new RuntimeException('Database integrity or foreign-key check failed.');
+}
+$expected = [];
+foreach (preg_split('/\R/', file_get_contents('/review/legacy/data/DBgroups.txt')) as $line) {
+ $id = trim($line);
+ if ($id === '') { continue; }
+ if (!preg_match('/^-?[0-9]{1,20}$/D', $id)) {
+ throw new RuntimeException('Invalid legacy group identifier requires review.');
+ }
+ $expected[$id] = $id;
+}
+$expected = array_values($expected);
+sort($expected, SORT_STRING);
+$actual = $db->pdo()->query('SELECT group_id FROM groups')->fetchAll(PDO::FETCH_COLUMN);
+sort($actual, SORT_STRING);
+if ($expected === [] || $actual !== $expected) {
+ throw new RuntimeException('Imported group IDs do not exactly match the non-empty legacy list.');
+}
+$settings = $db->pdo()->query('SELECT group_id,mode,language,location_name,timezone_id,candle_minutes,havdalah_method,havdalah_value,close_before_candles_minutes,open_after_havdalah_minutes,times_alert_enabled,close_message_enabled,open_message_enabled,zmanim_enabled,daf_enabled,tefillin_enabled,omer_enabled FROM group_settings ORDER BY group_id')->fetchAll();
+if (count($settings) !== count($expected)) {
+ throw new RuntimeException('Missing group settings.');
+}
+$counts = [];
+foreach (['groups','group_settings','users','message_templates','admin_state','known_broadcast_peers','legacy_imports'] as $table) {
+ $counts[$table] = (int) $db->pdo()->query('SELECT COUNT(*) FROM '.$table)->fetchColumn();
+}
+$recovery = $db->pdo()->query('SELECT group_id,status,snapshot_provenance,last_error FROM group_lock_state ORDER BY group_id')->fetchAll();
+echo Database::encode(['checks'=>$checks,'counts'=>$counts,'group_settings'=>$settings,'permission_review'=>$recovery]), PHP_EOL;
+PHP
+```
+
+Review the settings against the source flags and inspect normalized templates through the backed-up database/private reports. Template totals include both group templates and personal drafts, so they need not equal the number of groups or the preview's group-template count. Legacy-selected groups and saved drafts confer no administrator authority. Keep the original files as evidence even after a successful import.
+
+Preserve a verified post-import SQLite backup without any retention deletion. This snippet calls the existing `BackupService::backup` directly to a new filename; it does not invoke the pruning helper. It also copies the migration/import backups out of the volume and sets ownership on the **new** data/session volumes only.
+
+```sh
+new_compose run --rm --no-deps --user 0:0 shabbat php -r '
+require "/app/vendor/autoload.php";
+$db = Shabbat\Storage\Database::open("/app/data/shabbat.sqlite");
+$result = (new Shabbat\Storage\BackupService())->backup($db, "/app/data/upgrade-backups/post-import.sqlite");
+echo Shabbat\Storage\Database::encode($result), PHP_EOL;
+' > "$BACKUP_DIR/post-import-backup.json"
+install -d -m 700 "$BACKUP_DIR/sqlite"
+new_compose run --rm --no-deps --user 0:0 \
+ --volume "$BACKUP_DIR/sqlite:/export" shabbat sh -ec '
+ cp -a /app/data/upgrade-backups/. /export/
+ chown -R 10001:10001 /app/data /app/runtime
+'
+new_compose run --rm --no-deps shabbat php bin/runtime-permissions.php --require-non-root
+new_compose run --rm --no-deps shabbat php scripts/verify-patches.php
+new_compose run --rm --no-deps shabbat php bin/self-check.php --release
+```
+
+Rehearse the actual restore into a new disposable destination in a maintenance container. It validates the backup and creates an isolated SQLite copy; it does not replace `/app/data/shabbat.sqlite` or open the bot session.
+
+```sh
+new_compose run --rm --no-deps shabbat php bin/restore.php \
+ --backup /app/data/upgrade-backups/post-import.sqlite \
+ --db /tmp/shabbat-restore-rehearsal.sqlite --writers-stopped \
+ > "$BACKUP_DIR/restore-rehearsal.json"
+```
+
+Accept the restore output and the complete source/private-state backup before proceeding. `health.php`, even with `--deep`, also needs recent live scheduler heartbeats; an offline restore cannot satisfy that live-health gate. Use the structural checks above before startup, then the real live probe afterwards.
+
+## 7. Put the reviewed source at `/root/shabat`
+
+Proceed only after all offline checks and backups pass, every import warning is understood, and the administrator has a plan for each recovery entry. The old bot remains stopped. The two renames preserve the complete original directory and move the already-built source into the requested final path; they do not overlay old vendor or data with new files.
+
+```sh
+test "$(docker inspect --format '{{.State.Running}}' "$OLD_CONTAINER_ID")" = false
+test "$(docker inspect --format '{{.HostConfig.RestartPolicy.Name}}' "$OLD_CONTAINER_ID")" = no
+test "$(readlink -f -- "$ORIGINAL_DIR")" = /root/shabat
+case "$(readlink -f -- "$RELEASE_DIR")" in
+ /root/shabbat-releases/*) ;;
+ *) printf '%s\n' 'Unexpected staging path; stop.' >&2; exit 1 ;;
+esac
+case "$RETIRED_DIR" in
+ /root/shabat-retired-*) ;;
+ *) printf '%s\n' 'Unexpected retained-source path; stop.' >&2; exit 1 ;;
+esac
+test ! -e "$RETIRED_DIR"
+mv -T -- "$ORIGINAL_DIR" "$RETIRED_DIR"
+mv -T -- "$RELEASE_DIR" "$ORIGINAL_DIR"
+RELEASE_DIR="$ORIGINAL_DIR"
+cd "$ORIGINAL_DIR"
+new_compose config --quiet
+printf '%s\n' "$RETIRED_DIR" > "$BACKUP_DIR/retired-source-path.txt"
+printf '%s\n' "$NEW_PROJECT" > "$BACKUP_DIR/new-compose-project.txt"
+```
+
+If the second rename fails, stop and inspect both paths. The original is preserved at `RETIRED_DIR`; return it to `/root/shabat` only after verifying that the destination is absent. Do not merge directory contents or use an overwrite operation to conceal a partial cutover.
+
+**The stopped old container still has a bind mount whose source is `/root/shabat`. It must not be started while that path contains the new source.** Its old image/vendor assumptions do not match the new layout. The rollback section restores the old directory before restarting that container.
+
+## 8. Start exactly one production instance and accept it
+
+This step connects to Telegram, sends the existing administrator startup notification, and enables all imported active group behavior. It is not a harmless smoke test. Confirm the bot identity, the reviewed source URL, the maintenance window and the intended permissions once more.
+
+```sh
+test "$(docker inspect --format '{{.State.Running}}' "$OLD_CONTAINER_ID")" = false
+new_compose up -d --no-build shabbat
+new_compose ps
+new_compose logs --tail=120 shabbat
+```
+
+Keep logs private. Wait for completed permission and notification scheduler passes, then run:
+
+```sh
+new_compose exec -T shabbat php bin/health.php --deep
+new_compose ps
+```
+
+Both heartbeat ages must be current; allow initial startup/provider work to complete and investigate repeated failure. A green health probe establishes the checks it reports, not that every group has resolved its imported permission debt. Do not start a second process to investigate session/authentication errors. Stop the new service and inspect the preserved versions/session state first; rollback remains available.
+
+As the configured bot owner, use `/version` or `/source` privately to verify release identity, `/admin` and its diagnostics to inspect registered groups, then `/settings` for a representative group and every group marked for recovery. Existing imported groups need no `/add`. Confirm group count, local time/location, original notification flags and custom message previews without sending a mass broadcast or using `/closegroups` / `/opengroups` as a test.
+
+For **each** unresolved legacy permission record, a current group administrator must follow [permission recovery](PERMISSION_RECOVERY.md): outside protected periods, configure the actual intended normal group permissions in Telegram; refresh the bot's **Resolve permission problem** panel; review the named group; and explicitly choose **Use current permissions as normal**. A legacy/unverified snapshot cannot be used by the restore button. Adoption does not itself send a restriction update. Re-check diagnostics; leave unexplained mismatches unresolved rather than treating generally open permissions as the intended baseline.
+
+Review copied broadcast history and any cancelled/blocked schedule. Do not recreate an interrupted broadcast without checking which messages Telegram already accepted. Retain the old source/session/private backup until a real scheduled close/open cycle and recovery behavior have been accepted.
+
+The permanent production commands are now run **from `/root/shabat`, with the recorded project name**:
+
+```sh
+cd /root/shabat
+docker compose --project-name shabbat-production-dev9 ps
+docker compose --project-name shabbat-production-dev9 logs --tail=120 shabbat
+docker compose --project-name shabbat-production-dev9 exec -T shabbat php bin/health.php --deep
+```
+
+For an intentional later stop, use `docker compose --project-name shabbat-production-dev9 stop shabbat`; for an approved restart, use `docker compose --project-name shabbat-production-dev9 restart shabbat`. The new persistent volumes are `shabbat-production-dev9_bot-data` and `shabbat-production-dev9_madeline-session`. Keep both, the private `.env`, matching source/lock/patches and corresponding-source identity in the normal backup policy. A later SQLite-version upgrade follows the stopped-writer backup/migration instructions, not this legacy import again.
+
+## 9. Roll back without mixing old and new state
+
+Before any new startup, rollback can return the preserved old directory/container directly. **After the new bot has contacted Telegram, rollback also requires a remote-state review:** restoring files does not undo sent messages, updated permissions, broadcast actions or Telegram session changes. Settle any new bot-owned permission changes and stop new activity before restoring older state. Do not copy the new SQLite database or newly written session over the old file-based installation.
+
+If the maintenance attempt stopped **before either rename in step 7**, the original folder is still in place. Verify that no new production service is running and that no retained-source directory was created, then restore the old restart policy and start its existing container:
+
+```sh
+test ! -e "$RETIRED_DIR"
+test "$(readlink -f -- "$ORIGINAL_DIR")" = /root/shabat
+test -z "$(docker ps --filter label=com.docker.compose.project="$NEW_PROJECT" --filter label=com.docker.compose.service=shabbat --quiet)"
+docker update --restart="$OLD_RESTART_POLICY" "$OLD_CONTAINER_ID"
+docker start "$OLD_CONTAINER_ID"
+```
+
+For this pre-swap rollback, stop here. The remaining rollback blocks apply after step 7 replaced the directory.
+
+If the new service was started, stop it and disable automatic restart before touching directories. These commands run in the same session, with `RELEASE_DIR=/root/shabat` after the swap:
+
+```sh
+NEW_CONTAINER_ID="$(new_compose ps -a -q shabbat)"
+if [ -n "$NEW_CONTAINER_ID" ]; then
+ docker update --restart=no "$NEW_CONTAINER_ID"
+fi
+new_compose stop --timeout 120 shabbat
+if [ -n "$NEW_CONTAINER_ID" ]; then
+ test "$(docker inspect --format '{{.State.Running}}' "$NEW_CONTAINER_ID")" = false
+fi
+test "$(docker inspect --format '{{.State.Running}}' "$OLD_CONTAINER_ID")" = false
+```
+
+Preserve the stopped new volumes for diagnosis. The helper command only reads the volumes and writes private tar files outside the source tree; it does not launch the bot. Use a new destination if this rollback capture has already been taken.
+
+```sh
+test ! -e "$BACKUP_DIR/failed-new-state"
+install -d -m 700 "$BACKUP_DIR/failed-new-state"
+new_compose run --rm --no-deps --user 0:0 \
+ --volume "$BACKUP_DIR/failed-new-state:/export" shabbat sh -ec '
+ tar -cpf /export/new-data.tar -C /app/data .
+ tar -cpf /export/new-session.tar -C /app/runtime .
+'
+sha256sum "$BACKUP_DIR/failed-new-state/"*.tar \
+ > "$BACKUP_DIR/failed-new-state/SHA256SUMS"
+```
+
+After reviewing any remote effects, return the preserved old folder to its original path. Do not execute the first rename if the swap never happened; in that case `/root/shabat` already contains the old source and no directory replacement is needed.
+
+```sh
+FAILED_SOURCE_DIR="/root/shabat-failed-$STAMP"
+test "$(readlink -f -- "$ORIGINAL_DIR")" = /root/shabat
+test -d "$RETIRED_DIR"
+case "$RETIRED_DIR" in /root/shabat-retired-*) ;; *) exit 1 ;; esac
+case "$FAILED_SOURCE_DIR" in /root/shabat-failed-*) ;; *) exit 1 ;; esac
+test ! -e "$FAILED_SOURCE_DIR"
+mv -T -- "$ORIGINAL_DIR" "$FAILED_SOURCE_DIR"
+mv -T -- "$RETIRED_DIR" "$ORIGINAL_DIR"
+test -f "$ORIGINAL_DIR/.env"
+test -f "$ORIGINAL_DIR/data/DBgroups.txt"
+docker update --restart="$OLD_RESTART_POLICY" "$OLD_CONTAINER_ID"
+docker start "$OLD_CONTAINER_ID"
+docker logs --tail=120 "$OLD_CONTAINER_ID"
+```
+
+This restarts the exact retained old container/image against its original code, vendor, `.env`, data and pre-cutover session. Do not use `compose up --build` or pull the old mutable image for rollback. If the old image/container or retained directory is missing, stop and restore from the private full-folder/image backup with a separately reviewed recovery procedure; do not improvise a partial rollback.
+
+Keep the failed new source, both new volumes and all reports. A new attempt must start with an inspected state and a fresh stopped snapshot; the existing-destination guards are intentional. If authentication or permissions changed on Telegram during the new run, the old runtime still needs explicit review even though its local bytes were preserved.
+
+## Acceptance record
+
+Record the verified release checksum/commit, the old runtime versions/container/image, backup paths and hashes, import counts/warnings, exact group-ID comparison, schema/integrity/restore results, final Compose project/volume names, live health, and each group's permission-review outcome. Record unresolved limitations honestly. Keep identifiers, session material, private logs, credentials and backup contents outside GitHub and public release assets.
\ No newline at end of file
diff --git a/docs/PUBLICATION_STATUS.md b/docs/PUBLICATION_STATUS.md
new file mode 100644
index 0000000..f906cc0
--- /dev/null
+++ b/docs/PUBLICATION_STATUS.md
@@ -0,0 +1,57 @@
+# Publication and release status
+
+## Current GitHub prerelease
+
+The owner-authorized `v3.0.0-dev.9` publication uses the reviewed merge commit and remains a prerelease. The deployment asset is `shabbat-3.0.0-dev.9.zip`; the separate `shabbat-3.0.0-dev.9-corresponding-source.zip` contains the 57 locked runtime package trees and the preferred-source supplements described below. `SHA256SUMS` and `RELEASE_MANIFEST.json` identify the final archives, source fingerprint and CI evidence. Release `build-info.json` records the actual merge commit and `local_changes=false` only after exact source equality. See [release notes](RELEASE_NOTES.md) and [legacy production cutover](PRODUCTION_UPGRADE.md).
+
+Composer 2.10.3 replaces the old build-tool pin; application dependency versions, approved patch bytes and existing SQL migration bytes are unchanged. The generic developer packager output is not substituted for the scoped runtime source asset. Publishing the GitHub release does not deploy or restart the bot.
+
+## Historical local preparation
+
+The earlier local documentation/public-source preparation of **3.0.0-dev.9** preserved runtime behavior and locked dependencies. That preparation itself created no commit, tag, GitHub release or production deployment. Its historical artifact names and metadata are recorded below; use the current release assets above for deployment.
+
+## Delivery layout
+
+| Path in the local delivery | Contents |
+|---|---|
+| `repo/` | Clean files intended for the repository root; no `.git`, installed `vendor`, actual `.env`, databases, sessions, logs, private backups or generated test caches |
+| `artifacts/shabbat-3.0.0-dev.9-publication.zip` | The clean repository source, including dotfiles, tests, resources, immutable migration sources and reviewed patch baselines, plus public archive-only `build-info.json` required for no-Git Docker builds |
+| `artifacts/shabbat-3.0.0-dev.9-corresponding-source.zip` | Application source plus the 57 locked runtime Composer dependency source trees, their notices and approved local modifications; see the archive's source-scope/index |
+| `artifacts/SHA256SUMS` | SHA-256 hashes for the delivered ZIPs |
+| `checks/` | Sanitized implementation inventory, original-source preservation evidence, executed-check reports, archive manifests and documented blockers |
+
+The archive-only `build-info.json` records the verified original baseline commit, `local_changes=true` and the exact publication-source fingerprint. It is excluded from `repo/` and Git; it neither creates nor claims a new commit. Extract the publication ZIP into a new directory, retain that metadata unchanged, and follow the README. It is a source distribution requiring `composer install` or a Docker build, not a preconfigured bot. The existing `scripts/package.php` remains a separate developer packager that produces its documented three archive types from an installed development tree; do not confuse those names/scope with this publication delivery.
+
+## Corresponding-source scope
+
+The separate source archive preserves application resources, migration/build/install/test scripts, `composer.lock`, licenses, the five active patch sets, exact upstream baselines and patched runtime dependency sources. Runtime dependency versions/references and files are indexed in its manifest.
+
+The locked `amphp/process` package also distributes two Windows helper executables.
+Their MIT-licensed preferred source is included separately under
+`additional-source/amphp-windows-process-wrapper/` at the immutable
+[v1.2.0 source commit](https://github.com/amphp/windows-process-wrapper/tree/8d5aca0c56b73cba460894d79ee83ec558b41027).
+The version association is supported by official package history and matching
+executable Git blobs. A reproducible rebuild of those binaries was not performed.
+
+The source collection also supplements Composer's MadelineProto distribution with
+pinned upstream TL generator/build inputs, current schema/language inputs and a
+required documentation-template input. Original paths, commits and Git-blob/file
+hashes are indexed in the archive. Generators were not executed; upstream
+documentation/translation workflows that query mutable RPC/Weblate data are not
+claimed to be reproducible offline. Historical schema archives and generated
+upstream documentation are outside this collection.
+
+
+Development tool installations are excluded from this source archive. In particular, a distributed PHPStan PHAR is not labeled as preferred source. Development tools remain reproducible through the committed lock, and their license inventory is retained. PHP, SQLite, operating-system packages and Docker base images are external runtime/build dependencies, not bundled source or a distributed image in this delivery. Dockerfile digests, download checksums and upstream references document those external inputs. No claim is made that this ZIP contains the full source of an entire Linux container or every optional development tool.
+
+See [licensing](LICENSING.md), [third-party notices](../THIRD_PARTY_NOTICES.md), [dependency inventory](DEPENDENCIES.md) and the source archive's scope manifest for exact inclusions, omissions and outstanding provenance findings.
+
+## Review boundaries
+
+- The public README pair is `README.md` and `readme-he.md`; there is no competing `README.he.md`.
+- Original application code, the Composer lock, SQL migration bytes, dependency licenses and approved patch/base bytes are preserved. The release inventory and documentation checker use the requested Hebrew filename; provenance metadata uses portable project-relative identifiers.
+- The public `.env.example` keeps the same key/value placeholders and uses comment-free syntax shared by Compose and PHP INI parsing. Explanations are in the configuration guide.
+- Current acceptance and feature gaps are documented in [implementation status](IMPLEMENTATION_STATUS.md); they are not silently fixed or marketed as complete.
+- Exact test results belong to the final separate checks report. Previously recorded dev.9 results do not replace a clean-install check of this copy.
+
+When publishing manually, use the exact reviewed source and preserve its corresponding-source offer. Do not upload private environment/session/data files. The historical local preparation performed no remote publication or production action; the later authorized GitHub prerelease is described at the top of this document.
diff --git a/docs/RELEASE.md b/docs/RELEASE.md
new file mode 100644
index 0000000..501dba4
--- /dev/null
+++ b/docs/RELEASE.md
@@ -0,0 +1,68 @@
+# Release preparation and publication
+
+The version in [VERSION](../VERSION) is **3.0.0-dev.9**. This remains a development snapshot for review and isolated acceptance testing. A clean source archive does not establish production readiness. See [current release notes](RELEASE_NOTES.md), [publication status](PUBLICATION_STATUS.md) and [licensing/source scope](LICENSING.md).
+
+## Source and publication boundary
+
+Prepare files in an isolated copy and keep runtime data separate. For the owner-authorized `v3.0.0-dev.9` prerelease, require green checks on the reviewed PR, mark it ready and merge without rewriting history, then validate the merged commit before creating the matching tag and release assets. No server deployment is part of that process. Release metadata must identify the actual merged commit and matching source fingerprint; the historical preparation baseline alone does not identify local changes.
+
+The two publication artifacts are `shabbat-3.0.0-dev.9.zip` and `shabbat-3.0.0-dev.9-corresponding-source.zip`. The first contains the clean repository; the second adds the locked Composer runtime sources and notices within the scope documented in [LICENSING](LICENSING.md). The release also includes `SHA256SUMS` and `RELEASE_MANIFEST.json`. Local diagnostic `checks/` directories are not copied into the source ZIP. The corresponding-source asset is the separately reviewed runtime collection with preferred-source supplements, not the generic developer packager's installed-tool archive.
+
+## Reproducible checks
+
+Run these from the root of a disposable clean copy, as its nonroot owner with permission to write that copy. It must have no production `.env`, session or database. The Composer installation writes only that copy's dependencies and applies the approved local patches. Do not run installation or patch application against an active bot tree.
+
+```sh
+composer validate --strict --no-check-all
+composer install --no-interaction --prefer-dist
+composer check-platform-reqs
+php scripts/verify-patches.php
+composer test
+composer lint
+composer analyse
+composer style
+composer check-docs
+composer check-locales
+php bin/self-check.php
+php bin/self-check.php --release
+```
+
+`--no-check-all` retains Composer's strict manifest/lock validation while omitting its advice against the intentional exact MadelineProto constraint. Ordinary installation and upgrades use `composer install` with the existing lock. A dependency update is a separate reviewed change. Tests use isolated fixtures; the release self-check enforces the target-platform gate and must not be bypassed when the host SQLite is too old.
+
+Record the locked inventory and current advisory evidence from the same isolated installation:
+
+```sh
+composer show --locked --format=json
+composer licenses --format=json
+composer audit --locked --format=json
+php scripts/dependencies.php
+```
+
+The first three commands display package/license/advisory reports; the advisory command needs network access. `scripts/dependencies.php` writes `docs/dependencies.json` in the copy and validates bundled data/license artifacts. The release workflows use Composer 2.10.3; the installed license report must come from a verified locked installation. Advisory results and license evidence are separate checks.
+
+For a target with Docker, run this from the disposable repository root as a user authorized to use its Docker engine. Compose requires the declared `.env` file even for a build. Create it from the public example in this fresh copy; no live Telegram credentials are needed to build the image. Do not overwrite an existing configured installation's `.env`.
+
+```sh
+cp .env.example .env
+docker compose build --no-cache shabbat
+```
+
+Retain the archive's `build-info.json` so its source identity remains available. For a later Git checkout, supply `BUILD_COMMIT` only from the actual reviewed commit, as described in [installation](INSTALLATION.md). Do not substitute an invented commit or the old baseline for a changed working tree.
+
+This builds the configured `shabbat` service image, installs runtime dependencies, verifies all patches and runs the image's offline platform/filesystem checks. It does not start a Telegram bot. A passed local PHP suite is not evidence that this image build or a remote CI run passed.
+
+## Archive review
+
+Inspect the final ZIP contents and hashes, not only its staging directory. Extract the application ZIP into a fresh disposable directory and repeat installation from `composer.lock`, patch verification and the relevant checks. Verify both README files, public configuration, required dotfiles, tests/fixtures, resources, migrations, all patch artifacts and license notices are present. Check local links and anchors, external assets, case-sensitive filenames and secret patterns.
+
+Exclude `.git`, actual `.env` files, sessions, application/runtime databases and WAL/SHM files, backups, logs, caches, coverage and private work material. Do not exclude required JSON/TXT fixtures or baselines by extension. Do not copy test-created state back into an archive. The runtime corresponding-source manifest must name every included package and deliberate exclusion; it is not a complete source archive for the Docker base image, operating system or development toolchain.
+
+The existing `php scripts/package.php` command is available to maintainers with a development installation. Run it from the isolated repository root as the file owner: it verifies patches and writes three archive variants, manifests and checksums to `dist/`. Its dependency input is the installed `vendor` distribution. In particular, including a tool PHAR is not evidence that the tool's preferred source was bundled. The dedicated publication artifacts have a narrower runtime source scope and their own manifest.
+
+## Before changing a live installation
+
+Complete target-image/platform acceptance, disposable-group Telegram permission capture/readback/restoration, shutdown/restart, inline/GPS/client rendering, rich messages, custom emoji, broadcasts, command menus and Stars acceptance as applicable. Run a wall-clock soak and exercise backup/restore and rollback. These are separate from this local preparation and are not performed by packaging.
+
+Use the [legacy production cutover guide](PRODUCTION_UPGRADE.md), [upgrade/import guide](MIGRATION.md) and [backup/restore guide](BACKUP_RESTORE.md). Stop every writer before migration, retain matching code/database/session/BroadcastManager state backups, apply the numbered migrations and validate the target runtime before startup. Dev.9 requires migration 016; older unresolved permission snapshots still need the documented provenance-aware recovery. Do not use a test bot's database as production state.
+
+Before a later authorized deployment, make the exact corresponding source available and set `SOURCE_URL` accordingly. Development version labels and archived source fingerprints must remain truthful until the outstanding acceptance work is complete.
diff --git a/docs/RELEASE_NOTES.md b/docs/RELEASE_NOTES.md
new file mode 100644
index 0000000..2e38618
--- /dev/null
+++ b/docs/RELEASE_NOTES.md
@@ -0,0 +1,47 @@
+# WizardLoop Shabbat 3.0.0-dev.9
+
+PHP 8.3 compatibility: module-delivery bookkeeping now uses the database-owned `BEGIN IMMEDIATE` transaction state and exact connection identity; native callback/message fixtures initialize inherited readonly properties from their declaring class. Regression tests cover rejected foreign/outside transactions, rollback and state reset.
+
+**GitHub prerelease `v3.0.0-dev.9`.** This version retains MadelineProto, SQLite and the existing bot features. The publication follow-up fixes Composer tooling, protects immutable source bytes and documents legacy deployment; it does not turn offline acceptance into production certification. See [publication status](PUBLICATION_STATUS.md), [production upgrade](PRODUCTION_UPGRADE.md) and [CHANGELOG](../CHANGELOG.md).
+
+## Release corrections
+
+Composer 2.10.3 is pinned in both workflows and the Docker build, including the official digest. It addresses the upstream [Actions token compatibility/disclosure issue](https://github.com/composer/composer/security/advisories/GHSA-f9f8-rm49-7jv2) while retaining authentication and every validation gate. Git attributes and regression coverage preserve migration, bundled-text and captured-fixture bytes; no existing migration or patch was rewritten. The release assets identify the actual merged commit and separately inventory the application/runtime corresponding source.
+
+## Current implementation
+
+Dev.9 adds transport-time calendar and quiet-hour checks after provider, media, topic and emoji preflight; actual Tefillin spacing/daily caps; conservative handling of uncertain delivery evidence; and coalescing of never-dispatched reminder backlogs. Omer checks its restoration gap again at delivery. Configured closing notices remain an explicit exception to ordinary protected-period notification suppression.
+
+Permission reconciliation, notifications and scheduled broadcasts use independent non-overlapping callbacks. Permission work has bounded concurrency; notification planning uses fair bounded batches. Shared provider requests and short failure cooldowns reduce repeated failed work. Queue/heartbeat metrics distinguish notification activity from completed permission work, and bounded maintenance preserves unresolved recovery and uncertain delivery evidence.
+
+The reviewed BroadcastManager patch adds optional authorization, cancellation and admission hooks, cancellation-aware waits, joined worker/progress futures and cancelled queue persistence. Manual and scheduled bulk operations share one admission queue. The dependency keeps its native retry/error handling, with native flood waits exposed to manager-controlled retry. [Delivery and scheduler details](DEV9_DELIVERY_SAFETY.md) explain limits and exceptions.
+
+## Dependencies and migrations
+
+- Application version: `3.0.0-dev.9` from `VERSION`.
+- MadelineProto: exactly `8.7.0`, commit `7b99dd0f2d964a492c377912ffce9f5ce61aa9ed`.
+- BroadcastManager: locked `3.2.2`, commit `f2792a383416b66bb68e55f09414563e540538d6`, with the reviewed local derivative.
+- Five active patch sets: four MadelineProto compatibility patches and one BroadcastManager set. Exact baselines, prior approved sources, diffs, added source and manifests remain included.
+- Migration `016_delivery_safety.sql` is required. Migrations 001โ015 and `composer.lock` remain preserved. Startup validates schema; migration is an explicit maintenance action.
+
+See [dependency evidence](DEPENDENCIES.md), [patch workflow](PATCHES.md) and [technical attribution](THIRD_PARTY_DETAILS.md).
+
+## Upgrade and rollback
+
+Stop all writers, preserve verified application/database, MadelineProto session and BroadcastManager-state backups, then apply the migrations before startup. Follow [MIGRATION](MIGRATION.md) for the full legacy import; additive `DBgroups.txt` registration alone does not import the old templates, states or active permission obligations. Existing unresolved snapshots are retained and remain subject to the [verified permission recovery procedure](PERMISSION_RECOVERY.md).
+
+Rollback restores matching pre-upgrade code and state while stopped. Older code cannot be assumed to understand the new schema. Review unresolved permission obligations before reverting. The [backup/restore guide](BACKUP_RESTORE.md) describes the separate state stores.
+
+## Publication contents
+
+The clean repository ZIP contains application source, resources, tests, migrations, scripts, locked Composer metadata, public configuration, Docker files, documentation, notices and required GitHub files. The separate corresponding-source ZIP adds the 57 Composer runtime packages and licenses, including the reviewed patched sources. Development tool distributions and external Docker/system runtime sources are outside that archive's stated scope. [LICENSING](LICENSING.md) explains source offers, retained grants and evidence limitations.
+
+The publication README files are complete English and Hebrew counterparts. Technical hashes/history are kept in manifests and the provenance companion, leaving the third-party notices concise. Historical license grants and upstream source headers are preserved. No production configuration, session, database, WAL/SHM, private backup or log belongs in these artifacts.
+
+## Verification limits
+
+Exact current results and artifact identities belong to the external `checks/PUBLICATION_REPORT.md`, manifests and SHA-256 file, with a public summary in [PUBLICATION_STATUS](PUBLICATION_STATUS.md). Historical test counts are not reused as results for this publication copy. The local Windows PHP runtime's SQLite 3.45.2 is below the release gate. The Dockerfile pins checksum-verified SQLite 3.51.3; exact hosted matrix/image results belong to the release report and linked Actions runs.
+
+Offline tests use controlled clocks, isolated data and fake transports. They do not establish live Telegram delivery capacity, real flood quotas, accepted-request cancellation, rendered client behavior, actual Stars payments or a long wall-clock soak. A request already accepted by Telegram cannot be recalled by a local timing check. Unperformed live acceptance remains visible in the report; no production migration or bot restart is part of GitHub publication.
+
+MadelineProto's metadata/header license difference and historical local-reference provenance findings remain documented in [THIRD_PARTY_NOTICES](../THIRD_PARTY_NOTICES.md). Source packaging does not silently resolve them. The owner-authorized GitHub publication creates the reviewed merge, tag and prerelease assets. Server deployment remains a separate operator action.
diff --git a/docs/SERVER_TEST_DEPLOYMENT.md b/docs/SERVER_TEST_DEPLOYMENT.md
new file mode 100644
index 0000000..4a0756c
--- /dev/null
+++ b/docs/SERVER_TEST_DEPLOYMENT.md
@@ -0,0 +1,61 @@
+# Deploy the publication source to an isolated test server
+
+Use a separate test bot and the existing `shabbat-test` Compose project in `/root/shabbat-test`. These are operator instructions; publication preparation does not execute them. Keep `.env`, named data/session volumes and private backups. The supplied publication ZIP contains source without installed dependencies; Docker installs the locked runtime dependencies and patches during the build.
+
+For a new test server, follow [installation](INSTALLATION.md) first. For the older file-based bot, follow [explicit legacy migration](MIGRATION.md); updating an existing SQLite test installation is a different operation.
+
+## Upload
+
+From a local shell opened in the delivered `shabbat-publication/artifacts` folder, with SSH/SCP access to the test server, replace `SERVER_IP`:
+
+```sh
+scp shabbat-3.0.0-dev.9-publication.zip SHA256SUMS root@SERVER_IP:/root/shabbat-test/
+```
+
+This copies the source ZIP and checksum file to an already existing directory. It does not stop or start the bot. The corresponding-source archive is a separate source-offer artifact; it is not required for this Docker installation command.
+
+## Update the existing test deployment
+
+Run on Linux from the test directory with Docker access. Stop at the first error. Preserve a private backup of data/session/library state before cutover; the migration step also creates and verifies its application-database backup.
+
+```sh
+set -eu
+cd /root/shabbat-test
+sha256sum -c SHA256SUMS --ignore-missing
+docker compose -p shabbat-test stop shabbat
+unzip -o shabbat-3.0.0-dev.9-publication.zip
+chmod 600 .env
+docker compose -p shabbat-test config --quiet
+docker compose -p shabbat-test build shabbat
+docker compose -p shabbat-test run --rm --no-deps shabbat php scripts/verify-patches.php
+docker compose -p shabbat-test run --rm --no-deps shabbat php bin/self-check.php --release
+docker compose -p shabbat-test run --rm --no-deps shabbat php bin/runtime-permissions.php --require-non-root
+docker compose -p shabbat-test run --rm --no-deps shabbat php -r 'if (is_dir("/app/data/backups") === false && mkdir("/app/data/backups", 0700, true) === false) { exit(1); }'
+docker compose -p shabbat-test run --rm --no-deps shabbat php bin/migrate.php --writers-stopped --backup-dir /app/data/backups --db /app/data/shabbat.sqlite
+docker compose -p shabbat-test run --rm --no-deps shabbat php bin/check-calendar.php
+docker compose -p shabbat-test up -d --force-recreate shabbat
+docker compose -p shabbat-test logs --tail=120 shabbat
+```
+
+The final `up` command starts the configured bot and its networked actions. Earlier build/check/migration commands do not send Telegram messages. An existing database needs the backup directory; a new database initializes its schema. The runtime requires all 16 migration sources and rejects changed applied checksums.
+
+After the first completed scheduler cycle:
+
+```sh
+docker compose -p shabbat-test exec -T shabbat php bin/health.php --deep
+docker compose -p shabbat-test ps
+```
+
+Verify `/source` reports `3.0.0-dev.9` and the expected source identity. Retain the archive build metadata: its commit is the original baseline, explicitly accompanied by local changes and this publication fingerprint, not a new publication commit. Docker refuses missing or mismatched archive identity. Provide the exact accessible source URL before public operation.
+
+## Existing permission problems and acceptance
+
+1. Outside a protected period and without an active lock, review the group's normal rights in Telegram. An older overly permissive snapshot cannot reconstruct restrictions that were never saved correctly.
+2. In group settings, open **Resolve permission problem**, refresh, inspect the displayed group/rights, then confirm the intended supported action. Adopting current permissions preserves the earlier evidence and does not itself change Telegram rights.
+3. In a disposable group, disable media, invitations or pinning before closing/opening. Verify exact restoration, repeat after restart and check scheduled transitions separately. Include forum/non-forum groups if used.
+4. Review `/testzmanim GROUP_ID` and its JSON-file export when investigating; avoid repeated forced restoration or deleting lock-state records.
+5. Watch both heartbeat ages, oldest due jobs and delivery delay. A running process alone does not prove the queue is keeping up.
+
+Do not use `down -v`, copy test data into production or run both versions on one session/database. [Rollback](BACKUP_RESTORE.md) requires matching code, data and session/library state; it cannot undo Telegram actions already accepted.
+
+The publication checks report records local results and limits. Docker image build and live Telegram acceptance must be performed on the target test server when unavailable locally. See [delivery safety](DEV9_DELIVERY_SAFETY.md) and [publication status](PUBLICATION_STATUS.md).
diff --git a/docs/TEFILLIN_ACKNOWLEDGEMENTS.md b/docs/TEFILLIN_ACKNOWLEDGEMENTS.md
new file mode 100644
index 0000000..88e8037
--- /dev/null
+++ b/docs/TEFILLIN_ACKNOWLEDGEMENTS.md
@@ -0,0 +1,41 @@
+# Tefillin acknowledgements and inherited buttons
+
+Group tefillin reminders receive a blue native **ืื ืืชื (0)** button by default, with the supplied tefillin custom emoji icon. When animated icons are unavailable, the fallback shows a basic ๐ before the same current label/count. The active custom label remains unchanged. The label follows the group's language in Hebrew, English, Russian, French, Spanish or Brazilian Portuguese. Each identified Telegram account can add its acknowledgement and press again to remove it. The public count belongs to that exact delivered reminder message. A private callback response tells the person whether their own mark was added or removed.
+
+Group managers can switch acknowledgements on/off and set a plain one-line label of up to 48 UTF-16 units. The count is appended automatically. A null/default label selects the current group language. Labels and other keyboard buttons are preserved for an already delivered round; settings changes apply to subsequently prepared reminders.
+
+## Button inheritance
+
+Migration `012_tefillin_acknowledgements.sql` adds `message_templates.buttons_mode`:
+
+- `inherit`: use the module's default buttons while preserving customized text, entities and media.
+- `custom`: use the saved custom keyboard; do not add the default acknowledgement button.
+- `none`: explicitly remove buttons; do not restore defaults implicitly.
+
+Existing empty button arrays migrate to `inherit`. Every existing nonempty keyboard, including native markup objects, migrates to `custom`. Recognized full native/Bot API keyboard wrappers are normalized to row arrays when read or imported, preserving every button action/style/icon and the original stored/source bytes where already present. Malformed recognized wrappers fail explicitly. The template editor must write the mode explicitly for custom/remove/default actions. Text-only templates may inherit default buttons. Contradictory nonempty custom buttons with an inherit/none mode are rejected by the repository.
+
+## Delivery binding and persistence
+
+Preparing a persisted tefillin job creates one random callback token and stores the original keyboard, language, label and settings revision. Retrying the same prepared job uses the same token. A legitimately replanned unsent job with a changed settings revision receives a fresh token; an already delivered round cannot change identity.
+
+A round becomes clickable only after the successful delivery is durably recorded. Registration re-reads the actual job and its successful delivery log and verifies the outgoing message IDs with `SentMessageIds`. It also verifies that the stored prepared payload contains the round token. A job with several verified message IDs gets independent counters for each message. Missing/wrong-peer/incoming/unknown message IDs cannot create a binding.
+
+Votes, callback replay protection and counter revisions commit in one SQLite immediate transaction. The unique key prevents a user from being counted twice for the same round and message. A redelivered Telegram callback is idempotent, while a new click toggles the user's vote. Tokens are additionally bound to the group and message. Bot/self, anonymous, unidentified and inline actors are rejected. Group removal or disabling the module/acknowledgements stops further voting.
+
+The database stores the account IDs required for unique voting and hashed Telegram callback IDs. It does not publish a voter list. Counts survive normal process restart and database restore. The usual database backup/retention policy applies to this operational data.
+
+## Markup edits and recovery
+
+An edit changes only reply markup; the reminder's text, entities, caption and media stay intact. The service updates its own counter button and preserves every other keyboard entry. Per-message edits are coalesced across service instances sharing the same process/database. A newer count is sent only after the older in-flight edit finishes, preventing old completion order from overwriting the newest count.
+
+The normal application single-instance writer lock remains required; do not run two live bot processes against the same database/session. No database transaction is held during a Telegram request.
+
+Confirmed edits persist the rendered revision. A restart can flush pending revisions without losing votes. Flood-wait and other retry deadlines remain in effect even when more users click. Automatic retries are bounded to five attempts with backoff; a later valid user action can resume an exhausted update after its deadline. Intentional Amp cancellation propagates and leaves pending work recoverable. Each call sends at most three successive coalesced updates, then leaves any remaining work for the next ordinary tick.
+
+The runtime should call pending edits only after priority permission work and must honor shutdown before a markup RPC. Do not send a replacement reminder when a count edit fails.
+
+## Tests
+
+The focused tests use actual scheduled jobs and durable delivery logs. They cover six languages, custom labels, custom/removed/inherited keyboard modes, rich message preservation, previous-schema migration, stable token preparation, revision replanning, verified outgoing IDs, independent messages/rounds, unique toggles, callback redelivery and cross-context rejection, invalid actors, disabled groups, transaction rollback, asynchronous concurrent edits, flood waits, bounded errors, cancellation, file-backed restart and separate SQLite connections.
+
+No real Telegram group, message or callback was changed during these tests. Visual custom emoji entitlement and actual group delivery remain staging checks.
diff --git a/docs/THIRD_PARTY_DETAILS.md b/docs/THIRD_PARTY_DETAILS.md
new file mode 100644
index 0000000..b9226e8
--- /dev/null
+++ b/docs/THIRD_PARTY_DETAILS.md
@@ -0,0 +1,76 @@
+# Third-party technical provenance
+
+This companion to [THIRD_PARTY_NOTICES](../THIRD_PARTY_NOTICES.md) retains technical identities, modification history and open findings. The five checked-in patch manifests are authoritative for exact source, output, prerequisite and diff SHA-256 values. Historical hashes below identify earlier reviewed artifacts, not the current output unless stated.
+
+## Original project and reference
+
+The historical WizardLoop Shabbat baseline is commit `2976ab8339f489d933c7246e0cb27f3b0e4aa697`, Copyright (c) 2024 WizardLoop, originally MIT. Its [complete original grant](../LICENSES/SHABBAT-LEGACY-MIT.txt) remains unchanged. The current application uses AGPL-3.0-only without altering historical grants.
+
+The JewishPulse interface comparison used commit `d7b06eb1acb206543dcff35a7c14ebce4760b4e5` under AGPL-3.0-only. [FEATURE_PARITY](FEATURE_PARITY.md) records that relationship. No JewishPulse graphic branding or hardcoded bot links are imported.
+
+Local reference path labels in the patch provenance identify historical project exports, not files required at installation and not public download URLs. Publication preparation removes the workstation's absolute home/Desktop prefix while retaining project/file identity, hashes and all license findings. Baselines, reviewed PHP, diffs, historical source and approved target hashes are unchanged.
+
+## Active patch identity
+
+All four MadelineProto patch sets target version **8.7.0**, commit `7b99dd0f2d964a492c377912ffce9f5ce61aa9ed`. The BroadcastManager set targets **3.2.2**, commit `f2792a383416b66bb68e55f09414563e540538d6`.
+
+| Set | Upstream baseline SHA-256 | Current approved output SHA-256 |
+|---|---|---|
+| [UpdateLoop](../patches/madelineproto/manifest.json) | `2fbcbb73822258102837f53f4a231b87526d66809be7acd6339156ea778a8c2f` | `8f6cb1a483c879024bddea289e90d4bf2d71dd15716102bc83a1f34a243782e4` |
+| [GeoPoint](../patches/madelineproto-geopoint/manifest.json) | `9ed8054caade6abc96420b0f5b120bec029ee2bc7b7b6aacca56fb7ddc61a27b` | `5633cc441e541e5e919dfa8156192587a68e578e49a496acac91172be5f2549d` |
+| [MessageEntity factory](../patches/madelineproto-formatted-date/manifest.json) | `06a9d438cc198f56cfb745a2a3808932269033671146cf82f84ae88e8d6eae60` | `b265ed707326e1d71c7a8bdf8291f0b8c94a153b98a2c4c45c776fec3b374005` |
+| [PeerHandler](../patches/madelineproto-fresh-peer/manifest.json) | `3e1af3624f2256f53264fe4256475d0a5f353af8761cc9716133b7f0412538d2` | `8188777fc376f2b3cca39963d79f85239fc84141f978128ea2bb591f03891928` |
+| [BroadcastManager](../patches/broadcastmanager/manifest.json) | `2a9a42dc6e3f6d7b51c178e7d9db962a5b5ac424046af18afcfcdb12d41dc6e8` | `d665b24009aabfb5b09e00edad1271f1501bf0ef797d5a2593129da91c8cc435` |
+
+### UpdateLoop cancellation and recovery
+
+[The patch review](../patches/madelineproto/README.md) identifies a retained historical PHP patch script and a reproduced reference diff. The script's SHA-256 is `1ec2691498e878d28ca8e9cde94b00c803d9ccba789096ffc8471156dfb429e9`; identical copies were found in local project exports `newsearch-v2` and `psy new/release-r21-work`. The reference diff is reproduced from that script, not claimed to be a historical Git diff.
+
+The applied derivative adds shutdown/authorization guards and delayed recovery to the reference cancellation checks. Direct timeouts and timeout-derived cancellation can recover; unrelated cancellation is rethrown and a stopped loop stops. The immutable baseline, script, reference diff, applied diff and manifest remain bundled. The historical standalone script has no license header: its hash verifies identity, not an independently recovered license grant.
+
+### Fractional coordinates
+
+The [GeoPoint correction](../patches/madelineproto-geopoint/README.md) changes the two longitude/latitude property types and annotations from integer to float, matching Telegram double coordinates. Access hashes, accuracy radius and surrounding serialization remain unchanged. It is separate from the historical UpdateLoop work; the pinned original file, reviewed output, diff and manifest are retained.
+
+### Formatted-date entities
+
+The [factory/class correction](../patches/madelineproto-formatted-date/README.md) adds the native/Bot API factory branches and a new independently authored `FormattedDate` wrapper. Native timestamps, UTF-16 offsets/lengths and flags remain represented; the locked schema and complete upstream license accompany the baseline and diff. The new class SHA-256 is `ffe03d0d78215bc42968cd836c25b1f8a602f161fb55eba189b4102d9b99d5e5`; its copyright is WizardLoop 2026 and its grant is AGPL-3.0-or-later. The unmodified upstream license SHA-256 is `57c8ff33c9c0cfc3ef00e650a1cc910d7ee479a8bc509f6c9209a7c2a11399d6`, identical to the root AGPL text.
+
+Two historical exports share script SHA-256 `c3091f61a401d8022b8ef1d228cf8c5f31a1094982de69a0be108e21885cb967` and wrapper SHA-256 `a9e3146e49a88bec469e67e53f5e27cffb1b5127cde547590f2e385ef02dd264`. Their project declarations conflict: MIT in `wizardloop/theisraelis`, proprietary in `psychedelize/psy-bot-suite`. No standalone grant or recoverable Git history was found. Those original exports are not redistributed; [the investigation](MADELINE_FORMATTED_DATE.md) preserves the finding and explains the new implementation's source basis.
+
+### Fresh full-peer response evidence
+
+The [PeerHandler correction](../patches/madelineproto-fresh-peer/README.md), reviewed 2026-10-16, preserves the public `getFullInfo` signature and exposes per-call raw response evidence when full-info caching is disabled. This supports current permission validation without trusting a stale combined chat/cache record. It is a new local correction, not attributed to a recovered historical patch or an upstream fix. Its baseline was recorded as matching the pinned remote and installed source; original AGPL/copyright headers remain intact.
+
+### BroadcastManager history and current lifecycle changes
+
+The [BroadcastManager review](../patches/broadcastmanager/README.md) records three stages:
+
+| Stage | Reviewed-source SHA-256 | Scope |
+|---|---|---|
+| dev.3 | `3d9425f7911b12c69faae5e87954db134dc3fb7c2f7d654a72f972ebeb84e424` | Per-instance status/counter/control presentation. |
+| dev.4 through dev.8 | `08db35c271f671041257ebe79d1010cdd7c33c4d5868e4553c78612f9e8bf0f3` | Optional message-transport hook at native text/media/album/edit/status sites. |
+| dev.9, current | `d665b24009aabfb5b09e00edad1271f1501bf0ef797d5a2593129da91c8cc435` | Runtime authorization/cancellation/admission hooks, cancellation-aware waits, worker/progress joining, cancelled queue persistence and bounded completed progress history. |
+
+The older statement that the patch did not change delivery lifecycle applies to the historical presentation stage only. The current derivative includes lifecycle behavior. The native library retains separate persistent state; null optional hooks preserve native call paths, while current future joining is part of the reviewed implementation. The application requests native flood-wait errors for manager-controlled retry. Exact original source, both prior approved sources and all guarded transition diffs remain in the manifest. Unknown intermediate bytes fail verification.
+
+## Data provenance
+
+- **Hebcal.com, CC-BY-4.0:** [projected Jerusalem fixtures](../tests/Fixtures/hebcal-jerusalem-provenance.json) record retrieval dates, exact queries, removed fields, normalization and hashes. Dates, times and offsets were not changed. [Default complete-response provenance](../tests/Fixtures/hebcal-jerusalem-default-full-provenance.json) and [complete runtime-response provenance](../tests/Fixtures/hebcal-full-runtime-provenance.json) describe the unprojected captures; all response fields remain in those fixtures.
+- **Omer counts, Hebcal.com, CC-BY-4.0:** [resource metadata](../resources/templates/omer-counts.json) retains the exact query and 2026-10-13 retrieval for all 49 Hebrew/English counts. `OmerText` identifies the basic Ashkenaz counting-suffix modification. [Localized explanations](../resources/templates/omer-counts-localized.json), credited to Hebcal.com and WizardLoop Shabbat, retain CC-BY-4.0 and are separate from Hebrew ritual wording; no runtime-generated prayer or translated blessing is claimed.
+- **Omer transcription/editorial resource, Hebrew Wikisource contributors, CC-BY-SA-4.0:** retrieved 2026-10-13 from [Ashkenaz revision 3017054](https://he.wikisource.org/w/index.php?title=ืกืืืืจ/ื ืืกื_ืืฉืื ื/ืกืคืืจืช_ืืขืืืจ&oldid=3017054), [Sephard revision 2929975](https://he.wikisource.org/w/index.php?title=ืกืืจ_ืกืคืืจืช_ืืขืืืจ&oldid=2929975), [Psalm 67 revision 1014815](https://he.wikisource.org/w/index.php?title=ืชืืืื_ืกื&oldid=1014815) and [Ana Bekoach revision 3085666](https://he.wikisource.org/w/index.php?title=ืื ื_ืืื&oldid=3085666). [Resource metadata](../resources/templates/omer-liturgy.json) records vowel/cantillation removal, divine-name presentation, punctuation/line normalization and the ืจืืื variant. Separate Ashkenaz/Sephard preparations/endings, Harachaman, Psalm 67, Ana Bekoach and daily sefira are included. The ancient underlying text and the separately licensed modern transcription/editorial resource have distinct scopes.
+- **GeoNames.org, CC-BY-4.0:** [provider attribution and terms](https://www.geonames.org/about.html); operator API access terms/quotas are separate. No demo account is bundled.
+
+The [retained grants](../LICENSES/README.md) identify the official Creative Commons text endpoints. Application AGPL licensing does not replace these data grants. Old remote-thumbnail provenance remains unestablished; current inline results do not rely on it. No third-party graphic asset was added.
+
+## Amp Windows process helper source
+
+The locked `amphp/process` runtime package retains its two upstream Windows helper
+executables and notices. Its official [binary update record](https://github.com/amphp/process/commit/6810aacc5d7049d9db87e8161e2ef0e66caa1472)
+identifies wrapper **v1.2.0**; the executable Git blobs match those at the locked
+package revision. The separate corresponding-source collection adds the
+[preferred source at commit 8d5aca0c56b73cba460894d79ee83ec558b41027](https://github.com/amphp/windows-process-wrapper/tree/8d5aca0c56b73cba460894d79ee83ec558b41027)
+under `additional-source/amphp-windows-process-wrapper/`, including its original
+MIT license. Exact source and executable hashes are in the archive's scope
+manifest and the separate checks evidence. No reproducible binary rebuild is
+claimed, and the Linux Docker runtime does not execute those Windows helpers.
\ No newline at end of file
diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md
new file mode 100644
index 0000000..eb676d7
--- /dev/null
+++ b/docs/TROUBLESHOOTING.md
@@ -0,0 +1,13 @@
+# Troubleshooting
+
+* **A previous week's Shabbat appears:** verify provider event dates and cache coverage in group status. Runtime decisions use explicit ranges and UTC snapshots, not `state_open.txt`.
+* **A group remained locked:** inspect the stored lock status, snapshot checksum and last error. Reconciliation retries based on current desired state beyond six minutes. A manual permission conflict requires administrator resolution; never reset by enabling all permissions or deleting the snapshot.
+* **SQLite locked:** ensure only one runtime writer is active, and maintenance uses the same writer-lock path. Transactions are short; avoid running old and new bots against the same application state.
+* **Invalid/missing timezone:** choose an IANA identifier in the location panel; changing the zone does not change latitude/longitude. Missing DST hours are skipped.
+* **Inline disabled:** BotFather `/setinline` must be enabled. The private `/city TOKEN query` alternative still works. GPS permission is not needed for name search.
+* **GeoNames unavailable:** set the operator's username and enable webservices. HTTP 200 status errors include account/quota failures. Known cities or explicit coordinates remain available.
+* **Patch verification fails:** check the exact locked MadelineProto source hash. Do not edit vendor using a broad replacement, skip verification, or apply during runtime. Unknown/partial sources deliberately stop the build.
+* **Health failed:** inspect scheduler heartbeat age and database integrity, not only whether PHP exists. Use `/testzmanim` and group status for current calendar state.
+* **Unpublished development source:** configure SOURCE_URL only once the exact source package is available. No upstream link is represented as the source of local unpublished changes.
+
+No maintenance command in this task was run against production or Telegram credentials.
diff --git a/docs/USER_EXPERIENCE.md b/docs/USER_EXPERIENCE.md
new file mode 100644
index 0000000..9f336c1
--- /dev/null
+++ b/docs/USER_EXPERIENCE.md
@@ -0,0 +1,73 @@
+# User experience in dev.7
+
+## Opening and language
+
+The Hebrew welcome text is:
+
+> **โก๏ธ ืฉืืืจ ืฉืืช**
+> ืืื ื ืฉืืช ืืื, ืืื ื ืืืื, ืืฃ ืืืื ืืกืคืืจืช ืืขืืืจ, ืืคื ืืืืงืื ืฉืื.
+>
+> ืืืจื ืื ืืืฆืื, ืื ืคืชืื ืืช ืืืขืืคืืช ืฉืืื ืื ืืืื ืืฉืคื ืืืืืงืื.
+>
+> ืื ืืื ืงืืืฆืืช ืืืืืื ืื ืื ืื ืงืืืฆื ืืจื ืดืืงืืืฆืืช ืฉืืืด.
+
+The same menu is available in Hebrew, English, Russian, French, Spanish and Brazilian Portuguese. **My preferences** changes the private user's language, location and calendar methods. A group's language and location are configured independently. **My groups** lists registered groups for which the current user passes a fresh administrator check.
+
+Buttons navigate by editing the originating private bot panel. A fresh command can open a panel; callback navigation, questions and previews continue in that panel. A deleted or unavailable panel produces a callback notice without a replacement-message loop. Opening another task cancels competing text prompts, so a later ordinary message cannot accidentally become a former broadcast draft, subscriber search or donation amount.
+
+## Simple settings
+
+Open a category, choose a setting, then select a preset or **Other**. A custom value uses one plain question. Time inputs use the group's local clock; day choices use translated weekday names. Current values use readable on/off, city custom, named methods and translated permission labels.
+
+The Shabbat mode button opens four choices. Timings, message design and announcements have their own submenus. Saving or cancelling returns to the originating category or editor page. Settings still require a named-context preview and confirmation. Internal flow tokens stay out of the ordinary questions. Existing advanced commands remain available for compatibility.
+
+The tefillin category exposes days, fixed times, optional intervals, the permitted window, sunset offsets, maximum per day, spacing, chol hamoed, Tisha B'Av policy, sunset safety, quiet hours, rich message design, previous-reminder deletion and acknowledgement settings. An absent interval or quiet time is displayed as off. The documented city custom applies to candle lighting.
+
+See [group settings](GROUP_SETTINGS.md) for the complete settings and safety behavior.
+
+## Locations and sharing
+
+City searches accept ordinary city names in the inline bot. The private location menu also supports a place identifier, coordinates, a timezone search and reply-bound Telegram GPS sharing. A location or timezone preview names the personal or group context before confirmation. A timezone change preserves coordinates.
+
+The **Share** button opened from an administrator's private group-calendar view creates a temporary read-only snapshot through inline mode. That snapshot uses the named group's location, language, calendar convention and selected date. Its flow is bound to the initiating user and checked group revision, and the shared text names the group.
+
+The default Share buttons attached to delivered group announcements retain the original public inline queries (shabat/zmanim/daf). Those public queries use the person who opens them and that person's preferences; they do not carry the group's coordinates. Use the private group view's Share action when the shared result must preserve group settings. Neither path saves settings or sends an administrative instruction.
+
+## Calendar screens
+
+Shabbat and holiday notices group relevant calendar information around the selected local sequence: civil and Hebrew dates, Torah portion, available Shabbat Mevarchim and holiday metadata, every candle-lighting event, final havdalah and applicable group close/reopen times. Consecutive festival/Shabbat dates preserve one continuous closure interval. Each timestamp retains its actual local date and UTC offset.
+
+Shabbat and holiday times use a single overview with no previous/next controls, the nearest Shabbat-containing period and verified current/upcoming observances starting at the current local date. A separate weekday Yom Tov before Shabbat remains visible with its verified entrance and final exit. Later Shabbat summaries, scripture-reference rows and source/update footers are omitted; local dates use Y-m-d H:i without numeric UTC offsets. The selected candle/exit policy remains visible. Only Hebcal yomtov events receive festival entrance/exit times. Daily times and Daf Yomi have date navigation and sharing. Daily times have short/full views. Omer is an inactive successful result outside the counting season. Provider failures remain explicit; missing operational times are never guessed.
+
+## Message design and buttons
+
+The rich editor preserves text, Telegram entities, reusable media and validated native button styles. Guided questions keep the original private panel. Candidate content is previewed before explicit save, and cancelling leaves the stored design unchanged. The button editor offers a simple label/URL path plus examples for advanced original-parser and JSON input.
+
+A named-group **TEST** action has a separate confirmation and rate limit. Its preview/test acknowledgement control is inert. A delivered tefillin reminder's counter is bound to verified delivery IDs and cannot be activated by a preview.
+
+Text-only designs can inherit module buttons. **Custom** uses the saved keyboard. **None** explicitly removes buttons. See [tefillin acknowledgements](TEFILLIN_ACKNOWLEDGEMENTS.md) for persistence, concurrency and button inheritance.
+
+## Animated emoji and colors
+
+The supplied twelve custom emoji identifiers are registered centrally by menu purpose: brand, settings, support, information, source, groups, add-to-group, Shabbat/holidays, tefillin, daily times, Daf and Omer. Text uses native custom-emoji entities and supported buttons use native style icons. Main actions use primary blue, routine navigation is neutral, and red marks cancellation, disabled states or destructive actions. Valid user-designed styles remain supported.
+
+Telegram entitlement and client support determine actual animation. The transport resolves official basic-emoji alternatives and retries once after a definite custom-emoji/Premium rejection, preserving request identities and message content. The operator can set CUSTOM_EMOJI_ENABLED=0 for basic emoji from startup. Calendar calculations and permissions do not depend on animation.
+
+## Support and administration
+
+Support retains the six Stars presets and adds **Other** for whole amounts from 1 through 10,000. The amount prompt edits the current panel. Invoice intents bind payer, exact amount and XTR currency; authenticated payment updates record each charge once. Navigating away while invoice export is waiting prevents an old result from repainting the new screen.
+
+The administration panel adds delegated capabilities, a searchable subscriber directory, statistics, advanced broadcast settings, system links and tracking links. Group-administrator status alone does not grant access. See [administration](ADMIN_PANEL.md).
+
+Tefillin acknowledgement counts describe users' own per-message marks. They do not verify a physical action. Default controls appear only after verified reminder delivery, follow the group's language, and respect explicit custom/removed button modes.
+
+## Testing and deployment
+
+Automated checks use SQLite, actual native message/query types, recorded provider replies and fake transports, including real Amp suspension points for races. They cover role revocation, old/replaced/cancelled flows, wrong contexts, UTF-16 formatting, payment replay, migration rollback and competing-prompt cleanup.
+
+Actual Telegram client appearance, animation entitlement, live payments, real broadcasts and the Docker image still require isolated server acceptance. Use the [test deployment guide](SERVER_TEST_DEPLOYMENT.md); dev.6 retains all twelve dev.5 migrations. Keep stopped-writer backup/schema verification; installations older than dev.4 apply any missing migrations 009-012.
+
+
+## Recovery and manager diagnostics
+
+The group panel includes a reviewed [permission recovery flow](PERMISSION_RECOVERY.md). The private manager command has an editable [diagnostics panel](DIAGNOSTICS.md), with next/previous, a clickable page counter, search, filters and JSON document export. These controls are available in all six languages.
diff --git a/docs/ZMANIM_METHODS.md b/docs/ZMANIM_METHODS.md
new file mode 100644
index 0000000..b2be932
--- /dev/null
+++ b/docs/ZMANIM_METHODS.md
@@ -0,0 +1,43 @@
+# Calculation methods
+
+Times depend on location, timezone, calendar scheme and chosen method; this is not personal halachic advice.
+
+Candle lighting uses either city custom or an explicit integer 1โ120 minutes before sunset. Presets are exactly 10, 15, 18, 20, 22, 30 and 40. Hebcal defaults are Jerusalem 40, Haifa/Zikhron Yaakov 30, elsewhere 18. An API check of the legacy Jerusalem `b=18` query returned the same 40-minute result as city default; importing groups preserves that actual behavior.
+
+Havdalah method/value are separate. Degrees 8.5 uses the provider's `tzeit85deg`; it is never represented by integer 8 or converted to minutes. Supported fixed minute values include 42, 50, 72 and validated custom minutes after sunset. The 72-minute option is explicitly fixed minutes, without implying all Rabbeinu Tam interpretations are equivalent.
+
+Operational `close_before_candles_minutes` and `open_after_havdalah_minutes` affect group permissions only. Screens display candles, lock, havdalah and open independently. Calendar events require structured `yomtov=true`; a generic holiday is not prohibited work. Adjacent festival/Shabbat civil days form one interval, so intermediate candles never trigger opening.
+
+The calendar is fetched separately with explicit `i=on/off`, avoiding a location-derived override of Israel/diaspora custom. Solar requests use exact coordinates and timezone with an explicit batch range. Missing required solar values stop automatic permission changes and reminders. No polar fallback is invented.
+
+Civil dates select Daf Yomi. Omer evening uses the next civil date's structured Omer event; after midnight the current date continues that night. Counting progresses through non-send days. Night messages wait until chosen nightfall and quiet/lock end; daytime completion is separate, disabled by default and omits the blessing.
+
+Sources: [Hebcal calendar](https://www.hebcal.com/home/195/jewish-calendar-rest-api), [zmanim](https://www.hebcal.com/home/1663/zmanim-halachic-times-api), [candle/havdalah](https://www.hebcal.com/home/4463/candle-lighting-havdalah-fast-times).
+
+Current `is_shabbat` and `is_yom_tov` flags are calculated from active individual day segments. A merged festival/Shabbat sequence keeps one final opening time; its union of event metadata does not imply that every day in the sequence is both Shabbat and Yom Tov. `quiet_now` includes the configured early operational closure, even before the religious interval begins.
+
+Omer phase is explicit: day, twilight or night. At sunset the next civil date's structured Omer event becomes the display count, but nighttime delivery waits for the chosen nightfall. After midnight the current civil date still identifies that counting night until verified dawn. Fixed times before noon are anchored on the following civil morning of the night being planned; a time beyond dawn does not create a night reminder. Optional daytime completion is a separate no-blessing behavior and is suppressed during the group's religious/operational quiet period. Night retry expiry never passes dawn.
+
+Tefillin starts no earlier than the selected provider misheyakir and ends strictly before its effective cutoff. Observed Tish'a B'Av dates are taken from the provider's event date, including postponed fasts; a separate mincha policy requires verified mincha time. PHP weekday values stored by the UI (Sunday=0) are converted once to the planner's ISO weekday convention.
+
+Provider responses validate ordered civil-date coverage, event dates, complete solar series and explicit timezone offsets. Solar nulls are retained for unavailable calculations; missing required values stop operations. Persistent raw-cache fallback on a Hebcal refresh error exposes stale status and its original fetch time, and still passes the same coverage checks. The timestamp of a new response is never treated as evidence of a current calendar range.
+
+The 49 Hebrew/English count texts in `resources/templates/omer-counts.json` come from the attributed Hebcal dataset (CC-BY-4.0). The supported `ashkenaz`/`sephard` forms use ba-omer/la-omer suffix variants. Every locale preserves the authentic Hebrew count, including weeks and remaining days. French, Spanish, Russian and Brazilian Portuguese add a bundled static explanatory summary from `omer-counts-localized.json`; English retains Hebcal's summary. These summaries explain the count rather than replacing Hebrew ritual wording. No runtime language model or translation service composes prayers.
+
+Full night form additionally includes the traditional blessing with localized eligibility wording; optional Ashkenaz preparation or the selected Sephard Leshem Yichud preparation; Harachaman; Psalm 67; Ana Bekoach; and the traditional concluding prayer with that day's sefira. Ashkenaz uses the short conclusion, while Sephard uses its extended conclusion. `sephard` here identifies the documented Nusach Sefard sequence; it is not a claim to support all Edot HaMizrach/Yemenite/custom variants. Day completion omits the blessing, preparation and additions. The application frames the night reminder with date, location, day number and relevant time separately.
+
+`resources/templates/omer-liturgy.json` retains attribution, license, retrieval date and four immutable source revisions: [Ashkenaz 3017054](https://he.wikisource.org/w/index.php?title=ืกืืืืจ/ื ืืกื_ืืฉืื ื/ืกืคืืจืช_ืืขืืืจ&oldid=3017054), [Sephard sequence 2929975](https://he.wikisource.org/w/index.php?title=ืกืืจ_ืกืคืืจืช_ืืขืืืจ&oldid=2929975), [Psalm 67 1014815](https://he.wikisource.org/w/index.php?title=ืชืืืื_ืกื&oldid=1014815), and [Ana Bekoach 3085666](https://he.wikisource.org/w/index.php?title=ืื ื_ืืื&oldid=3085666), checked 2026-10-13. It is a separately attributed CC-BY-SA-4.0 transcription of traditional liturgy: vowel points/cantillation removed, punctuation/line breaks normalized, divine name displayed as ืืณ, and Ana Bekoach uses the source's documented ืจืืื alternative. Those pages' incomplete numbered count list was never used. No modern Sefaria English translation was copied. Automated tests cover all 49 days, six locales, exact variant distinctions/order, week transitions and Telegram text length; this does not represent personal rabbinic approval of an individual's blessing eligibility.
+
+The current tefillin reminder start method is explicitly Hebcal `misheyakir`, 11.5 degrees below the morning horizon. The provider also returns `misheyakirMachmir` (10.2 degrees), but the current reminder form does not offer a selector for that second method. A missing/null selected misheyakir blocks scheduling; the code does not substitute dawn/sunrise or a guessed value. See the [provider method definitions](https://www.hebcal.com/home/1663/zmanim-halachic-times-api).
+
+Internal language codes are `he`, `en`, `ru`, `fr`, `es`, `pt-br`. Calendar-provider requests map English to Hebcal `s` and Brazilian Portuguese to `pt`; the other four map directly. The [official language documentation](https://www.hebcal.com/home/4534/jewish-calendar-event-language-support) lists the Hebrew/English/Russian/French/Spanish options; the [official calendar form](https://www.hebcal.com/ical/) and a public `lg=pt` API request on 2026-10-13 confirmed Portuguese titles. `title_orig` retains the original event name used for fast/day classification, with original English `title` fallback when the provider omits a redundant original field. A display language change cannot change event restrictions.
+
+## Complete solar response contract (dev.3)
+
+Hebcal's times object combines daily solar series, empty elevation-only series and sparse monthly lunar events. The parser explicitly recognizes supported daily fields; seaLevelSunrise/seaLevelSunset, molad/Kiddush Levanah and unknown future extensions are not used as daily operational schedules. They do not make a valid daily response incomplete.
+
+Sunset must exist and cover the requested dates. Every present supported daily series still needs complete coverage and strict timestamp/date/IANA validation; explicit null remains unavailable. The engine refuses missing selected sunrise/dawn/nightfall/sunset as required by each operation. The preceding-night chatzotNight exception remains bounded and separate. No missing operational time is synthesized.
+
+Full unprojected fixtures for the exact default Jerusalem coordinates retain all 42 original fields and their raw metadata. They cover the real response shape that dev.2's seven-field projection missed. See the fixture provenance beside the files in tests/Fixtures.
+
+For live read-only verification, run php bin/check-calendar.php --date=2026-10-15. It uses the actual public HTTP/calendar/solar pipeline, reports separate stages and safe reason codes, and does not open the live application database, load .env, authenticate Telegram or change permissions. October Omer is correctly reported inactive; in-season tests verify the actual provider's 49 counts and sunset/midnight phases.
diff --git a/docs/composer-audit.json b/docs/composer-audit.json
new file mode 100644
index 0000000..22fecbe
--- /dev/null
+++ b/docs/composer-audit.json
@@ -0,0 +1,4 @@
+{
+ "advisories": [],
+ "abandoned": []
+}
diff --git a/docs/composer-licenses.json b/docs/composer-licenses.json
new file mode 100644
index 0000000..49353f9
--- /dev/null
+++ b/docs/composer-licenses.json
@@ -0,0 +1,507 @@
+{
+ "name": "wizardloop/shabbat",
+ "version": "dev-main",
+ "license": [
+ "AGPL-3.0-only"
+ ],
+ "dependencies": {
+ "amphp/amp": {
+ "version": "v3.1.3",
+ "license": [
+ "MIT"
+ ]
+ },
+ "amphp/byte-stream": {
+ "version": "v2.1.2",
+ "license": [
+ "MIT"
+ ]
+ },
+ "amphp/cache": {
+ "version": "v2.0.1",
+ "license": [
+ "MIT"
+ ]
+ },
+ "amphp/dns": {
+ "version": "v2.4.1",
+ "license": [
+ "MIT"
+ ]
+ },
+ "amphp/file": {
+ "version": "v3.2.0",
+ "license": [
+ "MIT"
+ ]
+ },
+ "amphp/hpack": {
+ "version": "v3.2.2",
+ "license": [
+ "MIT"
+ ]
+ },
+ "amphp/http": {
+ "version": "v2.1.2",
+ "license": [
+ "MIT"
+ ]
+ },
+ "amphp/http-client": {
+ "version": "v5.3.6",
+ "license": [
+ "MIT"
+ ]
+ },
+ "amphp/http-client-cookies": {
+ "version": "v2.0.0",
+ "license": [
+ "MIT"
+ ]
+ },
+ "amphp/http-server": {
+ "version": "v3.4.7",
+ "license": [
+ "MIT"
+ ]
+ },
+ "amphp/log": {
+ "version": "v2.0.0",
+ "license": [
+ "MIT"
+ ]
+ },
+ "amphp/mysql": {
+ "version": "v3.1.1",
+ "license": [
+ "MIT"
+ ]
+ },
+ "amphp/parallel": {
+ "version": "v2.4.0",
+ "license": [
+ "MIT"
+ ]
+ },
+ "amphp/parser": {
+ "version": "v1.1.1",
+ "license": [
+ "MIT"
+ ]
+ },
+ "amphp/pipeline": {
+ "version": "v1.2.7",
+ "license": [
+ "MIT"
+ ]
+ },
+ "amphp/postgres": {
+ "version": "v2.2.1",
+ "license": [
+ "MIT"
+ ]
+ },
+ "amphp/process": {
+ "version": "v2.1.0",
+ "license": [
+ "MIT"
+ ]
+ },
+ "amphp/redis": {
+ "version": "v2.0.4",
+ "license": [
+ "MIT"
+ ]
+ },
+ "amphp/serialization": {
+ "version": "v1.1.0",
+ "license": [
+ "MIT"
+ ]
+ },
+ "amphp/socket": {
+ "version": "v2.4.1",
+ "license": [
+ "MIT"
+ ]
+ },
+ "amphp/sql": {
+ "version": "v2.1.1",
+ "license": [
+ "MIT"
+ ]
+ },
+ "amphp/sql-common": {
+ "version": "v2.0.4",
+ "license": [
+ "MIT"
+ ]
+ },
+ "amphp/sync": {
+ "version": "v2.3.0",
+ "license": [
+ "MIT"
+ ]
+ },
+ "amphp/websocket": {
+ "version": "v2.0.4",
+ "license": [
+ "MIT"
+ ]
+ },
+ "amphp/websocket-client": {
+ "version": "v2.0.2",
+ "license": [
+ "MIT"
+ ]
+ },
+ "bacon/bacon-qr-code": {
+ "version": "v3.1.1",
+ "license": [
+ "BSD-2-Clause"
+ ]
+ },
+ "danog/async-orm": {
+ "version": "1.1.4",
+ "license": [
+ "Apache-2.0"
+ ]
+ },
+ "danog/better-prometheus": {
+ "version": "0.1.1",
+ "license": [
+ "Apache-2.0"
+ ]
+ },
+ "danog/dns-over-https": {
+ "version": "1.0.1",
+ "license": [
+ "MIT"
+ ]
+ },
+ "danog/ipc": {
+ "version": "1.0.1",
+ "license": [
+ "MIT"
+ ]
+ },
+ "danog/libdns-json": {
+ "version": "0.2.1",
+ "license": [
+ "MIT"
+ ]
+ },
+ "danog/loop": {
+ "version": "1.1.1",
+ "license": [
+ "MIT"
+ ]
+ },
+ "danog/madelineproto": {
+ "version": "8.7.0",
+ "license": [
+ "AGPL-3.0-only"
+ ]
+ },
+ "danog/primemodule": {
+ "version": "1.0.13",
+ "license": [
+ "AGPL-3.0-only"
+ ]
+ },
+ "danog/telegram-entities": {
+ "version": "1.0.5",
+ "license": [
+ "Apache-2.0"
+ ]
+ },
+ "danog/tg-dialog-id": {
+ "version": "2.0.0",
+ "license": [
+ "Apache-2.0"
+ ]
+ },
+ "danog/tg-file-decoder": {
+ "version": "1.0.2",
+ "license": [
+ "AGPL-3.0-only"
+ ]
+ },
+ "dasprid/enum": {
+ "version": "1.0.7",
+ "license": [
+ "BSD-2-Clause"
+ ]
+ },
+ "daverandom/libdns": {
+ "version": "v2.1.0",
+ "license": [
+ "MIT"
+ ]
+ },
+ "kelunik/certificate": {
+ "version": "v1.1.3",
+ "license": [
+ "MIT"
+ ]
+ },
+ "league/uri": {
+ "version": "7.8.1",
+ "license": [
+ "MIT"
+ ]
+ },
+ "league/uri-components": {
+ "version": "7.8.1",
+ "license": [
+ "MIT"
+ ]
+ },
+ "league/uri-interfaces": {
+ "version": "7.8.1",
+ "license": [
+ "MIT"
+ ]
+ },
+ "monolog/monolog": {
+ "version": "3.12.1",
+ "license": [
+ "MIT"
+ ]
+ },
+ "myclabs/deep-copy": {
+ "version": "1.14.0",
+ "license": [
+ "MIT"
+ ]
+ },
+ "nikic/php-parser": {
+ "version": "v5.9.0",
+ "license": [
+ "BSD-3-Clause"
+ ]
+ },
+ "paragonie/constant_time_encoding": {
+ "version": "v3.1.3",
+ "license": [
+ "MIT"
+ ]
+ },
+ "paragonie/random_compat": {
+ "version": "v9.99.100",
+ "license": [
+ "MIT"
+ ]
+ },
+ "phar-io/manifest": {
+ "version": "2.0.4",
+ "license": [
+ "BSD-3-Clause"
+ ]
+ },
+ "phar-io/version": {
+ "version": "3.2.1",
+ "license": [
+ "BSD-3-Clause"
+ ]
+ },
+ "phpseclib/phpseclib": {
+ "version": "3.0.57",
+ "license": [
+ "MIT"
+ ]
+ },
+ "phpstan/phpstan": {
+ "version": "2.3.1",
+ "license": [
+ "MIT"
+ ]
+ },
+ "phpunit/php-code-coverage": {
+ "version": "12.5.8",
+ "license": [
+ "BSD-3-Clause"
+ ]
+ },
+ "phpunit/php-file-iterator": {
+ "version": "6.0.2",
+ "license": [
+ "BSD-3-Clause"
+ ]
+ },
+ "phpunit/php-invoker": {
+ "version": "6.0.0",
+ "license": [
+ "BSD-3-Clause"
+ ]
+ },
+ "phpunit/php-text-template": {
+ "version": "5.0.1",
+ "license": [
+ "BSD-3-Clause"
+ ]
+ },
+ "phpunit/php-timer": {
+ "version": "8.0.0",
+ "license": [
+ "BSD-3-Clause"
+ ]
+ },
+ "phpunit/phpunit": {
+ "version": "12.5.39",
+ "license": [
+ "BSD-3-Clause"
+ ]
+ },
+ "promphp/prometheus_client_php": {
+ "version": "v2.15.1",
+ "license": [
+ "Apache-2.0"
+ ]
+ },
+ "psr/http-factory": {
+ "version": "1.1.0",
+ "license": [
+ "MIT"
+ ]
+ },
+ "psr/http-message": {
+ "version": "2.0",
+ "license": [
+ "MIT"
+ ]
+ },
+ "psr/log": {
+ "version": "3.0.2",
+ "license": [
+ "MIT"
+ ]
+ },
+ "revolt/event-loop": {
+ "version": "v1.0.9",
+ "license": [
+ "MIT"
+ ]
+ },
+ "sebastian/cli-parser": {
+ "version": "4.2.1",
+ "license": [
+ "BSD-3-Clause"
+ ]
+ },
+ "sebastian/comparator": {
+ "version": "7.1.8",
+ "license": [
+ "BSD-3-Clause"
+ ]
+ },
+ "sebastian/complexity": {
+ "version": "5.0.0",
+ "license": [
+ "BSD-3-Clause"
+ ]
+ },
+ "sebastian/diff": {
+ "version": "7.0.1",
+ "license": [
+ "BSD-3-Clause"
+ ]
+ },
+ "sebastian/environment": {
+ "version": "8.1.2",
+ "license": [
+ "BSD-3-Clause"
+ ]
+ },
+ "sebastian/exporter": {
+ "version": "7.0.3",
+ "license": [
+ "BSD-3-Clause"
+ ]
+ },
+ "sebastian/global-state": {
+ "version": "8.0.3",
+ "license": [
+ "BSD-3-Clause"
+ ]
+ },
+ "sebastian/lines-of-code": {
+ "version": "4.0.1",
+ "license": [
+ "BSD-3-Clause"
+ ]
+ },
+ "sebastian/object-enumerator": {
+ "version": "7.0.0",
+ "license": [
+ "BSD-3-Clause"
+ ]
+ },
+ "sebastian/object-reflector": {
+ "version": "5.0.0",
+ "license": [
+ "BSD-3-Clause"
+ ]
+ },
+ "sebastian/recursion-context": {
+ "version": "7.0.1",
+ "license": [
+ "BSD-3-Clause"
+ ]
+ },
+ "sebastian/type": {
+ "version": "6.0.4",
+ "license": [
+ "BSD-3-Clause"
+ ]
+ },
+ "sebastian/version": {
+ "version": "6.0.1",
+ "license": [
+ "BSD-3-Clause"
+ ]
+ },
+ "squizlabs/php_codesniffer": {
+ "version": "4.0.4",
+ "license": [
+ "BSD-3-Clause"
+ ]
+ },
+ "staabm/side-effects-detector": {
+ "version": "1.0.5",
+ "license": [
+ "MIT"
+ ]
+ },
+ "symfony/polyfill-mbstring": {
+ "version": "v1.43.0",
+ "license": [
+ "MIT"
+ ]
+ },
+ "symfony/polyfill-php83": {
+ "version": "v1.43.0",
+ "license": [
+ "MIT"
+ ]
+ },
+ "theseer/tokenizer": {
+ "version": "2.0.1",
+ "license": [
+ "BSD-3-Clause"
+ ]
+ },
+ "webmozart/assert": {
+ "version": "1.12.1",
+ "license": [
+ "MIT"
+ ]
+ },
+ "wizardloop/broadcastmanager": {
+ "version": "3.2.2",
+ "license": [
+ "AGPL-3.0-only"
+ ]
+ }
+ }
+}
diff --git a/docs/composer-show.json b/docs/composer-show.json
new file mode 100644
index 0000000..3ff3f31
--- /dev/null
+++ b/docs/composer-show.json
@@ -0,0 +1,751 @@
+{
+ "locked": [
+ {
+ "name": "amphp/amp",
+ "direct-dependency": true,
+ "homepage": "https://amphp.org/amp",
+ "source": "https://github.com/amphp/amp/tree/v3.1.3",
+ "version": "v3.1.3",
+ "description": "A non-blocking concurrency framework for PHP applications.",
+ "abandoned": false
+ },
+ {
+ "name": "amphp/byte-stream",
+ "direct-dependency": false,
+ "homepage": "https://amphp.org/byte-stream",
+ "source": "https://github.com/amphp/byte-stream/tree/v2.1.2",
+ "version": "v2.1.2",
+ "description": "A stream abstraction to make working with non-blocking I/O simple.",
+ "abandoned": false
+ },
+ {
+ "name": "amphp/cache",
+ "direct-dependency": false,
+ "homepage": "https://amphp.org/cache",
+ "source": "https://github.com/amphp/cache/tree/v2.0.1",
+ "version": "v2.0.1",
+ "description": "A fiber-aware cache API based on Amp and Revolt.",
+ "abandoned": false
+ },
+ {
+ "name": "amphp/dns",
+ "direct-dependency": false,
+ "homepage": "https://github.com/amphp/dns",
+ "source": "https://github.com/amphp/dns/tree/v2.4.1",
+ "version": "v2.4.1",
+ "description": "Async DNS resolution for Amp.",
+ "abandoned": false
+ },
+ {
+ "name": "amphp/file",
+ "direct-dependency": true,
+ "homepage": "https://github.com/amphp/file",
+ "source": "https://github.com/amphp/file/tree/v3.2.0",
+ "version": "v3.2.0",
+ "description": "Non-blocking access to the filesystem based on Amp and Revolt.",
+ "abandoned": false
+ },
+ {
+ "name": "amphp/hpack",
+ "direct-dependency": false,
+ "homepage": "https://github.com/amphp/hpack",
+ "source": "https://github.com/amphp/hpack/tree/v3.2.2",
+ "version": "v3.2.2",
+ "description": "HTTP/2 HPack implementation.",
+ "abandoned": false
+ },
+ {
+ "name": "amphp/http",
+ "direct-dependency": false,
+ "homepage": null,
+ "source": "https://github.com/amphp/http/tree/v2.1.2",
+ "version": "v2.1.2",
+ "description": "Basic HTTP primitives which can be shared by servers and clients.",
+ "abandoned": false
+ },
+ {
+ "name": "amphp/http-client",
+ "direct-dependency": true,
+ "homepage": "https://amphp.org/http-client",
+ "source": "https://github.com/amphp/http-client/tree/v5.3.6",
+ "version": "v5.3.6",
+ "description": "An advanced async HTTP client library for PHP, enabling efficient, non-blocking, and concurrent requests and responses.",
+ "abandoned": false
+ },
+ {
+ "name": "amphp/http-client-cookies",
+ "direct-dependency": false,
+ "homepage": "https://github.com/amphp/http-client-cookies",
+ "source": "https://github.com/amphp/http-client-cookies/tree/v2.0.0",
+ "version": "v2.0.0",
+ "description": "Automatic cookie handling for Amp's HTTP client.",
+ "abandoned": false
+ },
+ {
+ "name": "amphp/http-server",
+ "direct-dependency": false,
+ "homepage": "https://github.com/amphp/http-server",
+ "source": "https://github.com/amphp/http-server/tree/v3.4.7",
+ "version": "v3.4.7",
+ "description": "A non-blocking HTTP application server for PHP based on Amp.",
+ "abandoned": false
+ },
+ {
+ "name": "amphp/log",
+ "direct-dependency": false,
+ "homepage": "https://github.com/amphp/log",
+ "source": "https://github.com/amphp/log/tree/v2.0.0",
+ "version": "v2.0.0",
+ "description": "Non-blocking logging for PHP based on Amp, Revolt, and Monolog.",
+ "abandoned": false
+ },
+ {
+ "name": "amphp/mysql",
+ "direct-dependency": false,
+ "homepage": null,
+ "source": "https://github.com/amphp/mysql/tree/v3.1.1",
+ "version": "v3.1.1",
+ "description": "Asynchronous MySQL client for PHP based on Amp.",
+ "abandoned": false
+ },
+ {
+ "name": "amphp/parallel",
+ "direct-dependency": false,
+ "homepage": "https://github.com/amphp/parallel",
+ "source": "https://github.com/amphp/parallel/tree/v2.4.0",
+ "version": "v2.4.0",
+ "description": "Parallel processing component for Amp.",
+ "abandoned": false
+ },
+ {
+ "name": "amphp/parser",
+ "direct-dependency": false,
+ "homepage": "https://github.com/amphp/parser",
+ "source": "https://github.com/amphp/parser/tree/v1.1.1",
+ "version": "v1.1.1",
+ "description": "A generator parser to make streaming parsers simple.",
+ "abandoned": false
+ },
+ {
+ "name": "amphp/pipeline",
+ "direct-dependency": false,
+ "homepage": "https://amphp.org/pipeline",
+ "source": "https://github.com/amphp/pipeline/tree/v1.2.7",
+ "version": "v1.2.7",
+ "description": "Asynchronous iterators and operators.",
+ "abandoned": false
+ },
+ {
+ "name": "amphp/postgres",
+ "direct-dependency": false,
+ "homepage": "https://amphp.org",
+ "source": "https://github.com/amphp/postgres/tree/v2.2.1",
+ "version": "v2.2.1",
+ "description": "Asynchronous PostgreSQL client for Amp.",
+ "abandoned": false
+ },
+ {
+ "name": "amphp/process",
+ "direct-dependency": false,
+ "homepage": "https://amphp.org/process",
+ "source": "https://github.com/amphp/process/tree/v2.1.0",
+ "version": "v2.1.0",
+ "description": "A fiber-aware process manager based on Amp and Revolt.",
+ "abandoned": false
+ },
+ {
+ "name": "amphp/redis",
+ "direct-dependency": false,
+ "homepage": "https://amphp.org/redis",
+ "source": "https://github.com/amphp/redis/tree/v2.0.4",
+ "version": "v2.0.4",
+ "description": "Efficient asynchronous communication with Redis servers, enabling scalable and responsive data storage and retrieval.",
+ "abandoned": false
+ },
+ {
+ "name": "amphp/serialization",
+ "direct-dependency": false,
+ "homepage": "https://github.com/amphp/serialization",
+ "source": "https://github.com/amphp/serialization/tree/v1.1.0",
+ "version": "v1.1.0",
+ "description": "Serialization tools for IPC and data storage in PHP.",
+ "abandoned": false
+ },
+ {
+ "name": "amphp/socket",
+ "direct-dependency": false,
+ "homepage": "https://github.com/amphp/socket",
+ "source": "https://github.com/amphp/socket/tree/v2.4.1",
+ "version": "v2.4.1",
+ "description": "Non-blocking socket connection / server implementations based on Amp and Revolt.",
+ "abandoned": false
+ },
+ {
+ "name": "amphp/sql",
+ "direct-dependency": false,
+ "homepage": "https://amphp.org",
+ "source": "https://github.com/amphp/sql/tree/v2.1.1",
+ "version": "v2.1.1",
+ "description": "Asynchronous SQL client for Amp.",
+ "abandoned": false
+ },
+ {
+ "name": "amphp/sql-common",
+ "direct-dependency": false,
+ "homepage": "https://amphp.org",
+ "source": "https://github.com/amphp/sql-common/tree/v2.0.4",
+ "version": "v2.0.4",
+ "description": "Common classes for non-blocking SQL implementations.",
+ "abandoned": false
+ },
+ {
+ "name": "amphp/sync",
+ "direct-dependency": false,
+ "homepage": "https://github.com/amphp/sync",
+ "source": "https://github.com/amphp/sync/tree/v2.3.0",
+ "version": "v2.3.0",
+ "description": "Non-blocking synchronization primitives for PHP based on Amp and Revolt.",
+ "abandoned": false
+ },
+ {
+ "name": "amphp/websocket",
+ "direct-dependency": false,
+ "homepage": "https://github.com/amphp/websocket",
+ "source": "https://github.com/amphp/websocket/tree/v2.0.4",
+ "version": "v2.0.4",
+ "description": "Shared code for websocket servers and clients.",
+ "abandoned": false
+ },
+ {
+ "name": "amphp/websocket-client",
+ "direct-dependency": false,
+ "homepage": null,
+ "source": "https://github.com/amphp/websocket-client/tree/v2.0.2",
+ "version": "v2.0.2",
+ "description": "Async WebSocket client for PHP based on Amp.",
+ "abandoned": false
+ },
+ {
+ "name": "bacon/bacon-qr-code",
+ "direct-dependency": false,
+ "homepage": "https://github.com/Bacon/BaconQrCode",
+ "source": "https://github.com/Bacon/BaconQrCode/tree/v3.1.1",
+ "version": "v3.1.1",
+ "description": "BaconQrCode is a QR code generator for PHP.",
+ "abandoned": false
+ },
+ {
+ "name": "danog/async-orm",
+ "direct-dependency": false,
+ "homepage": null,
+ "source": "https://github.com/danog/AsyncOrm/tree/1.1.4",
+ "version": "1.1.4",
+ "description": "Async ORM based on AMPHP v3 and fibers.",
+ "abandoned": false
+ },
+ {
+ "name": "danog/better-prometheus",
+ "direct-dependency": false,
+ "homepage": null,
+ "source": "https://github.com/danog/better-prometheus/tree/0.1.1",
+ "version": "0.1.1",
+ "description": "A better Prometheus library for PHP applications",
+ "abandoned": false
+ },
+ {
+ "name": "danog/dns-over-https",
+ "direct-dependency": false,
+ "homepage": "https://github.com/danog/dns-over-https",
+ "source": "https://github.com/danog/dns-over-https/tree/1.0.1",
+ "version": "1.0.1",
+ "description": "Async DNS-over-HTTPS resolution for Amp.",
+ "abandoned": false
+ },
+ {
+ "name": "danog/ipc",
+ "direct-dependency": false,
+ "homepage": "https://github.com/danog/ipc",
+ "source": "https://github.com/danog/ipc/tree/1.0.1",
+ "version": "1.0.1",
+ "description": "IPC component for Amp.",
+ "abandoned": false
+ },
+ {
+ "name": "danog/libdns-json",
+ "direct-dependency": false,
+ "homepage": "https://github.com/danog/libdns-json",
+ "source": "https://github.com/danog/LibDNSJson/tree/0.2.1",
+ "version": "0.2.1",
+ "description": "Encoder/decoder for google's JSON DNS message format based on libdns",
+ "abandoned": false
+ },
+ {
+ "name": "danog/loop",
+ "direct-dependency": false,
+ "homepage": "https://github.com/danog/loop",
+ "source": "https://github.com/danog/loop/tree/1.1.1",
+ "version": "1.1.1",
+ "description": "Loop abstraction for AMPHP.",
+ "abandoned": false
+ },
+ {
+ "name": "danog/madelineproto",
+ "direct-dependency": true,
+ "homepage": "https://docs.madelineproto.xyz",
+ "source": "https://github.com/danog/MadelineProto/tree/8.7.0",
+ "version": "8.7.0",
+ "description": "Async PHP client API for the telegram MTProto protocol.",
+ "abandoned": false
+ },
+ {
+ "name": "danog/primemodule",
+ "direct-dependency": false,
+ "homepage": null,
+ "source": "https://github.com/danog/PrimeModule/tree/1.0.13",
+ "version": "1.0.13",
+ "description": "Prime module capable of doing prime factorization of huge numbers very quickly.\"",
+ "abandoned": false
+ },
+ {
+ "name": "danog/telegram-entities",
+ "direct-dependency": false,
+ "homepage": null,
+ "source": "https://github.com/danog/telegram-entities/tree/1.0.5",
+ "version": "1.0.5",
+ "description": "A library to work with Telegram UTF-16 styled text entities.",
+ "abandoned": false
+ },
+ {
+ "name": "danog/tg-dialog-id",
+ "direct-dependency": false,
+ "homepage": null,
+ "source": "https://github.com/danog/tg-dialog-id/tree/2.0.0",
+ "version": "2.0.0",
+ "description": "A library to work with Telegram bot API dialog IDs",
+ "abandoned": false
+ },
+ {
+ "name": "danog/tg-file-decoder",
+ "direct-dependency": false,
+ "homepage": null,
+ "source": "https://github.com/danog/tg-file-decoder/tree/1.0.2",
+ "version": "1.0.2",
+ "description": "Decode Telegram bot API file IDs",
+ "abandoned": false
+ },
+ {
+ "name": "dasprid/enum",
+ "direct-dependency": false,
+ "homepage": null,
+ "source": "https://github.com/DASPRiD/Enum/tree/1.0.7",
+ "version": "1.0.7",
+ "description": "PHP 7.1 enum implementation",
+ "abandoned": false
+ },
+ {
+ "name": "daverandom/libdns",
+ "direct-dependency": false,
+ "homepage": null,
+ "source": "https://github.com/DaveRandom/LibDNS/tree/v2.1.0",
+ "version": "v2.1.0",
+ "description": "DNS protocol implementation written in pure PHP",
+ "abandoned": false
+ },
+ {
+ "name": "kelunik/certificate",
+ "direct-dependency": false,
+ "homepage": null,
+ "source": "https://github.com/kelunik/certificate/tree/v1.1.3",
+ "version": "v1.1.3",
+ "description": "Access certificate details and transform between different formats.",
+ "abandoned": false
+ },
+ {
+ "name": "league/uri",
+ "direct-dependency": false,
+ "homepage": "https://uri.thephpleague.com",
+ "source": "https://github.com/thephpleague/uri/tree/7.8.1",
+ "version": "7.8.1",
+ "description": "URI manipulation library",
+ "abandoned": false
+ },
+ {
+ "name": "league/uri-components",
+ "direct-dependency": false,
+ "homepage": "http://uri.thephpleague.com",
+ "source": "https://github.com/thephpleague/uri-components/tree/7.8.1",
+ "version": "7.8.1",
+ "description": "URI components manipulation library",
+ "abandoned": false
+ },
+ {
+ "name": "league/uri-interfaces",
+ "direct-dependency": false,
+ "homepage": "https://uri.thephpleague.com",
+ "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.1",
+ "version": "7.8.1",
+ "description": "Common tools for parsing and resolving RFC3987/RFC3986 URI",
+ "abandoned": false
+ },
+ {
+ "name": "monolog/monolog",
+ "direct-dependency": false,
+ "homepage": "https://github.com/Seldaek/monolog",
+ "source": "https://github.com/Seldaek/monolog/tree/3.12.1",
+ "version": "3.12.1",
+ "description": "Sends your logs to files, sockets, inboxes, databases and various web services",
+ "abandoned": false
+ },
+ {
+ "name": "myclabs/deep-copy",
+ "direct-dependency": false,
+ "homepage": null,
+ "source": "https://github.com/myclabs/DeepCopy/tree/1.14.0",
+ "version": "1.14.0",
+ "description": "Create deep copies (clones) of your objects",
+ "abandoned": false
+ },
+ {
+ "name": "nikic/php-parser",
+ "direct-dependency": false,
+ "homepage": null,
+ "source": "https://github.com/nikic/PHP-Parser/tree/v5.9.0",
+ "version": "v5.9.0",
+ "description": "A PHP parser written in PHP",
+ "abandoned": false
+ },
+ {
+ "name": "paragonie/constant_time_encoding",
+ "direct-dependency": false,
+ "homepage": null,
+ "source": "https://github.com/paragonie/constant_time_encoding",
+ "version": "v3.1.3",
+ "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)",
+ "abandoned": false
+ },
+ {
+ "name": "paragonie/random_compat",
+ "direct-dependency": false,
+ "homepage": null,
+ "source": "https://github.com/paragonie/random_compat",
+ "version": "v9.99.100",
+ "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7",
+ "abandoned": false
+ },
+ {
+ "name": "phar-io/manifest",
+ "direct-dependency": false,
+ "homepage": null,
+ "source": "https://github.com/phar-io/manifest/tree/2.0.4",
+ "version": "2.0.4",
+ "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)",
+ "abandoned": false
+ },
+ {
+ "name": "phar-io/version",
+ "direct-dependency": false,
+ "homepage": null,
+ "source": "https://github.com/phar-io/version/tree/3.2.1",
+ "version": "3.2.1",
+ "description": "Library for handling version information and constraints",
+ "abandoned": false
+ },
+ {
+ "name": "phpseclib/phpseclib",
+ "direct-dependency": false,
+ "homepage": "http://phpseclib.sourceforge.net",
+ "source": "https://github.com/phpseclib/phpseclib/tree/3.0.57",
+ "version": "3.0.57",
+ "description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.",
+ "abandoned": false
+ },
+ {
+ "name": "phpstan/phpstan",
+ "direct-dependency": true,
+ "homepage": null,
+ "source": "https://github.com/phpstan/phpstan-src",
+ "version": "2.3.1",
+ "description": "PHPStan - PHP Static Analysis Tool",
+ "abandoned": false
+ },
+ {
+ "name": "phpunit/php-code-coverage",
+ "direct-dependency": false,
+ "homepage": "https://github.com/sebastianbergmann/php-code-coverage",
+ "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.8",
+ "version": "12.5.8",
+ "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.",
+ "abandoned": false
+ },
+ {
+ "name": "phpunit/php-file-iterator",
+ "direct-dependency": false,
+ "homepage": "https://github.com/sebastianbergmann/php-file-iterator/",
+ "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.2",
+ "version": "6.0.2",
+ "description": "FilterIterator implementation that filters files based on a list of suffixes.",
+ "abandoned": false
+ },
+ {
+ "name": "phpunit/php-invoker",
+ "direct-dependency": false,
+ "homepage": "https://github.com/sebastianbergmann/php-invoker/",
+ "source": "https://github.com/sebastianbergmann/php-invoker/tree/6.0.0",
+ "version": "6.0.0",
+ "description": "Invoke callables with a timeout",
+ "abandoned": false
+ },
+ {
+ "name": "phpunit/php-text-template",
+ "direct-dependency": false,
+ "homepage": "https://github.com/sebastianbergmann/php-text-template/",
+ "source": "https://github.com/sebastianbergmann/php-text-template/tree/5.0.1",
+ "version": "5.0.1",
+ "description": "Simple template engine.",
+ "abandoned": false
+ },
+ {
+ "name": "phpunit/php-timer",
+ "direct-dependency": false,
+ "homepage": "https://github.com/sebastianbergmann/php-timer/",
+ "source": "https://github.com/sebastianbergmann/php-timer/tree/8.0.0",
+ "version": "8.0.0",
+ "description": "Utility class for timing",
+ "abandoned": false
+ },
+ {
+ "name": "phpunit/phpunit",
+ "direct-dependency": true,
+ "homepage": "https://phpunit.de/",
+ "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.39",
+ "version": "12.5.39",
+ "description": "The PHP Unit Testing framework.",
+ "abandoned": false
+ },
+ {
+ "name": "promphp/prometheus_client_php",
+ "direct-dependency": false,
+ "homepage": null,
+ "source": "https://github.com/PromPHP/prometheus_client_php/tree/v2.15.1",
+ "version": "v2.15.1",
+ "description": "Prometheus instrumentation library for PHP applications.",
+ "abandoned": false
+ },
+ {
+ "name": "psr/http-factory",
+ "direct-dependency": false,
+ "homepage": null,
+ "source": "https://github.com/php-fig/http-factory",
+ "version": "1.1.0",
+ "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories",
+ "abandoned": false
+ },
+ {
+ "name": "psr/http-message",
+ "direct-dependency": false,
+ "homepage": "https://github.com/php-fig/http-message",
+ "source": "https://github.com/php-fig/http-message/tree/2.0",
+ "version": "2.0",
+ "description": "Common interface for HTTP messages",
+ "abandoned": false
+ },
+ {
+ "name": "psr/log",
+ "direct-dependency": false,
+ "homepage": "https://github.com/php-fig/log",
+ "source": "https://github.com/php-fig/log/tree/3.0.2",
+ "version": "3.0.2",
+ "description": "Common interface for logging libraries",
+ "abandoned": false
+ },
+ {
+ "name": "revolt/event-loop",
+ "direct-dependency": true,
+ "homepage": null,
+ "source": "https://github.com/revoltphp/event-loop/tree/v1.0.9",
+ "version": "v1.0.9",
+ "description": "Rock-solid event loop for concurrent PHP applications.",
+ "abandoned": false
+ },
+ {
+ "name": "sebastian/cli-parser",
+ "direct-dependency": false,
+ "homepage": "https://github.com/sebastianbergmann/cli-parser",
+ "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.1",
+ "version": "4.2.1",
+ "description": "Library for parsing CLI options",
+ "abandoned": false
+ },
+ {
+ "name": "sebastian/comparator",
+ "direct-dependency": false,
+ "homepage": "https://github.com/sebastianbergmann/comparator",
+ "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.8",
+ "version": "7.1.8",
+ "description": "Provides the functionality to compare PHP values for equality",
+ "abandoned": false
+ },
+ {
+ "name": "sebastian/complexity",
+ "direct-dependency": false,
+ "homepage": "https://github.com/sebastianbergmann/complexity",
+ "source": "https://github.com/sebastianbergmann/complexity/tree/5.0.0",
+ "version": "5.0.0",
+ "description": "Library for calculating the complexity of PHP code units",
+ "abandoned": false
+ },
+ {
+ "name": "sebastian/diff",
+ "direct-dependency": false,
+ "homepage": "https://github.com/sebastianbergmann/diff",
+ "source": "https://github.com/sebastianbergmann/diff/tree/7.0.1",
+ "version": "7.0.1",
+ "description": "Diff implementation",
+ "abandoned": false
+ },
+ {
+ "name": "sebastian/environment",
+ "direct-dependency": false,
+ "homepage": "https://github.com/sebastianbergmann/environment",
+ "source": "https://github.com/sebastianbergmann/environment/tree/8.1.2",
+ "version": "8.1.2",
+ "description": "Provides functionality to handle HHVM/PHP environments",
+ "abandoned": false
+ },
+ {
+ "name": "sebastian/exporter",
+ "direct-dependency": false,
+ "homepage": "https://www.github.com/sebastianbergmann/exporter",
+ "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.3",
+ "version": "7.0.3",
+ "description": "Provides the functionality to export PHP variables for visualization",
+ "abandoned": false
+ },
+ {
+ "name": "sebastian/global-state",
+ "direct-dependency": false,
+ "homepage": "https://www.github.com/sebastianbergmann/global-state",
+ "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.3",
+ "version": "8.0.3",
+ "description": "Snapshotting of global state",
+ "abandoned": false
+ },
+ {
+ "name": "sebastian/lines-of-code",
+ "direct-dependency": false,
+ "homepage": "https://github.com/sebastianbergmann/lines-of-code",
+ "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.1",
+ "version": "4.0.1",
+ "description": "Library for counting the lines of code in PHP source code",
+ "abandoned": false
+ },
+ {
+ "name": "sebastian/object-enumerator",
+ "direct-dependency": false,
+ "homepage": "https://github.com/sebastianbergmann/object-enumerator/",
+ "source": "https://github.com/sebastianbergmann/object-enumerator/tree/7.0.0",
+ "version": "7.0.0",
+ "description": "Traverses array structures and object graphs to enumerate all referenced objects",
+ "abandoned": false
+ },
+ {
+ "name": "sebastian/object-reflector",
+ "direct-dependency": false,
+ "homepage": "https://github.com/sebastianbergmann/object-reflector/",
+ "source": "https://github.com/sebastianbergmann/object-reflector/tree/5.0.0",
+ "version": "5.0.0",
+ "description": "Allows reflection of object attributes, including inherited and non-public ones",
+ "abandoned": false
+ },
+ {
+ "name": "sebastian/recursion-context",
+ "direct-dependency": false,
+ "homepage": "https://github.com/sebastianbergmann/recursion-context",
+ "source": "https://github.com/sebastianbergmann/recursion-context/tree/7.0.1",
+ "version": "7.0.1",
+ "description": "Provides functionality to recursively process PHP variables",
+ "abandoned": false
+ },
+ {
+ "name": "sebastian/type",
+ "direct-dependency": false,
+ "homepage": "https://github.com/sebastianbergmann/type",
+ "source": "https://github.com/sebastianbergmann/type/tree/6.0.4",
+ "version": "6.0.4",
+ "description": "Collection of value objects that represent the types of the PHP type system",
+ "abandoned": false
+ },
+ {
+ "name": "sebastian/version",
+ "direct-dependency": false,
+ "homepage": "https://github.com/sebastianbergmann/version",
+ "source": "https://github.com/sebastianbergmann/version/tree/6.0.1",
+ "version": "6.0.1",
+ "description": "Library that helps with managing the version number of Git-hosted PHP projects",
+ "abandoned": false
+ },
+ {
+ "name": "squizlabs/php_codesniffer",
+ "direct-dependency": true,
+ "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer",
+ "source": "https://github.com/PHPCSStandards/PHP_CodeSniffer",
+ "version": "4.0.4",
+ "description": "PHP_CodeSniffer tokenizes PHP files and detects violations of a defined set of coding standards.",
+ "abandoned": false
+ },
+ {
+ "name": "staabm/side-effects-detector",
+ "direct-dependency": false,
+ "homepage": null,
+ "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5",
+ "version": "1.0.5",
+ "description": "A static analysis tool to detect side effects in PHP code",
+ "abandoned": false
+ },
+ {
+ "name": "symfony/polyfill-mbstring",
+ "direct-dependency": false,
+ "homepage": "https://symfony.com",
+ "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.43.0",
+ "version": "v1.43.0",
+ "description": "Symfony polyfill for the Mbstring extension",
+ "abandoned": false
+ },
+ {
+ "name": "symfony/polyfill-php83",
+ "direct-dependency": false,
+ "homepage": "https://symfony.com",
+ "source": "https://github.com/symfony/polyfill-php83/tree/v1.43.0",
+ "version": "v1.43.0",
+ "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions",
+ "abandoned": false
+ },
+ {
+ "name": "theseer/tokenizer",
+ "direct-dependency": false,
+ "homepage": null,
+ "source": "https://github.com/theseer/tokenizer/tree/2.0.1",
+ "version": "2.0.1",
+ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats",
+ "abandoned": false
+ },
+ {
+ "name": "webmozart/assert",
+ "direct-dependency": false,
+ "homepage": null,
+ "source": "https://github.com/webmozarts/assert/tree/1.12.1",
+ "version": "1.12.1",
+ "description": "Assertions to validate method input/output with nice error messages.",
+ "abandoned": false
+ },
+ {
+ "name": "wizardloop/broadcastmanager",
+ "direct-dependency": true,
+ "homepage": "https://github.com/WizardLoop/BroadcastManager",
+ "source": "https://github.com/WizardLoop/BroadcastManager",
+ "version": "3.2.2",
+ "description": "High-performance Telegram broadcast manager for MadelineProto",
+ "abandoned": false
+ }
+ ]
+}
diff --git a/docs/dependencies.json b/docs/dependencies.json
new file mode 100644
index 0000000..84c8fb3
--- /dev/null
+++ b/docs/dependencies.json
@@ -0,0 +1,2009 @@
+{
+ "schema_version": 1,
+ "lock_sha256": "a6a66ad9af4ccd6ef0fa7e62f7a7e28a1ae73cf07e8545552710bc9b8477e9bf",
+ "packages": [
+ {
+ "name": "amphp/amp",
+ "version": "v3.1.3",
+ "direct": true,
+ "scope": "runtime",
+ "spdx": [
+ "MIT"
+ ],
+ "source_url": "https://github.com/amphp/amp.git",
+ "source_commit": "73c38b323ff8d790abf0f76c56fcc892bda4111a",
+ "source_permalink": "https://github.com/amphp/amp/tree/73c38b323ff8d790abf0f76c56fcc892bda4111a",
+ "license_files": [
+ {
+ "file": "vendor/amphp/amp/LICENSE",
+ "sha256": "84904cb068b00a937d61104d0ae336bff1aa3518c0329d13dae8b71880dc2429"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "amphp/byte-stream",
+ "version": "v2.1.2",
+ "direct": false,
+ "scope": "runtime",
+ "spdx": [
+ "MIT"
+ ],
+ "source_url": "https://github.com/amphp/byte-stream.git",
+ "source_commit": "55a6bd071aec26fa2a3e002618c20c35e3df1b46",
+ "source_permalink": "https://github.com/amphp/byte-stream/tree/55a6bd071aec26fa2a3e002618c20c35e3df1b46",
+ "license_files": [
+ {
+ "file": "vendor/amphp/byte-stream/LICENSE",
+ "sha256": "5054607a36e0b2c287274145c01e3358ca62e79754d947159b7ace87cdd78305"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "amphp/cache",
+ "version": "v2.0.1",
+ "direct": false,
+ "scope": "runtime",
+ "spdx": [
+ "MIT"
+ ],
+ "source_url": "https://github.com/amphp/cache.git",
+ "source_commit": "46912e387e6aa94933b61ea1ead9cf7540b7797c",
+ "source_permalink": "https://github.com/amphp/cache/tree/46912e387e6aa94933b61ea1ead9cf7540b7797c",
+ "license_files": [
+ {
+ "file": "vendor/amphp/cache/LICENSE",
+ "sha256": "a6d83873b76621737538da8481d4f737fa5ffa15dd3abe6e4c3a112f6f5ea1ac"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "amphp/dns",
+ "version": "v2.4.1",
+ "direct": false,
+ "scope": "runtime",
+ "spdx": [
+ "MIT"
+ ],
+ "source_url": "https://github.com/amphp/dns.git",
+ "source_commit": "ec5948bfafac808f410406e18bc7b52a2d4629b7",
+ "source_permalink": "https://github.com/amphp/dns/tree/ec5948bfafac808f410406e18bc7b52a2d4629b7",
+ "license_files": [
+ {
+ "file": "vendor/amphp/dns/LICENSE",
+ "sha256": "93dc2b2aae85651379265cbb4906d2b054020ac9cc4ee70e5040f79751e34651"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "amphp/file",
+ "version": "v3.2.0",
+ "direct": true,
+ "scope": "runtime",
+ "spdx": [
+ "MIT"
+ ],
+ "source_url": "https://github.com/amphp/file.git",
+ "source_commit": "28b38a805d2c235bb581d24415e78a42cd03aedc",
+ "source_permalink": "https://github.com/amphp/file/tree/28b38a805d2c235bb581d24415e78a42cd03aedc",
+ "license_files": [
+ {
+ "file": "vendor/amphp/file/LICENSE",
+ "sha256": "75ff14768ed3b0898ea24b8ff76e9615c21d1d4e6f7ba1d258cc55cb9689ca73"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "amphp/hpack",
+ "version": "v3.2.2",
+ "direct": false,
+ "scope": "runtime",
+ "spdx": [
+ "MIT"
+ ],
+ "source_url": "https://github.com/amphp/hpack.git",
+ "source_commit": "291da27078e7e149a9bad4d08ff05bf7d81c89f4",
+ "source_permalink": "https://github.com/amphp/hpack/tree/291da27078e7e149a9bad4d08ff05bf7d81c89f4",
+ "license_files": [
+ {
+ "file": "vendor/amphp/hpack/LICENSE",
+ "sha256": "93dc2b2aae85651379265cbb4906d2b054020ac9cc4ee70e5040f79751e34651"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "amphp/http",
+ "version": "v2.1.2",
+ "direct": false,
+ "scope": "runtime",
+ "spdx": [
+ "MIT"
+ ],
+ "source_url": "https://github.com/amphp/http.git",
+ "source_commit": "3680d80bd38b5d6f3c2cef2214ca6dd6cef26588",
+ "source_permalink": "https://github.com/amphp/http/tree/3680d80bd38b5d6f3c2cef2214ca6dd6cef26588",
+ "license_files": [
+ {
+ "file": "vendor/amphp/http/LICENSE",
+ "sha256": "95d9a352435f98f90b68c443bc8f6378701d1b708c8f5150a13509a9ceae4379"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "amphp/http-client",
+ "version": "v5.3.6",
+ "direct": true,
+ "scope": "runtime",
+ "spdx": [
+ "MIT"
+ ],
+ "source_url": "https://github.com/amphp/http-client.git",
+ "source_commit": "ca155026acafa74a612d776a97202d53077fee86",
+ "source_permalink": "https://github.com/amphp/http-client/tree/ca155026acafa74a612d776a97202d53077fee86",
+ "license_files": [
+ {
+ "file": "vendor/amphp/http-client/LICENSE",
+ "sha256": "5867f0443d153c8bc1510680d5dd922ad4db2bbc7445af9dd09d849a3efd7690"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "amphp/http-client-cookies",
+ "version": "v2.0.0",
+ "direct": false,
+ "scope": "runtime",
+ "spdx": [
+ "MIT"
+ ],
+ "source_url": "https://github.com/amphp/http-client-cookies.git",
+ "source_commit": "4124b37ffa4d15034e70d5d30e6fb9605ed6c1a7",
+ "source_permalink": "https://github.com/amphp/http-client-cookies/tree/4124b37ffa4d15034e70d5d30e6fb9605ed6c1a7",
+ "license_files": [
+ {
+ "file": "vendor/amphp/http-client-cookies/LICENSE",
+ "sha256": "80a36409928641ee64ab2932c9a3a3876f7e2ee0f1b886dfa7c30404d7bd3767"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "amphp/http-server",
+ "version": "v3.4.7",
+ "direct": false,
+ "scope": "runtime",
+ "spdx": [
+ "MIT"
+ ],
+ "source_url": "https://github.com/amphp/http-server.git",
+ "source_commit": "aa96ca38c5f822a827236543d5417d0c2a782ef0",
+ "source_permalink": "https://github.com/amphp/http-server/tree/aa96ca38c5f822a827236543d5417d0c2a782ef0",
+ "license_files": [
+ {
+ "file": "vendor/amphp/http-server/LICENSE",
+ "sha256": "75ff14768ed3b0898ea24b8ff76e9615c21d1d4e6f7ba1d258cc55cb9689ca73"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "amphp/log",
+ "version": "v2.0.0",
+ "direct": false,
+ "scope": "runtime",
+ "spdx": [
+ "MIT"
+ ],
+ "source_url": "https://github.com/amphp/log.git",
+ "source_commit": "bf1562b8a18a3f30efa069ed740f412ac70a8a6c",
+ "source_permalink": "https://github.com/amphp/log/tree/bf1562b8a18a3f30efa069ed740f412ac70a8a6c",
+ "license_files": [
+ {
+ "file": "vendor/amphp/log/LICENSE",
+ "sha256": "fc5149c45b0b34f924ad2ceee71216358599bce2e281892ef6d87fa9acd48006"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "amphp/mysql",
+ "version": "v3.1.1",
+ "direct": false,
+ "scope": "runtime",
+ "spdx": [
+ "MIT"
+ ],
+ "source_url": "https://github.com/amphp/mysql.git",
+ "source_commit": "ea3a5e07dc3dddc267669337ded2a98f02d86223",
+ "source_permalink": "https://github.com/amphp/mysql/tree/ea3a5e07dc3dddc267669337ded2a98f02d86223",
+ "license_files": [
+ {
+ "file": "vendor/amphp/mysql/LICENSE",
+ "sha256": "4daf770ec2fb0eef9a9e1c2764c6cebfacafaeb13ab6cf48c1afdab27ef7a4ca"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "amphp/parallel",
+ "version": "v2.4.0",
+ "direct": false,
+ "scope": "runtime",
+ "spdx": [
+ "MIT"
+ ],
+ "source_url": "https://github.com/amphp/parallel.git",
+ "source_commit": "37f5b2754fadc229c00f9416bd68fb8d04529a81",
+ "source_permalink": "https://github.com/amphp/parallel/tree/37f5b2754fadc229c00f9416bd68fb8d04529a81",
+ "license_files": [
+ {
+ "file": "vendor/amphp/parallel/LICENSE",
+ "sha256": "93dc2b2aae85651379265cbb4906d2b054020ac9cc4ee70e5040f79751e34651"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "amphp/parser",
+ "version": "v1.1.1",
+ "direct": false,
+ "scope": "runtime",
+ "spdx": [
+ "MIT"
+ ],
+ "source_url": "https://github.com/amphp/parser.git",
+ "source_commit": "3cf1f8b32a0171d4b1bed93d25617637a77cded7",
+ "source_permalink": "https://github.com/amphp/parser/tree/3cf1f8b32a0171d4b1bed93d25617637a77cded7",
+ "license_files": [
+ {
+ "file": "vendor/amphp/parser/LICENSE",
+ "sha256": "16ac45b95d11c9e4b805ae5392d069cab777f8085779ad3869b47030ccf47734"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "amphp/pipeline",
+ "version": "v1.2.7",
+ "direct": false,
+ "scope": "runtime",
+ "spdx": [
+ "MIT"
+ ],
+ "source_url": "https://github.com/amphp/pipeline.git",
+ "source_commit": "cf2d67696c2015ea7c7fd8f6ec5f8ed7c2d17c17",
+ "source_permalink": "https://github.com/amphp/pipeline/tree/cf2d67696c2015ea7c7fd8f6ec5f8ed7c2d17c17",
+ "license_files": [
+ {
+ "file": "vendor/amphp/pipeline/LICENSE",
+ "sha256": "dd9b5da37883d21ecc6fe466ac0a4644d8caf7ea7cd5ff17f47c5f167e131091"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "amphp/postgres",
+ "version": "v2.2.1",
+ "direct": false,
+ "scope": "runtime",
+ "spdx": [
+ "MIT"
+ ],
+ "source_url": "https://github.com/amphp/postgres.git",
+ "source_commit": "b68c4d5929d0ec1701781dbbb0bb81dd8d0a42d7",
+ "source_permalink": "https://github.com/amphp/postgres/tree/b68c4d5929d0ec1701781dbbb0bb81dd8d0a42d7",
+ "license_files": [
+ {
+ "file": "vendor/amphp/postgres/LICENSE",
+ "sha256": "4832233a325492ffa9dc1cb5c654a53e69c00adaec7f45d50a896d9d9c77a9df"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "amphp/process",
+ "version": "v2.1.0",
+ "direct": false,
+ "scope": "runtime",
+ "spdx": [
+ "MIT"
+ ],
+ "source_url": "https://github.com/amphp/process.git",
+ "source_commit": "583959df17d00304ad7b0b32285373f985935643",
+ "source_permalink": "https://github.com/amphp/process/tree/583959df17d00304ad7b0b32285373f985935643",
+ "license_files": [
+ {
+ "file": "vendor/amphp/process/LICENSE",
+ "sha256": "5b8738686f873967502b48ff6b52d03a2065ea124146d4db7688ff469873b373"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "amphp/redis",
+ "version": "v2.0.4",
+ "direct": false,
+ "scope": "runtime",
+ "spdx": [
+ "MIT"
+ ],
+ "source_url": "https://github.com/amphp/redis.git",
+ "source_commit": "964bcf6c2574645058371925a3668240a622bdab",
+ "source_permalink": "https://github.com/amphp/redis/tree/964bcf6c2574645058371925a3668240a622bdab",
+ "license_files": [
+ {
+ "file": "vendor/amphp/redis/LICENSE",
+ "sha256": "93dc2b2aae85651379265cbb4906d2b054020ac9cc4ee70e5040f79751e34651"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "amphp/serialization",
+ "version": "v1.1.0",
+ "direct": false,
+ "scope": "runtime",
+ "spdx": [
+ "MIT"
+ ],
+ "source_url": "https://github.com/amphp/serialization.git",
+ "source_commit": "fdf2834d78cebb0205fb2672676c1b1eb84371f0",
+ "source_permalink": "https://github.com/amphp/serialization/tree/fdf2834d78cebb0205fb2672676c1b1eb84371f0",
+ "license_files": [
+ {
+ "file": "vendor/amphp/serialization/LICENSE",
+ "sha256": "9cbfa9126ef8d331fdada832a7f7048fbd9723870ad79844f2e38b56398c3662"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "amphp/socket",
+ "version": "v2.4.1",
+ "direct": false,
+ "scope": "runtime",
+ "spdx": [
+ "MIT"
+ ],
+ "source_url": "https://github.com/amphp/socket.git",
+ "source_commit": "b347be5aff6b2cc025208bb4d896607eb470c018",
+ "source_permalink": "https://github.com/amphp/socket/tree/b347be5aff6b2cc025208bb4d896607eb470c018",
+ "license_files": [
+ {
+ "file": "vendor/amphp/socket/LICENSE",
+ "sha256": "75ff14768ed3b0898ea24b8ff76e9615c21d1d4e6f7ba1d258cc55cb9689ca73"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "amphp/sql",
+ "version": "v2.1.1",
+ "direct": false,
+ "scope": "runtime",
+ "spdx": [
+ "MIT"
+ ],
+ "source_url": "https://github.com/amphp/sql.git",
+ "source_commit": "258bafe5ecf8a0491d86681f2a2af1dee2933a69",
+ "source_permalink": "https://github.com/amphp/sql/tree/258bafe5ecf8a0491d86681f2a2af1dee2933a69",
+ "license_files": [
+ {
+ "file": "vendor/amphp/sql/LICENSE",
+ "sha256": "d1224ddb6b3741731e97eb3a734cd157a6da4b050e2a3d700ad68a9bf2407e16"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "amphp/sql-common",
+ "version": "v2.0.4",
+ "direct": false,
+ "scope": "runtime",
+ "spdx": [
+ "MIT"
+ ],
+ "source_url": "https://github.com/amphp/sql-common.git",
+ "source_commit": "735da17ef0a66e7139c9f7584af5c3f9827f83c0",
+ "source_permalink": "https://github.com/amphp/sql-common/tree/735da17ef0a66e7139c9f7584af5c3f9827f83c0",
+ "license_files": [
+ {
+ "file": "vendor/amphp/sql-common/LICENSE",
+ "sha256": "d1224ddb6b3741731e97eb3a734cd157a6da4b050e2a3d700ad68a9bf2407e16"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "amphp/sync",
+ "version": "v2.3.0",
+ "direct": false,
+ "scope": "runtime",
+ "spdx": [
+ "MIT"
+ ],
+ "source_url": "https://github.com/amphp/sync.git",
+ "source_commit": "217097b785130d77cfcc58ff583cf26cd1770bf1",
+ "source_permalink": "https://github.com/amphp/sync/tree/217097b785130d77cfcc58ff583cf26cd1770bf1",
+ "license_files": [
+ {
+ "file": "vendor/amphp/sync/LICENSE",
+ "sha256": "0e4919a5d6d70ae53b3291c932d055d1734172a539db060bd8c8f392a13ff0f1"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "amphp/websocket",
+ "version": "v2.0.4",
+ "direct": false,
+ "scope": "runtime",
+ "spdx": [
+ "MIT"
+ ],
+ "source_url": "https://github.com/amphp/websocket.git",
+ "source_commit": "963904b6a883c4b62d9222d1d9749814fac96a3b",
+ "source_permalink": "https://github.com/amphp/websocket/tree/963904b6a883c4b62d9222d1d9749814fac96a3b",
+ "license_files": [
+ {
+ "file": "vendor/amphp/websocket/LICENSE",
+ "sha256": "93dc2b2aae85651379265cbb4906d2b054020ac9cc4ee70e5040f79751e34651"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "amphp/websocket-client",
+ "version": "v2.0.2",
+ "direct": false,
+ "scope": "runtime",
+ "spdx": [
+ "MIT"
+ ],
+ "source_url": "https://github.com/amphp/websocket-client.git",
+ "source_commit": "dc033fdce0af56295a23f63ac4f579b34d470d6c",
+ "source_permalink": "https://github.com/amphp/websocket-client/tree/dc033fdce0af56295a23f63ac4f579b34d470d6c",
+ "license_files": [
+ {
+ "file": "vendor/amphp/websocket-client/LICENSE",
+ "sha256": "0e4919a5d6d70ae53b3291c932d055d1734172a539db060bd8c8f392a13ff0f1"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "bacon/bacon-qr-code",
+ "version": "v3.1.1",
+ "direct": false,
+ "scope": "runtime",
+ "spdx": [
+ "BSD-2-Clause"
+ ],
+ "source_url": "https://github.com/Bacon/BaconQrCode.git",
+ "source_commit": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2",
+ "source_permalink": "https://github.com/Bacon/BaconQrCode/tree/4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2",
+ "license_files": [
+ {
+ "file": "vendor/bacon/bacon-qr-code/LICENSE",
+ "sha256": "973af1db913b076a85e09732c75df802a6b8a61a63701c38f2ce6d471c213260"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "danog/async-orm",
+ "version": "1.1.4",
+ "direct": false,
+ "scope": "runtime",
+ "spdx": [
+ "Apache-2.0"
+ ],
+ "source_url": "https://github.com/danog/AsyncOrm.git",
+ "source_commit": "495f335d8ddb9dae8b773025911191f31c45e9ea",
+ "source_permalink": "https://github.com/danog/AsyncOrm/tree/495f335d8ddb9dae8b773025911191f31c45e9ea",
+ "license_files": [
+ {
+ "file": "vendor/danog/async-orm/LICENSE",
+ "sha256": "8c6db340475136df3c1201d458fa5755698eace76e510471ecc9d857d6083dac"
+ },
+ {
+ "file": "vendor/danog/async-orm/NOTICE",
+ "sha256": "3dfd6d3468a650d538cdfc3f94d42200a3517c72864c9224a78a6e86900d6fc6"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "danog/better-prometheus",
+ "version": "0.1.1",
+ "direct": false,
+ "scope": "runtime",
+ "spdx": [
+ "Apache-2.0"
+ ],
+ "source_url": "https://github.com/danog/better-prometheus.git",
+ "source_commit": "74e19343be41905572290878ca9fd0ef08ba0c5e",
+ "source_permalink": "https://github.com/danog/better-prometheus/tree/74e19343be41905572290878ca9fd0ef08ba0c5e",
+ "license_files": [
+ {
+ "file": "vendor/danog/better-prometheus/LICENSE",
+ "sha256": "8c6db340475136df3c1201d458fa5755698eace76e510471ecc9d857d6083dac"
+ },
+ {
+ "file": "vendor/danog/better-prometheus/NOTICE",
+ "sha256": "7ee673a8f098e3569a5af6f61f722bc735d340d92414a72a2dd4e35e38619eac"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "danog/dns-over-https",
+ "version": "1.0.1",
+ "direct": false,
+ "scope": "runtime",
+ "spdx": [
+ "MIT"
+ ],
+ "source_url": "https://github.com/danog/dns-over-https.git",
+ "source_commit": "142b8d439e7a582d66b13d9f1119820dd3110884",
+ "source_permalink": "https://github.com/danog/dns-over-https/tree/142b8d439e7a582d66b13d9f1119820dd3110884",
+ "license_files": [
+ {
+ "file": "vendor/danog/dns-over-https/LICENSE",
+ "sha256": "eff216d768cf4549198f24abbdb80f55fec10afb5876920291993d8d59c3aa47"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "danog/ipc",
+ "version": "1.0.1",
+ "direct": false,
+ "scope": "runtime",
+ "spdx": [
+ "MIT"
+ ],
+ "source_url": "https://github.com/danog/ipc.git",
+ "source_commit": "505e672b76af6ec623f7ef78efb2416f62ef8e29",
+ "source_permalink": "https://github.com/danog/ipc/tree/505e672b76af6ec623f7ef78efb2416f62ef8e29",
+ "license_files": [
+ {
+ "file": "vendor/danog/ipc/LICENSE",
+ "sha256": "21aa06129a162cf89ab5bad3bd16cb7366ccd1fd6858a294be3092f336c1a227"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "danog/libdns-json",
+ "version": "0.2.1",
+ "direct": false,
+ "scope": "runtime",
+ "spdx": [
+ "MIT"
+ ],
+ "source_url": "https://github.com/danog/LibDNSJson.git",
+ "source_commit": "cd2981a386a32ff62d447a9553e203384651ba90",
+ "source_permalink": "https://github.com/danog/LibDNSJson/tree/cd2981a386a32ff62d447a9553e203384651ba90",
+ "license_files": [
+ {
+ "file": "vendor/danog/libdns-json/LICENSE",
+ "sha256": "eff216d768cf4549198f24abbdb80f55fec10afb5876920291993d8d59c3aa47"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "danog/loop",
+ "version": "1.1.1",
+ "direct": false,
+ "scope": "runtime",
+ "spdx": [
+ "MIT"
+ ],
+ "source_url": "https://github.com/danog/loop.git",
+ "source_commit": "83c42b26c1d8d070c56bc9c03e2e8a147b45c60f",
+ "source_permalink": "https://github.com/danog/loop/tree/83c42b26c1d8d070c56bc9c03e2e8a147b45c60f",
+ "license_files": [
+ {
+ "file": "vendor/danog/loop/LICENSE",
+ "sha256": "30f46fa760c0c1f5d98504008487928f496c61043c304a3fe1624e15b3a7fb77"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "danog/madelineproto",
+ "version": "8.7.0",
+ "direct": true,
+ "scope": "runtime",
+ "spdx": [
+ "AGPL-3.0-only"
+ ],
+ "source_url": "https://github.com/danog/MadelineProto.git",
+ "source_commit": "7b99dd0f2d964a492c377912ffce9f5ce61aa9ed",
+ "source_permalink": "https://github.com/danog/MadelineProto/tree/7b99dd0f2d964a492c377912ffce9f5ce61aa9ed",
+ "license_files": [
+ {
+ "file": "vendor/danog/madelineproto/LICENSE",
+ "sha256": "57c8ff33c9c0cfc3ef00e650a1cc910d7ee479a8bc509f6c9209a7c2a11399d6"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": [
+ "Metadata says AGPL-3.0-only; source headers grant version 3 or later. Preserve upstream grant and report mismatch; application license remains AGPL-3.0-only."
+ ]
+ },
+ {
+ "name": "danog/primemodule",
+ "version": "1.0.13",
+ "direct": false,
+ "scope": "runtime",
+ "spdx": [
+ "AGPL-3.0-only"
+ ],
+ "source_url": "https://github.com/danog/PrimeModule.git",
+ "source_commit": "e7ebfc1ea64c24186e49064cd4b76bc76c28600e",
+ "source_permalink": "https://github.com/danog/PrimeModule/tree/e7ebfc1ea64c24186e49064cd4b76bc76c28600e",
+ "license_files": [
+ {
+ "file": "vendor/danog/primemodule/LICENSE",
+ "sha256": "57c8ff33c9c0cfc3ef00e650a1cc910d7ee479a8bc509f6c9209a7c2a11399d6"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "danog/telegram-entities",
+ "version": "1.0.5",
+ "direct": false,
+ "scope": "runtime",
+ "spdx": [
+ "Apache-2.0"
+ ],
+ "source_url": "https://github.com/danog/telegram-entities.git",
+ "source_commit": "19ab8a48bc4b9519c4493e2c62eb171fba8b58b4",
+ "source_permalink": "https://github.com/danog/telegram-entities/tree/19ab8a48bc4b9519c4493e2c62eb171fba8b58b4",
+ "license_files": [
+ {
+ "file": "vendor/danog/telegram-entities/LICENSE",
+ "sha256": "8c6db340475136df3c1201d458fa5755698eace76e510471ecc9d857d6083dac"
+ },
+ {
+ "file": "vendor/danog/telegram-entities/NOTICE",
+ "sha256": "6b4a79f5f9cfaff020d4ebe1c3979f089e22f0bc6c0bffc697fe13d08dba0dc6"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "danog/tg-dialog-id",
+ "version": "2.0.0",
+ "direct": false,
+ "scope": "runtime",
+ "spdx": [
+ "Apache-2.0"
+ ],
+ "source_url": "https://github.com/danog/tg-dialog-id.git",
+ "source_commit": "d03c653b2dad43d739675c83a6e8fa14b3b43636",
+ "source_permalink": "https://github.com/danog/tg-dialog-id/tree/d03c653b2dad43d739675c83a6e8fa14b3b43636",
+ "license_files": [
+ {
+ "file": "vendor/danog/tg-dialog-id/LICENSE",
+ "sha256": "8c6db340475136df3c1201d458fa5755698eace76e510471ecc9d857d6083dac"
+ },
+ {
+ "file": "vendor/danog/tg-dialog-id/NOTICE",
+ "sha256": "c23abfc407dd639c3136e3a12451688d68d7f9957b43ecbcedefb2cf5757086e"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "danog/tg-file-decoder",
+ "version": "1.0.2",
+ "direct": false,
+ "scope": "runtime",
+ "spdx": [
+ "AGPL-3.0-only"
+ ],
+ "source_url": "https://github.com/danog/tg-file-decoder.git",
+ "source_commit": "f4da78c28d47b658c4596249f1d6595956f3b796",
+ "source_permalink": "https://github.com/danog/tg-file-decoder/tree/f4da78c28d47b658c4596249f1d6595956f3b796",
+ "license_files": [
+ {
+ "file": "vendor/danog/tg-file-decoder/LICENSE",
+ "sha256": "57c8ff33c9c0cfc3ef00e650a1cc910d7ee479a8bc509f6c9209a7c2a11399d6"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "dasprid/enum",
+ "version": "1.0.7",
+ "direct": false,
+ "scope": "runtime",
+ "spdx": [
+ "BSD-2-Clause"
+ ],
+ "source_url": "https://github.com/DASPRiD/Enum.git",
+ "source_commit": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
+ "source_permalink": "https://github.com/DASPRiD/Enum/tree/b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
+ "license_files": [
+ {
+ "file": "vendor/dasprid/enum/LICENSE",
+ "sha256": "6748d0268a176f839bfa1031724f9ce0963acbdadc71e503c2c3e9b59c8fcd68"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "daverandom/libdns",
+ "version": "v2.1.0",
+ "direct": false,
+ "scope": "runtime",
+ "spdx": [
+ "MIT"
+ ],
+ "source_url": "https://github.com/DaveRandom/LibDNS.git",
+ "source_commit": "b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a",
+ "source_permalink": "https://github.com/DaveRandom/LibDNS/tree/b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a",
+ "license_files": [
+ {
+ "file": "vendor/daverandom/libdns/LICENSE",
+ "sha256": "3378b771ef8e0b6e09d1a5dd7d3daeaf5b06a41f8cf7ab43f20df3b646c18dc9"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "kelunik/certificate",
+ "version": "v1.1.3",
+ "direct": false,
+ "scope": "runtime",
+ "spdx": [
+ "MIT"
+ ],
+ "source_url": "https://github.com/kelunik/certificate.git",
+ "source_commit": "7e00d498c264d5eb4f78c69f41c8bd6719c0199e",
+ "source_permalink": "https://github.com/kelunik/certificate/tree/7e00d498c264d5eb4f78c69f41c8bd6719c0199e",
+ "license_files": [
+ {
+ "file": "vendor/kelunik/certificate/LICENSE",
+ "sha256": "4da4ff63fb9bf3961df2e1210e74a654c32f5c82414997977ca58e56d9eb2fba"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "league/uri",
+ "version": "7.8.1",
+ "direct": false,
+ "scope": "runtime",
+ "spdx": [
+ "MIT"
+ ],
+ "source_url": "https://github.com/thephpleague/uri.git",
+ "source_commit": "08cf38e3924d4f56238125547b5720496fac8fd4",
+ "source_permalink": "https://github.com/thephpleague/uri/tree/08cf38e3924d4f56238125547b5720496fac8fd4",
+ "license_files": [
+ {
+ "file": "vendor/league/uri/LICENSE",
+ "sha256": "bb7f984073bf5083be5cd56d144bf3508181409ee6a866b51538725a3dd8ce24"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "league/uri-components",
+ "version": "7.8.1",
+ "direct": false,
+ "scope": "runtime",
+ "spdx": [
+ "MIT"
+ ],
+ "source_url": "https://github.com/thephpleague/uri-components.git",
+ "source_commit": "848ff9db2f0be06229d6034b7c2e33d41b4fd675",
+ "source_permalink": "https://github.com/thephpleague/uri-components/tree/848ff9db2f0be06229d6034b7c2e33d41b4fd675",
+ "license_files": [
+ {
+ "file": "vendor/league/uri-components/LICENSE",
+ "sha256": "bb7f984073bf5083be5cd56d144bf3508181409ee6a866b51538725a3dd8ce24"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "league/uri-interfaces",
+ "version": "7.8.1",
+ "direct": false,
+ "scope": "runtime",
+ "spdx": [
+ "MIT"
+ ],
+ "source_url": "https://github.com/thephpleague/uri-interfaces.git",
+ "source_commit": "85d5c77c5d6d3af6c54db4a78246364908f3c928",
+ "source_permalink": "https://github.com/thephpleague/uri-interfaces/tree/85d5c77c5d6d3af6c54db4a78246364908f3c928",
+ "license_files": [
+ {
+ "file": "vendor/league/uri-interfaces/LICENSE",
+ "sha256": "bb7f984073bf5083be5cd56d144bf3508181409ee6a866b51538725a3dd8ce24"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "monolog/monolog",
+ "version": "3.12.1",
+ "direct": false,
+ "scope": "runtime",
+ "spdx": [
+ "MIT"
+ ],
+ "source_url": "https://github.com/Seldaek/monolog.git",
+ "source_commit": "d7059e4c691d05ea1222b5124bed0f961be46e15",
+ "source_permalink": "https://github.com/Seldaek/monolog/tree/d7059e4c691d05ea1222b5124bed0f961be46e15",
+ "license_files": [
+ {
+ "file": "vendor/monolog/monolog/LICENSE",
+ "sha256": "3a351f8f434ff941f4946afb534f7df1185523638b311c22901631d8882f70e1"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "nikic/php-parser",
+ "version": "v5.9.0",
+ "direct": false,
+ "scope": "runtime",
+ "spdx": [
+ "BSD-3-Clause"
+ ],
+ "source_url": "https://github.com/nikic/PHP-Parser.git",
+ "source_commit": "9e33da9553fe7786f0962b35f4e4ecf01be89def",
+ "source_permalink": "https://github.com/nikic/PHP-Parser/tree/9e33da9553fe7786f0962b35f4e4ecf01be89def",
+ "license_files": [
+ {
+ "file": "vendor/nikic/php-parser/LICENSE",
+ "sha256": "dd859e107fac9f87a4413ab8c3c96f0f1611a17f596a2db562a5a4f4904c1e97"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "paragonie/constant_time_encoding",
+ "version": "v3.1.3",
+ "direct": false,
+ "scope": "runtime",
+ "spdx": [
+ "MIT"
+ ],
+ "source_url": "https://github.com/paragonie/constant_time_encoding.git",
+ "source_commit": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77",
+ "source_permalink": "https://github.com/paragonie/constant_time_encoding/tree/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77",
+ "license_files": [
+ {
+ "file": "vendor/paragonie/constant_time_encoding/LICENSE.txt",
+ "sha256": "d175ac2e6bc71c13117ea49cc2c3be95284690dc07cc7fc441c6538c280641fd"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "paragonie/random_compat",
+ "version": "v9.99.100",
+ "direct": false,
+ "scope": "runtime",
+ "spdx": [
+ "MIT"
+ ],
+ "source_url": "https://github.com/paragonie/random_compat.git",
+ "source_commit": "996434e5492cb4c3edcb9168db6fbb1359ef965a",
+ "source_permalink": "https://github.com/paragonie/random_compat/tree/996434e5492cb4c3edcb9168db6fbb1359ef965a",
+ "license_files": [
+ {
+ "file": "vendor/paragonie/random_compat/LICENSE",
+ "sha256": "e79a4c7d03c72850c60b2d5c41c2f92d33448ed2b243bd4686cfa7674e15e3ba"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "phpseclib/phpseclib",
+ "version": "3.0.57",
+ "direct": false,
+ "scope": "runtime",
+ "spdx": [
+ "MIT"
+ ],
+ "source_url": "https://github.com/phpseclib/phpseclib.git",
+ "source_commit": "d17e0ddaeaf6f22f7e007cbb437d78792fe2a0e4",
+ "source_permalink": "https://github.com/phpseclib/phpseclib/tree/d17e0ddaeaf6f22f7e007cbb437d78792fe2a0e4",
+ "license_files": [
+ {
+ "file": "vendor/phpseclib/phpseclib/LICENSE",
+ "sha256": "fa986c1ac53f7bd83e30fe296f8dced7003ab0a05ec11945b5aea4a4ebd626dd"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "promphp/prometheus_client_php",
+ "version": "v2.15.1",
+ "direct": false,
+ "scope": "runtime",
+ "spdx": [
+ "Apache-2.0"
+ ],
+ "source_url": "https://github.com/PromPHP/prometheus_client_php.git",
+ "source_commit": "e153786bda5ca1d07335a2b5f477742c37195c40",
+ "source_permalink": "https://github.com/PromPHP/prometheus_client_php/tree/e153786bda5ca1d07335a2b5f477742c37195c40",
+ "license_files": [
+ {
+ "file": "vendor/promphp/prometheus_client_php/LICENSE",
+ "sha256": "9368976483a5d7ca12c5078170d7a199c9edac809eb76402bbe063e4ff972425"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "psr/http-factory",
+ "version": "1.1.0",
+ "direct": false,
+ "scope": "runtime",
+ "spdx": [
+ "MIT"
+ ],
+ "source_url": "https://github.com/php-fig/http-factory.git",
+ "source_commit": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
+ "source_permalink": "https://github.com/php-fig/http-factory/tree/2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
+ "license_files": [
+ {
+ "file": "vendor/psr/http-factory/LICENSE",
+ "sha256": "d2e70341a9667b93ad542e4842b0b7022d302aa7c2d2a909b156b8f1e3c4cca8"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "psr/http-message",
+ "version": "2.0",
+ "direct": false,
+ "scope": "runtime",
+ "spdx": [
+ "MIT"
+ ],
+ "source_url": "https://github.com/php-fig/http-message.git",
+ "source_commit": "402d35bcb92c70c026d1a6a9883f06b2ead23d71",
+ "source_permalink": "https://github.com/php-fig/http-message/tree/402d35bcb92c70c026d1a6a9883f06b2ead23d71",
+ "license_files": [
+ {
+ "file": "vendor/psr/http-message/LICENSE",
+ "sha256": "8d36e938943b04ef01994bb148793c971685b746d0ced31da03a97594a47ee62"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "psr/log",
+ "version": "3.0.2",
+ "direct": false,
+ "scope": "runtime",
+ "spdx": [
+ "MIT"
+ ],
+ "source_url": "https://github.com/php-fig/log.git",
+ "source_commit": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
+ "source_permalink": "https://github.com/php-fig/log/tree/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
+ "license_files": [
+ {
+ "file": "vendor/psr/log/LICENSE",
+ "sha256": "6e944907067bae93c00a9f2ad51032243820b22df34b77c77690d46cb2e98e24"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "revolt/event-loop",
+ "version": "v1.0.9",
+ "direct": true,
+ "scope": "runtime",
+ "spdx": [
+ "MIT"
+ ],
+ "source_url": "https://github.com/revoltphp/event-loop.git",
+ "source_commit": "44061cf513e53c6200372fc935ac42271566295d",
+ "source_permalink": "https://github.com/revoltphp/event-loop/tree/44061cf513e53c6200372fc935ac42271566295d",
+ "license_files": [
+ {
+ "file": "vendor/revolt/event-loop/LICENSE",
+ "sha256": "54e35a998b20dce201850275dd1c66c1685489972ecee9410600b8186e630853"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "symfony/polyfill-mbstring",
+ "version": "v1.43.0",
+ "direct": false,
+ "scope": "runtime",
+ "spdx": [
+ "MIT"
+ ],
+ "source_url": "https://github.com/symfony/polyfill-mbstring.git",
+ "source_commit": "4d5d331bf731e0aedbc6b2f5e61135b7b7996a72",
+ "source_permalink": "https://github.com/symfony/polyfill-mbstring/tree/4d5d331bf731e0aedbc6b2f5e61135b7b7996a72",
+ "license_files": [
+ {
+ "file": "vendor/symfony/polyfill-mbstring/LICENSE",
+ "sha256": "eaa5d6e59ff1d5322ae36d3ab9124460df72d2d0c8af60a99222e8a90dcbdf31"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "symfony/polyfill-php83",
+ "version": "v1.43.0",
+ "direct": false,
+ "scope": "runtime",
+ "spdx": [
+ "MIT"
+ ],
+ "source_url": "https://github.com/symfony/polyfill-php83.git",
+ "source_commit": "80ccff923a8d61f73ebe9da0aa94e25232f004ce",
+ "source_permalink": "https://github.com/symfony/polyfill-php83/tree/80ccff923a8d61f73ebe9da0aa94e25232f004ce",
+ "license_files": [
+ {
+ "file": "vendor/symfony/polyfill-php83/LICENSE",
+ "sha256": "6439cc1bba78b7de0c1905b0643679a5a7768ba020a090370d7742030303c6b2"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "webmozart/assert",
+ "version": "1.12.1",
+ "direct": false,
+ "scope": "runtime",
+ "spdx": [
+ "MIT"
+ ],
+ "source_url": "https://github.com/webmozarts/assert.git",
+ "source_commit": "9be6926d8b485f55b9229203f962b51ed377ba68",
+ "source_permalink": "https://github.com/webmozarts/assert/tree/9be6926d8b485f55b9229203f962b51ed377ba68",
+ "license_files": [
+ {
+ "file": "vendor/webmozart/assert/LICENSE",
+ "sha256": "5f82f5e664e8683e3d97d3bec6f74620b9d0a4d8669f9e617da96bce8767d4d3"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "wizardloop/broadcastmanager",
+ "version": "3.2.2",
+ "direct": true,
+ "scope": "runtime",
+ "spdx": [
+ "AGPL-3.0-only"
+ ],
+ "source_url": "https://github.com/WizardLoop/BroadcastManager.git",
+ "source_commit": "f2792a383416b66bb68e55f09414563e540538d6",
+ "source_permalink": "https://github.com/WizardLoop/BroadcastManager/tree/f2792a383416b66bb68e55f09414563e540538d6",
+ "license_files": [
+ {
+ "file": "vendor/wizardloop/broadcastmanager/LICENSE",
+ "sha256": "8486a10c4393cee1c25392769ddd3b2d6c242d6ec7928e1414efff7dfb2f07ef"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "myclabs/deep-copy",
+ "version": "1.14.0",
+ "direct": false,
+ "scope": "dev",
+ "spdx": [
+ "MIT"
+ ],
+ "source_url": "https://github.com/myclabs/DeepCopy.git",
+ "source_commit": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae",
+ "source_permalink": "https://github.com/myclabs/DeepCopy/tree/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae",
+ "license_files": [
+ {
+ "file": "vendor/myclabs/deep-copy/LICENSE",
+ "sha256": "90007d13610253dfbbe382190c336b50718f8344d5bff230d0580282fb16a7d6"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "phar-io/manifest",
+ "version": "2.0.4",
+ "direct": false,
+ "scope": "dev",
+ "spdx": [
+ "BSD-3-Clause"
+ ],
+ "source_url": "https://github.com/phar-io/manifest.git",
+ "source_commit": "54750ef60c58e43759730615a392c31c80e23176",
+ "source_permalink": "https://github.com/phar-io/manifest/tree/54750ef60c58e43759730615a392c31c80e23176",
+ "license_files": [
+ {
+ "file": "vendor/phar-io/manifest/LICENSE",
+ "sha256": "c882c8ea59ec1a1fdaa12ba56e5464be82c09f6924ed8a04883cec3b5c0126a1"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "phar-io/version",
+ "version": "3.2.1",
+ "direct": false,
+ "scope": "dev",
+ "spdx": [
+ "BSD-3-Clause"
+ ],
+ "source_url": "https://github.com/phar-io/version.git",
+ "source_commit": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74",
+ "source_permalink": "https://github.com/phar-io/version/tree/4f7fd7836c6f332bb2933569e566a0d6c4cbed74",
+ "license_files": [
+ {
+ "file": "vendor/phar-io/version/LICENSE",
+ "sha256": "f24078e519642d911ca010cd683f68ba4cb6ceeef35ce40b479da1ab9ab73427"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "phpstan/phpstan",
+ "version": "2.3.1",
+ "direct": true,
+ "scope": "dev",
+ "spdx": [
+ "MIT"
+ ],
+ "source_url": "https://api.github.com/repos/phpstan/phpstan/zipball/6155239b488e95d26d1ebd95c6b4bb294d7f09db",
+ "source_commit": "6155239b488e95d26d1ebd95c6b4bb294d7f09db",
+ "source_permalink": "https://api.github.com/repos/phpstan/phpstan/zipball/6155239b488e95d26d1ebd95c6b4bb294d7f09db/tree/6155239b488e95d26d1ebd95c6b4bb294d7f09db",
+ "license_files": [
+ {
+ "file": "vendor/phpstan/phpstan/LICENSE",
+ "sha256": "f9e4f43eb1c32f7c4f5bcdb0860de5ba71712c3c1547eeb0d1e4b3c43313ec81"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "phpunit/php-code-coverage",
+ "version": "12.5.8",
+ "direct": false,
+ "scope": "dev",
+ "spdx": [
+ "BSD-3-Clause"
+ ],
+ "source_url": "https://github.com/sebastianbergmann/php-code-coverage.git",
+ "source_commit": "7440368d94738d54f2c1de24dbd3871793bbb0dd",
+ "source_permalink": "https://github.com/sebastianbergmann/php-code-coverage/tree/7440368d94738d54f2c1de24dbd3871793bbb0dd",
+ "license_files": [
+ {
+ "file": "vendor/phpunit/php-code-coverage/LICENSE",
+ "sha256": "45e50b328bedfa6a1106f257f421ede537e392c98c47d00291c7cd662ea02d9b"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "phpunit/php-file-iterator",
+ "version": "6.0.2",
+ "direct": false,
+ "scope": "dev",
+ "spdx": [
+ "BSD-3-Clause"
+ ],
+ "source_url": "https://github.com/sebastianbergmann/php-file-iterator.git",
+ "source_commit": "a248d1640ab059b075f53a2ef0f9856e864e06b5",
+ "source_permalink": "https://github.com/sebastianbergmann/php-file-iterator/tree/a248d1640ab059b075f53a2ef0f9856e864e06b5",
+ "license_files": [
+ {
+ "file": "vendor/phpunit/php-file-iterator/LICENSE",
+ "sha256": "45e50b328bedfa6a1106f257f421ede537e392c98c47d00291c7cd662ea02d9b"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "phpunit/php-invoker",
+ "version": "6.0.0",
+ "direct": false,
+ "scope": "dev",
+ "spdx": [
+ "BSD-3-Clause"
+ ],
+ "source_url": "https://github.com/sebastianbergmann/php-invoker.git",
+ "source_commit": "12b54e689b07a25a9b41e57736dfab6ec9ae5406",
+ "source_permalink": "https://github.com/sebastianbergmann/php-invoker/tree/12b54e689b07a25a9b41e57736dfab6ec9ae5406",
+ "license_files": [
+ {
+ "file": "vendor/phpunit/php-invoker/LICENSE",
+ "sha256": "781184dbfd950e2361eac195f0677f8de995e77dcf2c28b275f1b88305766365"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "phpunit/php-text-template",
+ "version": "5.0.1",
+ "direct": false,
+ "scope": "dev",
+ "spdx": [
+ "BSD-3-Clause"
+ ],
+ "source_url": "https://github.com/sebastianbergmann/php-text-template.git",
+ "source_commit": "babab73b7ef9fae406f2eb3ee11323a61caa63d5",
+ "source_permalink": "https://github.com/sebastianbergmann/php-text-template/tree/babab73b7ef9fae406f2eb3ee11323a61caa63d5",
+ "license_files": [
+ {
+ "file": "vendor/phpunit/php-text-template/LICENSE",
+ "sha256": "45e50b328bedfa6a1106f257f421ede537e392c98c47d00291c7cd662ea02d9b"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "phpunit/php-timer",
+ "version": "8.0.0",
+ "direct": false,
+ "scope": "dev",
+ "spdx": [
+ "BSD-3-Clause"
+ ],
+ "source_url": "https://github.com/sebastianbergmann/php-timer.git",
+ "source_commit": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc",
+ "source_permalink": "https://github.com/sebastianbergmann/php-timer/tree/f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc",
+ "license_files": [
+ {
+ "file": "vendor/phpunit/php-timer/LICENSE",
+ "sha256": "a7d02dc8a24a343c7f3226b58e0a6749cdf426b9f008a1df2e437bd89d7c6943"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "phpunit/phpunit",
+ "version": "12.5.39",
+ "direct": true,
+ "scope": "dev",
+ "spdx": [
+ "BSD-3-Clause"
+ ],
+ "source_url": "https://github.com/sebastianbergmann/phpunit.git",
+ "source_commit": "4e8cbead3741cc57f16d142f4c5dc73c2747820a",
+ "source_permalink": "https://github.com/sebastianbergmann/phpunit/tree/4e8cbead3741cc57f16d142f4c5dc73c2747820a",
+ "license_files": [
+ {
+ "file": "vendor/phpunit/phpunit/LICENSE",
+ "sha256": "5aa383bf44a3951bb4a2214c740a96e7795e473e1a0abc8d5812e6a3c1dbfc1e"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "sebastian/cli-parser",
+ "version": "4.2.1",
+ "direct": false,
+ "scope": "dev",
+ "spdx": [
+ "BSD-3-Clause"
+ ],
+ "source_url": "https://github.com/sebastianbergmann/cli-parser.git",
+ "source_commit": "7d05781b13f7dec9043a629a21d086ed74582a15",
+ "source_permalink": "https://github.com/sebastianbergmann/cli-parser/tree/7d05781b13f7dec9043a629a21d086ed74582a15",
+ "license_files": [
+ {
+ "file": "vendor/sebastian/cli-parser/LICENSE",
+ "sha256": "759a5f7f752db78d04d43ec73bf6c0ee4bbec2f8f64596bd95419c17af9f3ed4"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "sebastian/comparator",
+ "version": "7.1.8",
+ "direct": false,
+ "scope": "dev",
+ "spdx": [
+ "BSD-3-Clause"
+ ],
+ "source_url": "https://github.com/sebastianbergmann/comparator.git",
+ "source_commit": "7c65c1e79836812819705b473a90c12399542485",
+ "source_permalink": "https://github.com/sebastianbergmann/comparator/tree/7c65c1e79836812819705b473a90c12399542485",
+ "license_files": [
+ {
+ "file": "vendor/sebastian/comparator/LICENSE",
+ "sha256": "c1b7b5b9bee26266f6d958a8b31b08cdddbf0aec34cc42b12f8b175f93b2dc4f"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "sebastian/complexity",
+ "version": "5.0.0",
+ "direct": false,
+ "scope": "dev",
+ "spdx": [
+ "BSD-3-Clause"
+ ],
+ "source_url": "https://github.com/sebastianbergmann/complexity.git",
+ "source_commit": "bad4316aba5303d0221f43f8cee37eb58d384bbb",
+ "source_permalink": "https://github.com/sebastianbergmann/complexity/tree/bad4316aba5303d0221f43f8cee37eb58d384bbb",
+ "license_files": [
+ {
+ "file": "vendor/sebastian/complexity/LICENSE",
+ "sha256": "f3b7f57c47d30a716d39846c5cd5482d01437d30b2eb1a97dc7df0d926af5c5f"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "sebastian/diff",
+ "version": "7.0.1",
+ "direct": false,
+ "scope": "dev",
+ "spdx": [
+ "BSD-3-Clause"
+ ],
+ "source_url": "https://github.com/sebastianbergmann/diff.git",
+ "source_commit": "cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6",
+ "source_permalink": "https://github.com/sebastianbergmann/diff/tree/cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6",
+ "license_files": [
+ {
+ "file": "vendor/sebastian/diff/LICENSE",
+ "sha256": "c1b7b5b9bee26266f6d958a8b31b08cdddbf0aec34cc42b12f8b175f93b2dc4f"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "sebastian/environment",
+ "version": "8.1.2",
+ "direct": false,
+ "scope": "dev",
+ "spdx": [
+ "BSD-3-Clause"
+ ],
+ "source_url": "https://github.com/sebastianbergmann/environment.git",
+ "source_commit": "9d32c685773823b1983e256ae4ecd48a10d6e439",
+ "source_permalink": "https://github.com/sebastianbergmann/environment/tree/9d32c685773823b1983e256ae4ecd48a10d6e439",
+ "license_files": [
+ {
+ "file": "vendor/sebastian/environment/LICENSE",
+ "sha256": "772a3b8c4e5846485ad60fe7759156408ef00b6d837358716380ee438f6c11a3"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "sebastian/exporter",
+ "version": "7.0.3",
+ "direct": false,
+ "scope": "dev",
+ "spdx": [
+ "BSD-3-Clause"
+ ],
+ "source_url": "https://github.com/sebastianbergmann/exporter.git",
+ "source_commit": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23",
+ "source_permalink": "https://github.com/sebastianbergmann/exporter/tree/c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23",
+ "license_files": [
+ {
+ "file": "vendor/sebastian/exporter/LICENSE",
+ "sha256": "c1b7b5b9bee26266f6d958a8b31b08cdddbf0aec34cc42b12f8b175f93b2dc4f"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "sebastian/global-state",
+ "version": "8.0.3",
+ "direct": false,
+ "scope": "dev",
+ "spdx": [
+ "BSD-3-Clause"
+ ],
+ "source_url": "https://github.com/sebastianbergmann/global-state.git",
+ "source_commit": "b164d3274d6537ab462591c5755f76a8f5b1aae9",
+ "source_permalink": "https://github.com/sebastianbergmann/global-state/tree/b164d3274d6537ab462591c5755f76a8f5b1aae9",
+ "license_files": [
+ {
+ "file": "vendor/sebastian/global-state/LICENSE",
+ "sha256": "5aa383bf44a3951bb4a2214c740a96e7795e473e1a0abc8d5812e6a3c1dbfc1e"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "sebastian/lines-of-code",
+ "version": "4.0.1",
+ "direct": false,
+ "scope": "dev",
+ "spdx": [
+ "BSD-3-Clause"
+ ],
+ "source_url": "https://github.com/sebastianbergmann/lines-of-code.git",
+ "source_commit": "d543b8ef219dcd8da262cbb958639a96bedba10e",
+ "source_permalink": "https://github.com/sebastianbergmann/lines-of-code/tree/d543b8ef219dcd8da262cbb958639a96bedba10e",
+ "license_files": [
+ {
+ "file": "vendor/sebastian/lines-of-code/LICENSE",
+ "sha256": "759a5f7f752db78d04d43ec73bf6c0ee4bbec2f8f64596bd95419c17af9f3ed4"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "sebastian/object-enumerator",
+ "version": "7.0.0",
+ "direct": false,
+ "scope": "dev",
+ "spdx": [
+ "BSD-3-Clause"
+ ],
+ "source_url": "https://github.com/sebastianbergmann/object-enumerator.git",
+ "source_commit": "1effe8e9b8e068e9ae228e542d5d11b5d16db894",
+ "source_permalink": "https://github.com/sebastianbergmann/object-enumerator/tree/1effe8e9b8e068e9ae228e542d5d11b5d16db894",
+ "license_files": [
+ {
+ "file": "vendor/sebastian/object-enumerator/LICENSE",
+ "sha256": "b4b44781733a8c15776a738f8dbe599f867e98c2dd9e0b4e766fa6f7cf72fce2"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "sebastian/object-reflector",
+ "version": "5.0.0",
+ "direct": false,
+ "scope": "dev",
+ "spdx": [
+ "BSD-3-Clause"
+ ],
+ "source_url": "https://github.com/sebastianbergmann/object-reflector.git",
+ "source_commit": "4bfa827c969c98be1e527abd576533293c634f6a",
+ "source_permalink": "https://github.com/sebastianbergmann/object-reflector/tree/4bfa827c969c98be1e527abd576533293c634f6a",
+ "license_files": [
+ {
+ "file": "vendor/sebastian/object-reflector/LICENSE",
+ "sha256": "9000f616c81278b859f8b7b7016f54480149b0b0ad819c9c2119c40514f40ba7"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "sebastian/recursion-context",
+ "version": "7.0.1",
+ "direct": false,
+ "scope": "dev",
+ "spdx": [
+ "BSD-3-Clause"
+ ],
+ "source_url": "https://github.com/sebastianbergmann/recursion-context.git",
+ "source_commit": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c",
+ "source_permalink": "https://github.com/sebastianbergmann/recursion-context/tree/0b01998a7d5b1f122911a66bebcb8d46f0c82d8c",
+ "license_files": [
+ {
+ "file": "vendor/sebastian/recursion-context/LICENSE",
+ "sha256": "bd6164746a2e92bf9fb581f8c691901019004e8c40d558eb01de1a535ccb5508"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "sebastian/type",
+ "version": "6.0.4",
+ "direct": false,
+ "scope": "dev",
+ "spdx": [
+ "BSD-3-Clause"
+ ],
+ "source_url": "https://github.com/sebastianbergmann/type.git",
+ "source_commit": "82ff822c2edc46724be9f7411d3163021f602773",
+ "source_permalink": "https://github.com/sebastianbergmann/type/tree/82ff822c2edc46724be9f7411d3163021f602773",
+ "license_files": [
+ {
+ "file": "vendor/sebastian/type/LICENSE",
+ "sha256": "923961ef4cae167a3569487ae6957f578b29bcd1e6c477092cb2a68c25ebdf11"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "sebastian/version",
+ "version": "6.0.1",
+ "direct": false,
+ "scope": "dev",
+ "spdx": [
+ "BSD-3-Clause"
+ ],
+ "source_url": "https://github.com/sebastianbergmann/version.git",
+ "source_commit": "9e935a16187e14f39b0e79910dabd614972cdb26",
+ "source_permalink": "https://github.com/sebastianbergmann/version/tree/9e935a16187e14f39b0e79910dabd614972cdb26",
+ "license_files": [
+ {
+ "file": "vendor/sebastian/version/LICENSE",
+ "sha256": "b288407b327ef0e297678bbc8dac11a66230f596180a031659622e09d92b1d0e"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "squizlabs/php_codesniffer",
+ "version": "4.0.4",
+ "direct": true,
+ "scope": "dev",
+ "spdx": [
+ "BSD-3-Clause"
+ ],
+ "source_url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git",
+ "source_commit": "bbdc3d0532623e21838b7041a4364383a8126f96",
+ "source_permalink": "https://github.com/PHPCSStandards/PHP_CodeSniffer/tree/bbdc3d0532623e21838b7041a4364383a8126f96",
+ "license_files": [
+ {
+ "file": "vendor/squizlabs/php_codesniffer/licence.txt",
+ "sha256": "ad489e425c9a48b9fe97b8c11dd5f422606aa72e7726a438cd01ebee22d8ba80"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "staabm/side-effects-detector",
+ "version": "1.0.5",
+ "direct": false,
+ "scope": "dev",
+ "spdx": [
+ "MIT"
+ ],
+ "source_url": "https://github.com/staabm/side-effects-detector.git",
+ "source_commit": "d8334211a140ce329c13726d4a715adbddd0a163",
+ "source_permalink": "https://github.com/staabm/side-effects-detector/tree/d8334211a140ce329c13726d4a715adbddd0a163",
+ "license_files": [
+ {
+ "file": "vendor/staabm/side-effects-detector/LICENSE",
+ "sha256": "23d428232751fbfd032b47195b3d2a60342d0cf0cdb6e2f474a6b19733e63d75"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ },
+ {
+ "name": "theseer/tokenizer",
+ "version": "2.0.1",
+ "direct": false,
+ "scope": "dev",
+ "spdx": [
+ "BSD-3-Clause"
+ ],
+ "source_url": "https://github.com/theseer/tokenizer.git",
+ "source_commit": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4",
+ "source_permalink": "https://github.com/theseer/tokenizer/tree/7989e43bf381af0eac72e4f0ca5bcbfa81658be4",
+ "license_files": [
+ {
+ "file": "vendor/theseer/tokenizer/LICENSE",
+ "sha256": "2820584a513c2547c81656abe330f414b4b395994d552f4c5e13c3d6e9279a90"
+ }
+ ],
+ "attribution": "Preserve distributed license and copyright notices; additional source obligations depend on the declared license.",
+ "status": "LICENSE_TEXT_PRESENT",
+ "findings": []
+ }
+ ],
+ "services_and_assets": [
+ {
+ "name": "Hebcal",
+ "kind": "data service",
+ "terms": "https://www.hebcal.com/home/195/jewish-calendar-rest-api",
+ "status": "Review separate data terms and attribution in THIRD_PARTY_NOTICES.md"
+ },
+ {
+ "name": "GeoNames",
+ "kind": "data service",
+ "terms": "https://www.geonames.org/about.html",
+ "status": "Account and quotas required; data attribution independent of application AGPL"
+ },
+ {
+ "name": "omer-counts.json",
+ "kind": "bundled data resource",
+ "file": "resources/templates/omer-counts.json",
+ "sha256": "11fc1d33365021edb0ebebc46a01ac3727d9e31fb1f4a5fb79bd7fa7474af95d",
+ "spdx": "CC-BY-4.0",
+ "license_files": [
+ {
+ "file": "LICENSES/CC-BY-4.0.txt",
+ "sha256": "9ba9550ad48438d0836ddab3da480b3b69ffa0aac7b7878b5a0039e7ab429411"
+ }
+ ],
+ "attribution": "Hebcal.com",
+ "source": "https://www.hebcal.com/hebcal?v=1&cfg=json&start=2026-04-03&end=2026-05-21&o=on&i=on&lg=he",
+ "retrieved_at": null,
+ "notes": "",
+ "status": "Bundled metadata recorded; preserve data license and attribution separately from the application AGPL."
+ },
+ {
+ "name": "omer-counts-localized.json",
+ "kind": "bundled data resource",
+ "file": "resources/templates/omer-counts-localized.json",
+ "sha256": "d43303e759b7b3522495fbc706a1ea0ea78abd31d94c06397e6e74bdd18c55f9",
+ "spdx": "CC-BY-4.0",
+ "license_files": [
+ {
+ "file": "LICENSES/CC-BY-4.0.txt",
+ "sha256": "9ba9550ad48438d0836ddab3da480b3b69ffa0aac7b7878b5a0039e7ab429411"
+ }
+ ],
+ "attribution": "Hebcal.com; explanatory translations by WizardLoop Shabbat",
+ "source": "https://www.hebcal.com/hebcal?v=1&cfg=json&start=2026-04-03&end=2026-05-21&o=on&i=on&lg=he",
+ "retrieved_at": null,
+ "notes": "Static explanatory translations of the verified 49-day calendar count; Hebrew ritual wording remains in omer-counts.json. No runtime translation or AI generation.",
+ "status": "Bundled metadata recorded; preserve data license and attribution separately from the application AGPL."
+ },
+ {
+ "name": "omer-liturgy.json",
+ "kind": "bundled data resource",
+ "file": "resources/templates/omer-liturgy.json",
+ "sha256": "e7547cf2b6c9d44e1bcd5fd9d1755823ef6e0519140c9dd217c413261d43f0d9",
+ "spdx": "CC-BY-SA-4.0",
+ "license_files": [
+ {
+ "file": "LICENSES/CC-BY-SA-4.0.txt",
+ "sha256": "28a9529c7d0bb4dc51f4bf5c116a3d16ef247a052f7591466768ddf563fd1cf5"
+ }
+ ],
+ "attribution": "Hebrew Wikisource contributors; traditional Hebrew liturgy and biblical texts",
+ "source": [
+ {
+ "key": "ashkenaz",
+ "url": "https://he.wikisource.org/w/index.php?title=ืกืืืืจ/ื ืืกื_ืืฉืื ื/ืกืคืืจืช_ืืขืืืจ&oldid=3017054",
+ "revision": 3017054
+ },
+ {
+ "key": "sephard",
+ "url": "https://he.wikisource.org/w/index.php?title=ืกืืจ_ืกืคืืจืช_ืืขืืืจ&oldid=2929975",
+ "revision": 2929975
+ },
+ {
+ "key": "psalm67",
+ "url": "https://he.wikisource.org/w/index.php?title=ืชืืืื_ืกื&oldid=1014815",
+ "revision": 1014815
+ },
+ {
+ "key": "ana_bekoach",
+ "url": "https://he.wikisource.org/w/index.php?title=ืื ื_ืืื&oldid=3085666",
+ "revision": 3085666
+ }
+ ],
+ "retrieved_at": "2026-10-13",
+ "notes": "Traditional text transcribed without vowel points or cantillation; divine name displayed as ืืณ. Line breaks and punctuation normalized. Ana Bekoach uses the documented ืจืืื variant; sources display ืจืืื as an alternative. Ashkenaz optional preparation and short concluding prayer; Sephard preparation and extended concluding prayer. No Edot HaMizrach or other nusach is mixed in. Counts are separately sourced from Hebcal, never the incomplete Wikisource numbered count list.",
+ "status": "Bundled metadata recorded; preserve data license and attribution separately from the application AGPL."
+ }
+ ],
+ "local_patches": [
+ {
+ "manifest": "patches/madelineproto/manifest.json",
+ "sha256": "1f46278a04f34ff574e30f034b800d3120b9e47ad2135270f9242150989b57fe",
+ "package": "danog/madelineproto",
+ "target": "src/Loop/Update/UpdateLoop.php",
+ "approved": [
+ {
+ "version": "8.7.0",
+ "commit": "7b99dd0f2d964a492c377912ffce9f5ce61aa9ed",
+ "mode": "patch",
+ "baseline": "baseline/UpdateLoop.php",
+ "source_url": "https://github.com/danog/MadelineProto/blob/7b99dd0f2d964a492c377912ffce9f5ce61aa9ed/src/Loop/Update/UpdateLoop.php",
+ "original_sha256": "2fbcbb73822258102837f53f4a231b87526d66809be7acd6339156ea778a8c2f",
+ "patched_sha256": "8f6cb1a483c879024bddea289e90d4bf2d71dd15716102bc83a1f34a243782e4",
+ "patch": "update-loop-timeout-cancellation.patch",
+ "patch_sha256": "2f5c34d5e2749d3575ef9de1ec80bff60a9ced1240ca61ef8727583ee939f592"
+ }
+ ],
+ "notes": "Locally modified library source and its immutable baseline/diff ship in the corresponding-source archive; preserve upstream notices."
+ },
+ {
+ "manifest": "patches/madelineproto-geopoint/manifest.json",
+ "sha256": "30b517d219f0cf7156b59c4e34cd9f0be262fc57bbb5cedb5108e62953900d48",
+ "package": "danog/madelineproto",
+ "target": "src/EventHandler/Media/GeoPoint.php",
+ "approved": [
+ {
+ "version": "8.7.0",
+ "commit": "7b99dd0f2d964a492c377912ffce9f5ce61aa9ed",
+ "mode": "patch",
+ "baseline": "baseline/GeoPoint.php",
+ "reviewed_output": "reviewed/GeoPoint.php",
+ "original_sha256": "9ed8054caade6abc96420b0f5b120bec029ee2bc7b7b6aacca56fb7ddc61a27b",
+ "patched_sha256": "5633cc441e541e5e919dfa8156192587a68e578e49a496acac91172be5f2549d",
+ "patch": "geopoint-double-coordinates.patch",
+ "patch_sha256": "b1b830953dce74978ddc64198a958d0ac13d5205d8929daa58a31e86c0541556"
+ }
+ ],
+ "notes": "Locally modified library source and its immutable baseline/diff ship in the corresponding-source archive; preserve upstream notices."
+ },
+ {
+ "manifest": "patches/madelineproto-formatted-date/manifest.json",
+ "sha256": "77376a8eef3743583f481d1bd83cb1b1e5384ccf7ce004b5922a3e792a41f807",
+ "package": "danog/madelineproto",
+ "target": "src/EventHandler/Message/Entities/MessageEntity.php",
+ "approved": [
+ {
+ "version": "8.7.0",
+ "commit": "7b99dd0f2d964a492c377912ffce9f5ce61aa9ed",
+ "mode": "patch",
+ "baseline": "baseline/MessageEntity.php",
+ "original_sha256": "06a9d438cc198f56cfb745a2a3808932269033671146cf82f84ae88e8d6eae60",
+ "reviewed_output": "reviewed/MessageEntity.php",
+ "patched_sha256": "b265ed707326e1d71c7a8bdf8291f0b8c94a153b98a2c4c45c776fec3b374005",
+ "patch": "formatted-date-entity.patch",
+ "patch_sha256": "5c726acbc73ef6b4994d1c970f74952af007c2eee3c92773cf593e73e834b3d3",
+ "source_url": "https://github.com/danog/MadelineProto/blob/7b99dd0f2d964a492c377912ffce9f5ce61aa9ed/src/EventHandler/Message/Entities/MessageEntity.php",
+ "license_files": [
+ {
+ "file": "upstream-LICENSE",
+ "sha256": "57c8ff33c9c0cfc3ef00e650a1cc910d7ee479a8bc509f6c9209a7c2a11399d6"
+ }
+ ],
+ "added_files": [
+ {
+ "target": "src/EventHandler/Message/Entities/FormattedDate.php",
+ "original_state": "absent",
+ "reviewed_output": "reviewed/FormattedDate.php",
+ "patched_sha256": "ffe03d0d78215bc42968cd836c25b1f8a602f161fb55eba189b4102d9b99d5e5",
+ "license": "AGPL-3.0-or-later"
+ }
+ ],
+ "prerequisites": [
+ {
+ "target": "src/TL_telegram_v225.tl",
+ "baseline": "baseline/TL_telegram_v225.tl",
+ "sha256": "539272f5ad8992ee97b61bd134fcf3a28a41ce720e806d0e7dc0e2e408c581f9",
+ "source_url": "https://github.com/danog/MadelineProto/blob/7b99dd0f2d964a492c377912ffce9f5ce61aa9ed/src/TL_telegram_v225.tl"
+ }
+ ]
+ }
+ ],
+ "notes": "Locally modified library source and its immutable baseline/diff ship in the corresponding-source archive; preserve upstream notices."
+ },
+ {
+ "manifest": "patches/madelineproto-fresh-peer/manifest.json",
+ "sha256": "40d39192dfcb91c64de98daf2eac5910c76e2203a306c656fc4b5b3bc8a5fd3e",
+ "package": "danog/madelineproto",
+ "target": "src/MTProtoTools/PeerHandler.php",
+ "approved": [
+ {
+ "version": "8.7.0",
+ "commit": "7b99dd0f2d964a492c377912ffce9f5ce61aa9ed",
+ "mode": "patch",
+ "baseline": "baseline/PeerHandler.php",
+ "reviewed_output": "reviewed/PeerHandler.php",
+ "original_sha256": "3e1af3624f2256f53264fe4256475d0a5f353af8761cc9716133b7f0412538d2",
+ "patched_sha256": "8188777fc376f2b3cca39963d79f85239fc84141f978128ea2bb591f03891928",
+ "patch": "fresh-full-peer-response.patch",
+ "patch_sha256": "db3099e7bf83550b7a4a4f54da451bc45a1078b8bd9bffc57f26bf52ab2d82e3"
+ }
+ ],
+ "notes": "Locally modified library source and its immutable baseline/diff ship in the corresponding-source archive; preserve upstream notices."
+ },
+ {
+ "manifest": "patches/broadcastmanager/manifest.json",
+ "sha256": "83b97a1c1b3be4ff32b37fa03167eeae1f94fbcd15ee72c7aa1f9463f56ba303",
+ "package": "wizardloop/broadcastmanager",
+ "target": "src/BroadcastManager.php",
+ "approved": [
+ {
+ "version": "3.2.2",
+ "commit": "f2792a383416b66bb68e55f09414563e540538d6",
+ "mode": "patch",
+ "baseline": "baseline/BroadcastManager.php",
+ "reviewed_output": "reviewed/BroadcastManager.php",
+ "original_sha256": "2a9a42dc6e3f6d7b51c178e7d9db962a5b5ac424046af18afcfcdb12d41dc6e8",
+ "patched_sha256": "d665b24009aabfb5b09e00edad1271f1501bf0ef797d5a2593129da91c8cc435",
+ "patch": "status-localization-hooks.patch",
+ "patch_sha256": "8d115dfd42568181c365001373772e3b52b06e7c9fbc4433878e30d0390097bb",
+ "intermediates": [
+ {
+ "source": "previous/dev3/BroadcastManager.php",
+ "sha256": "3d9425f7911b12c69faae5e87954db134dc3fb7c2f7d654a72f972ebeb84e424",
+ "patch": "dev3-to-transport-hooks.patch",
+ "patch_sha256": "42af3d2eb99003512477607f0cf34760d5f0b6b5169467a8115fae5ef3c69374"
+ },
+ {
+ "source": "previous/dev8/BroadcastManager.php",
+ "sha256": "08db35c271f671041257ebe79d1010cdd7c33c4d5868e4553c78612f9e8bf0f3",
+ "patch": "dev8-to-runtime-lifecycle.patch",
+ "patch_sha256": "2630fa3f02b9bb361e1a4ade2b3edccc40d4c0fc6468272b6bc6ced89cbf0a84"
+ }
+ ]
+ }
+ ],
+ "notes": "Locally modified library source and its immutable baseline/diff ship in the corresponding-source archive; preserve upstream notices."
+ }
+ ],
+ "external_runtime": [
+ {
+ "name": "SQLite",
+ "php_loaded_version": "3.45.2",
+ "license": "public-domain",
+ "terms": "https://sqlite.org/copyright.html",
+ "wal_fix_verified": false,
+ "docker_source_version": "3.51.3",
+ "docker_source_url": "https://www.sqlite.org/2026/sqlite-autoconf-3510300.tar.gz",
+ "docker_source_sha256": "81f5be397049b0cae1b167f2225af7646fc0f82e4a9b3c48c9ea3a533e21d77a",
+ "docker_build_status": "NOT VERIFIED locally: Docker unavailable on test host"
+ }
+ ]
+}
diff --git a/migrations/001_initial.sql b/migrations/001_initial.sql
new file mode 100644
index 0000000..a6b90c3
--- /dev/null
+++ b/migrations/001_initial.sql
@@ -0,0 +1,107 @@
+-- SPDX-License-Identifier: AGPL-3.0-only
+CREATE TABLE groups (
+ group_id TEXT PRIMARY KEY CHECK(length(group_id) BETWEEN 1 AND 24),
+ title TEXT NOT NULL DEFAULT '', active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)),
+ created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
+);
+CREATE TABLE locations (
+ location_id INTEGER PRIMARY KEY, geoname_id INTEGER UNIQUE, display_name TEXT NOT NULL,
+ country TEXT NOT NULL DEFAULT '', administrative_area TEXT NOT NULL DEFAULT '',
+ latitude REAL NOT NULL CHECK(latitude BETWEEN -90 AND 90), longitude REAL NOT NULL CHECK(longitude BETWEEN -180 AND 180),
+ timezone_id TEXT NOT NULL, source TEXT NOT NULL, verified_at INTEGER
+);
+CREATE TABLE group_settings (
+ group_id TEXT PRIMARY KEY REFERENCES groups(group_id), revision INTEGER NOT NULL DEFAULT 1 CHECK(revision > 0),
+ location_name TEXT NOT NULL DEFAULT 'Jerusalem', country TEXT NOT NULL DEFAULT 'IL', geoname_id INTEGER DEFAULT 281184,
+ latitude REAL NOT NULL DEFAULT 31.76904 CHECK(latitude BETWEEN -90 AND 90), longitude REAL NOT NULL DEFAULT 35.21633 CHECK(longitude BETWEEN -180 AND 180),
+ timezone_id TEXT NOT NULL DEFAULT 'Asia/Jerusalem', location_source TEXT NOT NULL DEFAULT 'default',
+ israel INTEGER NOT NULL DEFAULT 1 CHECK(israel IN (0,1)), language TEXT NOT NULL DEFAULT 'he' CHECK(language IN ('he','en')),
+ candle_minutes INTEGER DEFAULT NULL CHECK(candle_minutes BETWEEN 1 AND 120),
+ havdalah_method TEXT NOT NULL DEFAULT 'degrees' CHECK(havdalah_method IN ('degrees','minutes')),
+ havdalah_value REAL NOT NULL DEFAULT 8.5 CHECK(havdalah_value > 0 AND havdalah_value <= 120),
+ close_before_candles_minutes INTEGER NOT NULL DEFAULT 0 CHECK(close_before_candles_minutes BETWEEN 0 AND 180),
+ open_after_havdalah_minutes INTEGER NOT NULL DEFAULT 0 CHECK(open_after_havdalah_minutes BETWEEN 0 AND 180),
+ mode TEXT NOT NULL DEFAULT 'lock' CHECK(mode IN ('off','messages','lock','lock_messages')),
+ shabbat_enabled INTEGER NOT NULL DEFAULT 1 CHECK(shabbat_enabled IN (0,1)), holidays_enabled INTEGER NOT NULL DEFAULT 1 CHECK(holidays_enabled IN (0,1)),
+ times_alert_enabled INTEGER NOT NULL DEFAULT 0 CHECK(times_alert_enabled IN (0,1)), times_alert_time TEXT NOT NULL DEFAULT '13:30',
+ times_alert_mode TEXT NOT NULL DEFAULT 'local_time' CHECK(times_alert_mode IN ('local_time','before_close')),
+ times_alert_before_minutes INTEGER NOT NULL DEFAULT 30 CHECK(times_alert_before_minutes BETWEEN 0 AND 1440),
+ close_message_enabled INTEGER NOT NULL DEFAULT 0 CHECK(close_message_enabled IN (0,1)), open_message_enabled INTEGER NOT NULL DEFAULT 0 CHECK(open_message_enabled IN (0,1)),
+ target_topic_id INTEGER, zmanim_enabled INTEGER NOT NULL DEFAULT 0 CHECK(zmanim_enabled IN (0,1)), zmanim_time TEXT NOT NULL DEFAULT '08:00',
+ zmanim_days TEXT NOT NULL DEFAULT '[0,1,2,3,4,5]', zmanim_format TEXT NOT NULL DEFAULT 'short',
+ daf_enabled INTEGER NOT NULL DEFAULT 0 CHECK(daf_enabled IN (0,1)), daf_time TEXT NOT NULL DEFAULT '08:00', daf_days TEXT NOT NULL DEFAULT '[0,1,2,3,4,5]', daf_template TEXT NOT NULL DEFAULT '',
+ tefillin_enabled INTEGER NOT NULL DEFAULT 0 CHECK(tefillin_enabled IN (0,1)), tefillin_days TEXT NOT NULL DEFAULT '[0,1,2,3,4,5]',
+ tefillin_fixed_times TEXT NOT NULL DEFAULT '["09:00"]', tefillin_interval_minutes INTEGER DEFAULT NULL CHECK(tefillin_interval_minutes >= 5),
+ tefillin_window_start TEXT NOT NULL DEFAULT '08:00', tefillin_window_end TEXT NOT NULL DEFAULT '17:00', tefillin_sunset_offsets TEXT NOT NULL DEFAULT '[60,30,15]',
+ tefillin_max_per_day INTEGER NOT NULL DEFAULT 3 CHECK(tefillin_max_per_day BETWEEN 1 AND 24),
+ tefillin_min_spacing_minutes INTEGER NOT NULL DEFAULT 30 CHECK(tefillin_min_spacing_minutes >= 5),
+ quiet_start TEXT DEFAULT NULL, quiet_end TEXT DEFAULT NULL, tefillin_suspended_date TEXT DEFAULT NULL,
+ tefillin_chol_hamoed INTEGER NOT NULL DEFAULT 0 CHECK(tefillin_chol_hamoed IN (0,1)),
+ tefillin_tisha_bav_policy TEXT NOT NULL DEFAULT 'off' CHECK(tefillin_tisha_bav_policy IN ('off','mincha')),
+ tefillin_sunset_safety_minutes INTEGER NOT NULL DEFAULT 10 CHECK(tefillin_sunset_safety_minutes BETWEEN 1 AND 120), tefillin_template TEXT NOT NULL DEFAULT '',
+ omer_enabled INTEGER NOT NULL DEFAULT 0 CHECK(omer_enabled IN (0,1)), omer_time TEXT NOT NULL DEFAULT '21:00',
+ omer_mode TEXT NOT NULL DEFAULT 'fixed' CHECK(omer_mode IN ('fixed','nightfall')),
+ omer_after_nightfall_minutes INTEGER NOT NULL DEFAULT 15 CHECK(omer_after_nightfall_minutes BETWEEN 0 AND 240),
+ omer_nightfall_method TEXT NOT NULL DEFAULT 'degrees' CHECK(omer_nightfall_method IN ('degrees','minutes')), omer_nightfall_value REAL NOT NULL DEFAULT 8.5,
+ omer_nusach TEXT NOT NULL DEFAULT 'ashkenaz', omer_format TEXT NOT NULL DEFAULT 'short' CHECK(omer_format IN ('short','full')),
+ omer_day_completion INTEGER NOT NULL DEFAULT 0 CHECK(omer_day_completion IN (0,1)), omer_early_policy TEXT NOT NULL DEFAULT 'defer' CHECK(omer_early_policy IN ('defer','skip')),
+ omer_after_open_minutes INTEGER NOT NULL DEFAULT 5 CHECK(omer_after_open_minutes BETWEEN 0 AND 180),
+ updated_at INTEGER NOT NULL, updated_by TEXT, imported_at INTEGER
+);
+CREATE TABLE users (user_id TEXT PRIMARY KEY, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL);
+CREATE TABLE user_preferences (
+ user_id TEXT PRIMARY KEY REFERENCES users(user_id), revision INTEGER NOT NULL DEFAULT 1,
+ location_name TEXT NOT NULL DEFAULT 'Jerusalem', country TEXT NOT NULL DEFAULT 'IL', geoname_id INTEGER DEFAULT 281184,
+ latitude REAL NOT NULL DEFAULT 31.76904 CHECK(latitude BETWEEN -90 AND 90), longitude REAL NOT NULL DEFAULT 35.21633 CHECK(longitude BETWEEN -180 AND 180),
+ timezone_id TEXT NOT NULL DEFAULT 'Asia/Jerusalem', location_source TEXT NOT NULL DEFAULT 'default',
+ israel INTEGER NOT NULL DEFAULT 1 CHECK(israel IN (0,1)), language TEXT NOT NULL DEFAULT 'he' CHECK(language IN ('he','en')),
+ candle_minutes INTEGER DEFAULT NULL, havdalah_method TEXT NOT NULL DEFAULT 'degrees', havdalah_value REAL NOT NULL DEFAULT 8.5, updated_at INTEGER NOT NULL, imported_at INTEGER
+);
+CREATE TABLE message_templates (
+ template_id INTEGER PRIMARY KEY, group_id TEXT REFERENCES groups(group_id), user_id TEXT REFERENCES users(user_id),
+ kind TEXT NOT NULL CHECK(kind IN ('open','close','zmanim','daf','tefillin','omer','broadcast')),
+ text TEXT NOT NULL DEFAULT '', entities_json TEXT NOT NULL DEFAULT '[]', media_json TEXT, buttons_json TEXT NOT NULL DEFAULT '[]',
+ revision INTEGER NOT NULL DEFAULT 1, updated_at INTEGER NOT NULL, imported_at INTEGER, CHECK((group_id IS NULL) != (user_id IS NULL))
+);
+CREATE UNIQUE INDEX templates_group_kind ON message_templates(group_id,kind) WHERE group_id IS NOT NULL;
+CREATE UNIQUE INDEX templates_user_kind ON message_templates(user_id,kind) WHERE user_id IS NOT NULL;
+CREATE TABLE notification_rules (
+ rule_id INTEGER PRIMARY KEY, group_id TEXT NOT NULL REFERENCES groups(group_id), module TEXT NOT NULL,
+ enabled INTEGER NOT NULL DEFAULT 0 CHECK(enabled IN (0,1)), kind TEXT NOT NULL CHECK(kind IN ('fixed','interval','relative')),
+ local_time TEXT, interval_minutes INTEGER, reference TEXT, offset_minutes INTEGER, days_json TEXT NOT NULL DEFAULT '[0,1,2,3,4,5]',
+ settings_revision INTEGER NOT NULL, UNIQUE(group_id,module,kind,local_time,reference,offset_minutes)
+);
+CREATE TABLE calendar_cache (
+ cache_key TEXT PRIMARY KEY, provider TEXT NOT NULL, payload_json TEXT NOT NULL, fetched_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, coverage_start TEXT, coverage_end TEXT
+);
+CREATE TABLE scheduled_jobs (
+ job_id INTEGER PRIMARY KEY, group_id TEXT NOT NULL REFERENCES groups(group_id), module TEXT NOT NULL, event_key TEXT NOT NULL,
+ run_at INTEGER NOT NULL, expires_at INTEGER NOT NULL CHECK(expires_at >= run_at), settings_revision INTEGER NOT NULL,
+ payload_json TEXT NOT NULL, payload_prepared_at INTEGER, random_id TEXT NOT NULL, priority INTEGER NOT NULL DEFAULT 100,
+ status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','leased','sent','cancelled','expired','failed')),
+ attempts INTEGER NOT NULL DEFAULT 0, retry_at INTEGER, lease_owner TEXT, lease_until INTEGER, last_error TEXT, created_at INTEGER NOT NULL, completed_at INTEGER,
+ UNIQUE(group_id,module,event_key), UNIQUE(random_id)
+);
+CREATE INDEX jobs_due ON scheduled_jobs(status,priority,retry_at,run_at,lease_until);
+CREATE TABLE group_lock_state (
+ group_id TEXT PRIMARY KEY REFERENCES groups(group_id), event_key TEXT NOT NULL,
+ status TEXT NOT NULL CHECK(status IN ('prepared','locked','restoring','restored','prelocked','recovery_required','conflict','error')),
+ snapshot_json TEXT, applied_json TEXT, snapshot_sha256 TEXT, prepared_at INTEGER NOT NULL, locked_at INTEGER, restored_at INTEGER,
+ last_error TEXT, lease_owner TEXT, lease_until INTEGER, revision INTEGER NOT NULL DEFAULT 1
+);
+CREATE TABLE delivery_log (
+ delivery_id INTEGER PRIMARY KEY, job_id INTEGER NOT NULL REFERENCES scheduled_jobs(job_id), attempt INTEGER NOT NULL, random_id TEXT NOT NULL,
+ started_at INTEGER NOT NULL, finished_at INTEGER, outcome TEXT NOT NULL CHECK(outcome IN ('attempting','sent','retry','failed','uncertain')),
+ response_json TEXT, error TEXT, UNIQUE(job_id,attempt)
+);
+CREATE TABLE conversation_states (
+ token_hash TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(user_id), scope TEXT NOT NULL CHECK(scope IN ('user','group','share')),
+ group_id TEXT REFERENCES groups(group_id), settings_revision INTEGER NOT NULL, action TEXT NOT NULL, payload_json TEXT NOT NULL DEFAULT '{}',
+ created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, consumed_at INTEGER, revoked_at INTEGER,
+ CHECK((scope='user' AND group_id IS NULL) OR (scope IN ('group','share') AND group_id IS NOT NULL))
+);
+CREATE INDEX conversation_expiry ON conversation_states(expires_at,consumed_at);
+CREATE TABLE audit_log (
+ audit_id INTEGER PRIMARY KEY, actor_id TEXT, group_id TEXT REFERENCES groups(group_id), action TEXT NOT NULL, details_json TEXT NOT NULL, created_at INTEGER NOT NULL
+);
+CREATE TABLE legacy_imports (source_path TEXT PRIMARY KEY, sha256 TEXT NOT NULL, imported_at INTEGER NOT NULL, details_json TEXT NOT NULL);
diff --git a/migrations/002_admin_state.sql b/migrations/002_admin_state.sql
new file mode 100644
index 0000000..75767f7
--- /dev/null
+++ b/migrations/002_admin_state.sql
@@ -0,0 +1,20 @@
+-- SPDX-License-Identifier: AGPL-3.0-only
+-- Application-owned administration state. BroadcastManager's independent
+-- delivery backend is not converted by this additive migration.
+CREATE TABLE admin_state (
+ user_id TEXT NOT NULL,
+ state_key TEXT NOT NULL CHECK(state_key IN (
+ 'grs1.txt','messagetodelete.txt','txt.txt','ent.txt','media.txt',
+ 'BUTTONS.txt','broadcastsend.txt','pinmessage.txt',
+ 'startline_cat.txt','endline_cat.txt','page_var.txt','ids.txt','idsnew.txt'
+ )),
+ value_kind TEXT NOT NULL CHECK(value_kind IN ('text','json','media','ids')),
+ value_text TEXT NOT NULL CHECK(length(value_text) <= 1048576),
+ updated_at INTEGER NOT NULL,
+ PRIMARY KEY(user_id,state_key)
+);
+CREATE INDEX admin_state_updated ON admin_state(updated_at);
+CREATE TABLE admin_directories (
+ user_id TEXT PRIMARY KEY,
+ created_at INTEGER NOT NULL
+);
diff --git a/migrations/003_manual_payments.sql b/migrations/003_manual_payments.sql
new file mode 100644
index 0000000..5586a5c
--- /dev/null
+++ b/migrations/003_manual_payments.sql
@@ -0,0 +1,19 @@
+-- SPDX-License-Identifier: AGPL-3.0-only
+ALTER TABLE group_settings ADD COLUMN manual_override_mode TEXT NOT NULL DEFAULT 'none' CHECK(manual_override_mode IN ('none','lock','open'));
+ALTER TABLE group_settings ADD COLUMN manual_override_until INTEGER CHECK(manual_override_until IS NULL OR (typeof(manual_override_until)='integer' AND manual_override_until>=0));
+CREATE TABLE payment_intents (
+ intent_id INTEGER PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(user_id),
+ payload TEXT NOT NULL UNIQUE CHECK(length(payload) BETWEEN 1 AND 128), nonce TEXT NOT NULL UNIQUE,
+ currency TEXT NOT NULL DEFAULT 'XTR' CHECK(currency='XTR'), amount INTEGER NOT NULL CHECK(amount IN (5,25,100,150,250,400)),
+ language TEXT NOT NULL CHECK(language IN ('he','en')),
+ status TEXT NOT NULL DEFAULT 'created' CHECK(status IN ('created','exported','precheckout_approved','paid','expired','failed')),
+ created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL CHECK(expires_at>created_at),
+ invoice_url TEXT, precheckout_query_id TEXT UNIQUE, approved_at INTEGER, paid_at INTEGER, last_error TEXT
+);
+CREATE INDEX payment_intent_expiry ON payment_intents(status,expires_at);
+CREATE TABLE donations (
+ donation_id INTEGER PRIMARY KEY, intent_id INTEGER NOT NULL UNIQUE REFERENCES payment_intents(intent_id),
+ user_id TEXT NOT NULL REFERENCES users(user_id), currency TEXT NOT NULL CHECK(currency='XTR'),
+ amount INTEGER NOT NULL CHECK(amount IN (5,25,100,150,250,400)), charge_uid TEXT NOT NULL UNIQUE,
+ received_at INTEGER NOT NULL, service_message_id INTEGER
+);
diff --git a/migrations/004_expand_languages.sql b/migrations/004_expand_languages.sql
new file mode 100644
index 0000000..aaa663a
--- /dev/null
+++ b/migrations/004_expand_languages.sql
@@ -0,0 +1,77 @@
+-- SPDX-License-Identifier: AGPL-3.0-only
+-- Static six-language migration generated during development from audited schema 001-003.
+-- Copy all typed fields, IDs, payment bindings and manual overrides before replacing tables.
+CREATE TABLE group_settings_v4 (
+ group_id TEXT PRIMARY KEY REFERENCES groups(group_id), revision INTEGER NOT NULL DEFAULT 1 CHECK(revision > 0),
+ location_name TEXT NOT NULL DEFAULT 'Jerusalem', country TEXT NOT NULL DEFAULT 'IL', geoname_id INTEGER DEFAULT 281184,
+ latitude REAL NOT NULL DEFAULT 31.76904 CHECK(latitude BETWEEN -90 AND 90), longitude REAL NOT NULL DEFAULT 35.21633 CHECK(longitude BETWEEN -180 AND 180),
+ timezone_id TEXT NOT NULL DEFAULT 'Asia/Jerusalem', location_source TEXT NOT NULL DEFAULT 'default',
+ israel INTEGER NOT NULL DEFAULT 1 CHECK(israel IN (0,1)), language TEXT NOT NULL DEFAULT 'he' CHECK(language IN ('he','en','ru','fr','es','pt-br')),
+ candle_minutes INTEGER DEFAULT NULL CHECK(candle_minutes BETWEEN 1 AND 120),
+ havdalah_method TEXT NOT NULL DEFAULT 'degrees' CHECK(havdalah_method IN ('degrees','minutes')),
+ havdalah_value REAL NOT NULL DEFAULT 8.5 CHECK(havdalah_value > 0 AND havdalah_value <= 120),
+ close_before_candles_minutes INTEGER NOT NULL DEFAULT 0 CHECK(close_before_candles_minutes BETWEEN 0 AND 180),
+ open_after_havdalah_minutes INTEGER NOT NULL DEFAULT 0 CHECK(open_after_havdalah_minutes BETWEEN 0 AND 180),
+ mode TEXT NOT NULL DEFAULT 'lock' CHECK(mode IN ('off','messages','lock','lock_messages')),
+ shabbat_enabled INTEGER NOT NULL DEFAULT 1 CHECK(shabbat_enabled IN (0,1)), holidays_enabled INTEGER NOT NULL DEFAULT 1 CHECK(holidays_enabled IN (0,1)),
+ times_alert_enabled INTEGER NOT NULL DEFAULT 0 CHECK(times_alert_enabled IN (0,1)), times_alert_time TEXT NOT NULL DEFAULT '13:30',
+ times_alert_mode TEXT NOT NULL DEFAULT 'local_time' CHECK(times_alert_mode IN ('local_time','before_close')),
+ times_alert_before_minutes INTEGER NOT NULL DEFAULT 30 CHECK(times_alert_before_minutes BETWEEN 0 AND 1440),
+ close_message_enabled INTEGER NOT NULL DEFAULT 0 CHECK(close_message_enabled IN (0,1)), open_message_enabled INTEGER NOT NULL DEFAULT 0 CHECK(open_message_enabled IN (0,1)),
+ target_topic_id INTEGER, zmanim_enabled INTEGER NOT NULL DEFAULT 0 CHECK(zmanim_enabled IN (0,1)), zmanim_time TEXT NOT NULL DEFAULT '08:00',
+ zmanim_days TEXT NOT NULL DEFAULT '[0,1,2,3,4,5]', zmanim_format TEXT NOT NULL DEFAULT 'short',
+ daf_enabled INTEGER NOT NULL DEFAULT 0 CHECK(daf_enabled IN (0,1)), daf_time TEXT NOT NULL DEFAULT '08:00', daf_days TEXT NOT NULL DEFAULT '[0,1,2,3,4,5]', daf_template TEXT NOT NULL DEFAULT '',
+ tefillin_enabled INTEGER NOT NULL DEFAULT 0 CHECK(tefillin_enabled IN (0,1)), tefillin_days TEXT NOT NULL DEFAULT '[0,1,2,3,4,5]',
+ tefillin_fixed_times TEXT NOT NULL DEFAULT '["09:00"]', tefillin_interval_minutes INTEGER DEFAULT NULL CHECK(tefillin_interval_minutes >= 5),
+ tefillin_window_start TEXT NOT NULL DEFAULT '08:00', tefillin_window_end TEXT NOT NULL DEFAULT '17:00', tefillin_sunset_offsets TEXT NOT NULL DEFAULT '[60,30,15]',
+ tefillin_max_per_day INTEGER NOT NULL DEFAULT 3 CHECK(tefillin_max_per_day BETWEEN 1 AND 24),
+ tefillin_min_spacing_minutes INTEGER NOT NULL DEFAULT 30 CHECK(tefillin_min_spacing_minutes >= 5),
+ quiet_start TEXT DEFAULT NULL, quiet_end TEXT DEFAULT NULL, tefillin_suspended_date TEXT DEFAULT NULL,
+ tefillin_chol_hamoed INTEGER NOT NULL DEFAULT 0 CHECK(tefillin_chol_hamoed IN (0,1)),
+ tefillin_tisha_bav_policy TEXT NOT NULL DEFAULT 'off' CHECK(tefillin_tisha_bav_policy IN ('off','mincha')),
+ tefillin_sunset_safety_minutes INTEGER NOT NULL DEFAULT 10 CHECK(tefillin_sunset_safety_minutes BETWEEN 1 AND 120), tefillin_template TEXT NOT NULL DEFAULT '',
+ omer_enabled INTEGER NOT NULL DEFAULT 0 CHECK(omer_enabled IN (0,1)), omer_time TEXT NOT NULL DEFAULT '21:00',
+ omer_mode TEXT NOT NULL DEFAULT 'fixed' CHECK(omer_mode IN ('fixed','nightfall')),
+ omer_after_nightfall_minutes INTEGER NOT NULL DEFAULT 15 CHECK(omer_after_nightfall_minutes BETWEEN 0 AND 240),
+ omer_nightfall_method TEXT NOT NULL DEFAULT 'degrees' CHECK(omer_nightfall_method IN ('degrees','minutes')), omer_nightfall_value REAL NOT NULL DEFAULT 8.5,
+ omer_nusach TEXT NOT NULL DEFAULT 'ashkenaz', omer_format TEXT NOT NULL DEFAULT 'short' CHECK(omer_format IN ('short','full')),
+ omer_day_completion INTEGER NOT NULL DEFAULT 0 CHECK(omer_day_completion IN (0,1)), omer_early_policy TEXT NOT NULL DEFAULT 'defer' CHECK(omer_early_policy IN ('defer','skip')),
+ omer_after_open_minutes INTEGER NOT NULL DEFAULT 5 CHECK(omer_after_open_minutes BETWEEN 0 AND 180),
+ updated_at INTEGER NOT NULL, updated_by TEXT, imported_at INTEGER
+, manual_override_mode TEXT NOT NULL DEFAULT 'none' CHECK(manual_override_mode IN ('none','lock','open')), manual_override_until INTEGER CHECK(manual_override_until IS NULL OR (typeof(manual_override_until)='integer' AND manual_override_until>=0)));
+CREATE TABLE user_preferences_v4 (
+ user_id TEXT PRIMARY KEY REFERENCES users(user_id), revision INTEGER NOT NULL DEFAULT 1,
+ location_name TEXT NOT NULL DEFAULT 'Jerusalem', country TEXT NOT NULL DEFAULT 'IL', geoname_id INTEGER DEFAULT 281184,
+ latitude REAL NOT NULL DEFAULT 31.76904 CHECK(latitude BETWEEN -90 AND 90), longitude REAL NOT NULL DEFAULT 35.21633 CHECK(longitude BETWEEN -180 AND 180),
+ timezone_id TEXT NOT NULL DEFAULT 'Asia/Jerusalem', location_source TEXT NOT NULL DEFAULT 'default',
+ israel INTEGER NOT NULL DEFAULT 1 CHECK(israel IN (0,1)), language TEXT NOT NULL DEFAULT 'he' CHECK(language IN ('he','en','ru','fr','es','pt-br')),
+ candle_minutes INTEGER DEFAULT NULL, havdalah_method TEXT NOT NULL DEFAULT 'degrees', havdalah_value REAL NOT NULL DEFAULT 8.5, updated_at INTEGER NOT NULL, imported_at INTEGER
+);
+CREATE TABLE payment_intents_v4 (
+ intent_id INTEGER PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(user_id),
+ payload TEXT NOT NULL UNIQUE CHECK(length(payload) BETWEEN 1 AND 128), nonce TEXT NOT NULL UNIQUE,
+ currency TEXT NOT NULL DEFAULT 'XTR' CHECK(currency='XTR'), amount INTEGER NOT NULL CHECK(amount IN (5,25,100,150,250,400)),
+ language TEXT NOT NULL CHECK(language IN ('he','en','ru','fr','es','pt-br')),
+ status TEXT NOT NULL DEFAULT 'created' CHECK(status IN ('created','exported','precheckout_approved','paid','expired','failed')),
+ created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL CHECK(expires_at>created_at),
+ invoice_url TEXT, precheckout_query_id TEXT UNIQUE, approved_at INTEGER, paid_at INTEGER, last_error TEXT
+);
+CREATE TABLE donations_v4 (
+ donation_id INTEGER PRIMARY KEY, intent_id INTEGER NOT NULL UNIQUE REFERENCES payment_intents_v4(intent_id),
+ user_id TEXT NOT NULL REFERENCES users(user_id), currency TEXT NOT NULL CHECK(currency='XTR'),
+ amount INTEGER NOT NULL CHECK(amount IN (5,25,100,150,250,400)), charge_uid TEXT NOT NULL UNIQUE,
+ received_at INTEGER NOT NULL, service_message_id INTEGER
+);
+INSERT INTO group_settings_v4("group_id","revision","location_name","country","geoname_id","latitude","longitude","timezone_id","location_source","israel","language","candle_minutes","havdalah_method","havdalah_value","close_before_candles_minutes","open_after_havdalah_minutes","mode","shabbat_enabled","holidays_enabled","times_alert_enabled","times_alert_time","times_alert_mode","times_alert_before_minutes","close_message_enabled","open_message_enabled","target_topic_id","zmanim_enabled","zmanim_time","zmanim_days","zmanim_format","daf_enabled","daf_time","daf_days","daf_template","tefillin_enabled","tefillin_days","tefillin_fixed_times","tefillin_interval_minutes","tefillin_window_start","tefillin_window_end","tefillin_sunset_offsets","tefillin_max_per_day","tefillin_min_spacing_minutes","quiet_start","quiet_end","tefillin_suspended_date","tefillin_chol_hamoed","tefillin_tisha_bav_policy","tefillin_sunset_safety_minutes","tefillin_template","omer_enabled","omer_time","omer_mode","omer_after_nightfall_minutes","omer_nightfall_method","omer_nightfall_value","omer_nusach","omer_format","omer_day_completion","omer_early_policy","omer_after_open_minutes","updated_at","updated_by","imported_at","manual_override_mode","manual_override_until") SELECT "group_id","revision","location_name","country","geoname_id","latitude","longitude","timezone_id","location_source","israel","language","candle_minutes","havdalah_method","havdalah_value","close_before_candles_minutes","open_after_havdalah_minutes","mode","shabbat_enabled","holidays_enabled","times_alert_enabled","times_alert_time","times_alert_mode","times_alert_before_minutes","close_message_enabled","open_message_enabled","target_topic_id","zmanim_enabled","zmanim_time","zmanim_days","zmanim_format","daf_enabled","daf_time","daf_days","daf_template","tefillin_enabled","tefillin_days","tefillin_fixed_times","tefillin_interval_minutes","tefillin_window_start","tefillin_window_end","tefillin_sunset_offsets","tefillin_max_per_day","tefillin_min_spacing_minutes","quiet_start","quiet_end","tefillin_suspended_date","tefillin_chol_hamoed","tefillin_tisha_bav_policy","tefillin_sunset_safety_minutes","tefillin_template","omer_enabled","omer_time","omer_mode","omer_after_nightfall_minutes","omer_nightfall_method","omer_nightfall_value","omer_nusach","omer_format","omer_day_completion","omer_early_policy","omer_after_open_minutes","updated_at","updated_by","imported_at","manual_override_mode","manual_override_until" FROM group_settings;
+INSERT INTO user_preferences_v4("user_id","revision","location_name","country","geoname_id","latitude","longitude","timezone_id","location_source","israel","language","candle_minutes","havdalah_method","havdalah_value","updated_at","imported_at") SELECT "user_id","revision","location_name","country","geoname_id","latitude","longitude","timezone_id","location_source","israel","language","candle_minutes","havdalah_method","havdalah_value","updated_at","imported_at" FROM user_preferences;
+INSERT INTO payment_intents_v4("intent_id","user_id","payload","nonce","currency","amount","language","status","created_at","expires_at","invoice_url","precheckout_query_id","approved_at","paid_at","last_error") SELECT "intent_id","user_id","payload","nonce","currency","amount","language","status","created_at","expires_at","invoice_url","precheckout_query_id","approved_at","paid_at","last_error" FROM payment_intents;
+INSERT INTO donations_v4("donation_id","intent_id","user_id","currency","amount","charge_uid","received_at","service_message_id") SELECT "donation_id","intent_id","user_id","currency","amount","charge_uid","received_at","service_message_id" FROM donations;
+DROP TABLE donations;
+DROP TABLE payment_intents;
+DROP TABLE group_settings;
+DROP TABLE user_preferences;
+ALTER TABLE group_settings_v4 RENAME TO group_settings;
+ALTER TABLE user_preferences_v4 RENAME TO user_preferences;
+ALTER TABLE payment_intents_v4 RENAME TO payment_intents;
+ALTER TABLE donations_v4 RENAME TO donations;
+CREATE INDEX payment_intent_expiry ON payment_intents(status,expires_at);
diff --git a/migrations/005_known_broadcast_peers.sql b/migrations/005_known_broadcast_peers.sql
new file mode 100644
index 0000000..a85c5c5
--- /dev/null
+++ b/migrations/005_known_broadcast_peers.sql
@@ -0,0 +1,9 @@
+-- SPDX-License-Identifier: AGPL-3.0-only
+-- Known delivery audiences are separate from opt-in Shabbat group registration.
+CREATE TABLE known_broadcast_peers (
+ peer_id TEXT PRIMARY KEY CHECK(length(peer_id) BETWEEN 1 AND 24),
+ peer_type TEXT NOT NULL DEFAULT 'unknown' CHECK(peer_type IN ('unknown','user','bot','chat','supergroup','channel')),
+ title TEXT NOT NULL DEFAULT '',
+ created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, imported_at INTEGER
+);
+CREATE INDEX known_broadcast_peer_type ON known_broadcast_peers(peer_type,peer_id);
diff --git a/migrations/006_permission_retry.sql b/migrations/006_permission_retry.sql
new file mode 100644
index 0000000..d30380b
--- /dev/null
+++ b/migrations/006_permission_retry.sql
@@ -0,0 +1,3 @@
+-- SPDX-License-Identifier: AGPL-3.0-only
+-- Permission RPC quota deadlines survive restart without discarding recovery snapshots.
+ALTER TABLE group_lock_state ADD COLUMN retry_at INTEGER CHECK(retry_at IS NULL OR (typeof(retry_at)='integer' AND retry_at>=0));
diff --git a/migrations/007_delivery_deferrals.sql b/migrations/007_delivery_deferrals.sql
new file mode 100644
index 0000000..18796c0
--- /dev/null
+++ b/migrations/007_delivery_deferrals.sql
@@ -0,0 +1,3 @@
+-- SPDX-License-Identifier: AGPL-3.0-only
+-- Eligibility deferrals do not consume the transport retry budget.
+ALTER TABLE scheduled_jobs ADD COLUMN deferrals INTEGER NOT NULL DEFAULT 0 CHECK(deferrals >= 0);
diff --git a/migrations/008_group_moderation.sql b/migrations/008_group_moderation.sql
new file mode 100644
index 0000000..ed667f0
--- /dev/null
+++ b/migrations/008_group_moderation.sql
@@ -0,0 +1,53 @@
+-- SPDX-License-Identifier: AGPL-3.0-only
+-- Group-only controls; original lock snapshots and all earlier rows remain intact.
+ALTER TABLE group_settings ADD COLUMN lock_block_flags TEXT NOT NULL DEFAULT '["send_messages","send_media","send_stickers","send_gifs","send_games","send_inline","embed_links","send_polls","change_info","invite_users","pin_messages","manage_topics","send_photos","send_videos","send_roundvideos","send_audios","send_voices","send_docs","send_plain","edit_rank","send_reactions"]' CHECK(json_valid(lock_block_flags) AND json_type(lock_block_flags)='array' AND json_array_length(lock_block_flags)<=22);
+ALTER TABLE group_settings ADD COLUMN delete_holy_user_messages INTEGER NOT NULL DEFAULT 0 CHECK(delete_holy_user_messages IN (0,1));
+ALTER TABLE group_settings ADD COLUMN delete_holy_bot_messages INTEGER NOT NULL DEFAULT 0 CHECK(delete_holy_bot_messages IN (0,1));
+ALTER TABLE group_settings ADD COLUMN tefillin_delete_previous INTEGER NOT NULL DEFAULT 0 CHECK(tefillin_delete_previous IN (0,1));
+-- While a changed mask is in flight, either known bot-owned mask can require restoration.
+ALTER TABLE group_lock_state ADD COLUMN previous_applied_json TEXT;
+-- A confirmed send is never resent because subsequent cleanup bookkeeping failed.
+ALTER TABLE scheduled_jobs ADD COLUMN module_delivery_processed_at INTEGER;
+CREATE TABLE message_deletions (
+ deletion_id INTEGER PRIMARY KEY,
+ group_id TEXT NOT NULL REFERENCES groups(group_id),
+ message_id INTEGER NOT NULL CHECK(message_id BETWEEN 1 AND 2147483647),
+ purpose TEXT NOT NULL CHECK(purpose IN ('holy_message','tefillin_previous')),
+ original_timestamp INTEGER NOT NULL CHECK(original_timestamp >= 0),
+ sender_id TEXT, sender_is_bot INTEGER NOT NULL CHECK(sender_is_bot IN (0,1)),
+ sender_is_self INTEGER NOT NULL DEFAULT 0 CHECK(sender_is_self IN (0,1) AND (sender_is_self=0 OR sender_is_bot=1)),
+ settings_revision INTEGER CHECK(settings_revision IS NULL OR settings_revision>0),
+ origin_delivery_key TEXT,
+ due_at INTEGER NOT NULL CHECK(due_at>=0), expires_at INTEGER CHECK(expires_at IS NULL OR expires_at>=due_at),
+ status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','leased','deleted','cancelled','failed','expired')),
+ attempts INTEGER NOT NULL DEFAULT 0 CHECK(attempts>=0), retry_at INTEGER,
+ lease_owner TEXT, lease_until INTEGER, last_error TEXT,
+ created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, completed_at INTEGER,
+ UNIQUE(group_id,message_id,purpose),
+ CHECK(purpose<>'holy_message' OR expires_at IS NOT NULL),
+ CHECK(purpose<>'tefillin_previous' OR (sender_is_bot=1 AND sender_is_self=1 AND origin_delivery_key IS NOT NULL))
+);
+CREATE INDEX deletions_due ON message_deletions(status,retry_at,due_at,lease_until);
+CREATE TABLE module_last_messages (
+ group_id TEXT NOT NULL REFERENCES groups(group_id), module TEXT NOT NULL CHECK(length(module) BETWEEN 1 AND 32),
+ message_ids_json TEXT NOT NULL CHECK(json_valid(message_ids_json) AND json_type(message_ids_json)='array' AND json_array_length(message_ids_json) BETWEEN 1 AND 100),
+ max_message_id INTEGER NOT NULL CHECK(max_message_id BETWEEN 1 AND 2147483647),
+ delivery_key TEXT NOT NULL CHECK(length(delivery_key) BETWEEN 1 AND 128),
+ original_timestamp INTEGER NOT NULL CHECK(original_timestamp>=0), delivery_completed_at INTEGER NOT NULL CHECK(delivery_completed_at>=0), source_job_id INTEGER REFERENCES scheduled_jobs(job_id), updated_at INTEGER NOT NULL,
+ PRIMARY KEY(group_id,module)
+);
+-- Separate rich announcement template while retaining existing template identifiers and metadata.
+CREATE TABLE message_templates_moderation (
+ template_id INTEGER PRIMARY KEY, group_id TEXT REFERENCES groups(group_id), user_id TEXT REFERENCES users(user_id),
+ kind TEXT NOT NULL CHECK(kind IN ('open','close','times_alert','zmanim','daf','tefillin','omer','broadcast')),
+ text TEXT NOT NULL DEFAULT '', entities_json TEXT NOT NULL DEFAULT '[]', media_json TEXT, buttons_json TEXT NOT NULL DEFAULT '[]',
+ revision INTEGER NOT NULL DEFAULT 1, updated_at INTEGER NOT NULL, imported_at INTEGER, CHECK((group_id IS NULL) != (user_id IS NULL))
+);
+INSERT INTO message_templates_moderation SELECT * FROM message_templates;
+-- The previous upgrade shared zmanim with announcements: preserve that announcement content once.
+INSERT INTO message_templates_moderation(group_id,user_id,kind,text,entities_json,media_json,buttons_json,revision,updated_at,imported_at)
+SELECT group_id,NULL,'times_alert',text,entities_json,media_json,buttons_json,revision,updated_at,imported_at FROM message_templates WHERE kind='zmanim' AND group_id IS NOT NULL;
+DROP TABLE message_templates;
+ALTER TABLE message_templates_moderation RENAME TO message_templates;
+CREATE UNIQUE INDEX templates_group_kind ON message_templates(group_id,kind) WHERE group_id IS NOT NULL;
+CREATE UNIQUE INDEX templates_user_kind ON message_templates(user_id,kind) WHERE user_id IS NOT NULL;
diff --git a/migrations/009_admin_roles_links.sql b/migrations/009_admin_roles_links.sql
new file mode 100644
index 0000000..af242a5
--- /dev/null
+++ b/migrations/009_admin_roles_links.sql
@@ -0,0 +1,28 @@
+-- SPDX-License-Identifier: AGPL-3.0-only
+CREATE TABLE bot_admins (
+ user_id TEXT PRIMARY KEY, role TEXT NOT NULL CHECK(role IN ('full','limited')),
+ view_only INTEGER NOT NULL CHECK(view_only IN (0,1)), permissions_json TEXT NOT NULL,
+ revision INTEGER NOT NULL DEFAULT 1, created_at INTEGER NOT NULL, created_by TEXT NOT NULL,
+ updated_at INTEGER NOT NULL, updated_by TEXT NOT NULL
+);
+CREATE TABLE admin_panel_flows (
+ actor_id TEXT PRIMARY KEY, nonce TEXT NOT NULL, kind TEXT NOT NULL,
+ payload_json TEXT NOT NULL, expires_at INTEGER NOT NULL
+);
+CREATE TABLE admin_meta (meta_key TEXT PRIMARY KEY, meta_value TEXT NOT NULL);
+CREATE TABLE tracking_links (
+ id INTEGER PRIMARY KEY AUTOINCREMENT, param TEXT NOT NULL COLLATE NOCASE UNIQUE,
+ label TEXT NOT NULL, destination TEXT NOT NULL, created_by TEXT NOT NULL, created_at INTEGER NOT NULL,
+ revision INTEGER NOT NULL DEFAULT 1, total_clicks INTEGER NOT NULL DEFAULT 0,
+ unique_users INTEGER NOT NULL DEFAULT 0, first_click_at INTEGER, last_click_at INTEGER
+);
+CREATE TABLE tracking_link_users (
+ tracker_id INTEGER NOT NULL REFERENCES tracking_links(id) ON DELETE CASCADE,
+ user_hash TEXT NOT NULL, last_message_id INTEGER NOT NULL,
+ PRIMARY KEY(tracker_id,user_hash)
+) WITHOUT ROWID;
+CREATE TABLE tracking_link_days (
+ tracker_id INTEGER NOT NULL REFERENCES tracking_links(id) ON DELETE CASCADE,
+ day TEXT NOT NULL, clicks INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(tracker_id,day)
+) WITHOUT ROWID;
+CREATE INDEX admin_action_audit ON audit_log(action,audit_id);
diff --git a/migrations/010_subscriber_directory.sql b/migrations/010_subscriber_directory.sql
new file mode 100644
index 0000000..62333e0
--- /dev/null
+++ b/migrations/010_subscriber_directory.sql
@@ -0,0 +1,38 @@
+-- SPDX-License-Identifier: AGPL-3.0-only
+-- Additive directory observations: Telegram metadata is optional, never invented.
+CREATE TABLE subscriber_profiles (
+ peer_id TEXT PRIMARY KEY REFERENCES known_broadcast_peers(peer_id),
+ username TEXT, first_name TEXT, last_name TEXT, language_code TEXT,
+ private_chat_access INTEGER CHECK(private_chat_access IN (0,1)),
+ is_bot INTEGER CHECK(is_bot IN (0,1)), is_deleted INTEGER CHECK(is_deleted IN (0,1)),
+ is_premium INTEGER CHECK(is_premium IN (0,1)), is_contact INTEGER CHECK(is_contact IN (0,1)),
+ first_activity_at INTEGER, last_activity_at INTEGER, activity_count INTEGER NOT NULL DEFAULT 0,
+ start_count INTEGER NOT NULL DEFAULT 0, last_start_at INTEGER,
+ broadcast_authorized INTEGER NOT NULL DEFAULT 0 CHECK(broadcast_authorized IN (0,1)),
+ metadata_at INTEGER NOT NULL
+);
+CREATE INDEX subscriber_recent_activity ON subscriber_profiles(last_activity_at DESC,peer_id);
+CREATE INDEX subscriber_username ON subscriber_profiles(username COLLATE NOCASE);
+CREATE TABLE subscriber_group_admins (
+ user_id TEXT NOT NULL, group_id TEXT NOT NULL REFERENCES groups(group_id),
+ is_admin INTEGER NOT NULL CHECK(is_admin IN (0,1)), role TEXT NOT NULL,
+ checked_at INTEGER NOT NULL, PRIMARY KEY(user_id,group_id)
+);
+CREATE TABLE subscriber_directory_views (
+ viewer_id TEXT PRIMARY KEY, search TEXT NOT NULL DEFAULT '', sort TEXT NOT NULL DEFAULT 'activity',
+ filter TEXT NOT NULL DEFAULT 'all', page INTEGER NOT NULL DEFAULT 0, prompt TEXT,
+ updated_at INTEGER NOT NULL
+);
+CREATE TABLE operator_broadcast_options (
+ user_id TEXT NOT NULL REFERENCES users(user_id),
+ state_key TEXT NOT NULL CHECK(state_key IN ('broadcast_operation.txt','selfdestructhours.txt','scheduledat.txt')),
+ value_text TEXT NOT NULL, updated_at INTEGER NOT NULL,
+ PRIMARY KEY(user_id,state_key)
+);
+
+-- Only the pre-upgrade private registry is treated as a legacy private audience.
+INSERT OR IGNORE INTO known_broadcast_peers(peer_id,peer_type,title,created_at,updated_at)
+ SELECT user_id,'user','',created_at,updated_at FROM users;
+INSERT INTO subscriber_profiles(peer_id,private_chat_access,metadata_at)
+ SELECT peer_id,1,updated_at FROM known_broadcast_peers WHERE peer_type IN ('user','bot') OR peer_id IN (SELECT user_id FROM users)
+ ON CONFLICT(peer_id) DO UPDATE SET private_chat_access=1;
diff --git a/migrations/011_custom_star_amounts.sql b/migrations/011_custom_star_amounts.sql
new file mode 100644
index 0000000..5566cb9
--- /dev/null
+++ b/migrations/011_custom_star_amounts.sql
@@ -0,0 +1,28 @@
+-- SPDX-License-Identifier: AGPL-3.0-only
+-- Run under Database::migrate's transaction with foreign_keys enabled.
+-- Copy both sides before dropping the old FK child, then its parent.
+CREATE TABLE payment_intents_v11 (
+ intent_id INTEGER PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(user_id),
+ payload TEXT NOT NULL UNIQUE CHECK(length(payload) BETWEEN 1 AND 128), nonce TEXT NOT NULL UNIQUE,
+ currency TEXT NOT NULL DEFAULT 'XTR' CHECK(currency='XTR'),
+ amount INTEGER NOT NULL CHECK(typeof(amount)='integer' AND amount BETWEEN 1 AND 10000),
+ language TEXT NOT NULL CHECK(language IN ('he','en','ru','fr','es','pt-br')),
+ status TEXT NOT NULL DEFAULT 'created' CHECK(status IN ('created','exported','precheckout_approved','paid','expired','failed')),
+ created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL CHECK(expires_at>created_at),
+ invoice_url TEXT, precheckout_query_id TEXT UNIQUE, approved_at INTEGER, paid_at INTEGER, last_error TEXT
+);
+CREATE TABLE donations_v11 (
+ donation_id INTEGER PRIMARY KEY, intent_id INTEGER NOT NULL UNIQUE REFERENCES payment_intents_v11(intent_id),
+ user_id TEXT NOT NULL REFERENCES users(user_id), currency TEXT NOT NULL CHECK(currency='XTR'),
+ amount INTEGER NOT NULL CHECK(typeof(amount)='integer' AND amount BETWEEN 1 AND 10000),
+ charge_uid TEXT NOT NULL UNIQUE, received_at INTEGER NOT NULL, service_message_id INTEGER
+);
+INSERT INTO payment_intents_v11(intent_id,user_id,payload,nonce,currency,amount,language,status,created_at,expires_at,invoice_url,precheckout_query_id,approved_at,paid_at,last_error)
+ SELECT intent_id,user_id,payload,nonce,currency,amount,language,status,created_at,expires_at,invoice_url,precheckout_query_id,approved_at,paid_at,last_error FROM payment_intents;
+INSERT INTO donations_v11(donation_id,intent_id,user_id,currency,amount,charge_uid,received_at,service_message_id)
+ SELECT donation_id,intent_id,user_id,currency,amount,charge_uid,received_at,service_message_id FROM donations;
+DROP TABLE donations;
+DROP TABLE payment_intents;
+ALTER TABLE payment_intents_v11 RENAME TO payment_intents;
+ALTER TABLE donations_v11 RENAME TO donations;
+CREATE INDEX payment_intent_expiry ON payment_intents(status,expires_at);
diff --git a/migrations/012_tefillin_acknowledgements.sql b/migrations/012_tefillin_acknowledgements.sql
new file mode 100644
index 0000000..2d1e052
--- /dev/null
+++ b/migrations/012_tefillin_acknowledgements.sql
@@ -0,0 +1,29 @@
+-- SPDX-License-Identifier: AGPL-3.0-only
+ALTER TABLE group_settings ADD COLUMN tefillin_ack_enabled INTEGER NOT NULL DEFAULT 1 CHECK(tefillin_ack_enabled IN (0,1));
+ALTER TABLE group_settings ADD COLUMN tefillin_ack_label TEXT DEFAULT NULL;
+ALTER TABLE user_preferences ADD COLUMN tefillin_ack_enabled INTEGER NOT NULL DEFAULT 1 CHECK(tefillin_ack_enabled IN (0,1));
+ALTER TABLE user_preferences ADD COLUMN tefillin_ack_label TEXT DEFAULT NULL;
+ALTER TABLE message_templates ADD COLUMN buttons_mode TEXT NOT NULL DEFAULT 'inherit' CHECK(buttons_mode IN ('inherit','custom','none'));
+UPDATE message_templates SET buttons_mode=CASE WHEN EXISTS(SELECT 1 FROM json_each(message_templates.buttons_json)) THEN 'custom' ELSE 'inherit' END;
+CREATE TABLE tefillin_ack_rounds (
+ round_token TEXT PRIMARY KEY, job_id INTEGER NOT NULL UNIQUE REFERENCES scheduled_jobs(job_id) ON DELETE CASCADE,
+ group_id TEXT NOT NULL REFERENCES groups(group_id), label TEXT NOT NULL, language TEXT NOT NULL,
+ settings_revision INTEGER NOT NULL, markup_json TEXT NOT NULL, created_at INTEGER NOT NULL
+);
+CREATE TABLE tefillin_ack_messages (
+ group_id TEXT NOT NULL REFERENCES groups(group_id), message_id INTEGER NOT NULL CHECK(message_id BETWEEN 1 AND 2147483647),
+ round_token TEXT NOT NULL REFERENCES tefillin_ack_rounds(round_token) ON DELETE CASCADE,
+ revision INTEGER NOT NULL DEFAULT 0, rendered_revision INTEGER NOT NULL DEFAULT 0, retry_at INTEGER, edit_attempts INTEGER NOT NULL DEFAULT 0, last_error_code TEXT,
+ updated_at INTEGER NOT NULL, PRIMARY KEY(group_id,message_id), UNIQUE(round_token,message_id)
+);
+CREATE TABLE tefillin_ack_votes (
+ round_token TEXT NOT NULL, message_id INTEGER NOT NULL, user_id TEXT NOT NULL, created_at INTEGER NOT NULL,
+ PRIMARY KEY(round_token,message_id,user_id),
+ FOREIGN KEY(round_token,message_id) REFERENCES tefillin_ack_messages(round_token,message_id) ON DELETE CASCADE
+) WITHOUT ROWID;
+CREATE TABLE tefillin_ack_callbacks (
+ callback_hash TEXT PRIMARY KEY, round_token TEXT NOT NULL, message_id INTEGER NOT NULL,
+ user_id TEXT NOT NULL, created_at INTEGER NOT NULL,
+ FOREIGN KEY(round_token,message_id) REFERENCES tefillin_ack_messages(round_token,message_id) ON DELETE CASCADE
+) WITHOUT ROWID;
+CREATE INDEX tefillin_ack_pending ON tefillin_ack_messages(rendered_revision,revision,updated_at);
diff --git a/migrations/013_permission_recovery.sql b/migrations/013_permission_recovery.sql
new file mode 100644
index 0000000..0d172d8
--- /dev/null
+++ b/migrations/013_permission_recovery.sql
@@ -0,0 +1,17 @@
+-- SPDX-License-Identifier: AGPL-3.0-only
+-- Original snapshots remain immutable until the next successfully completed cycle.
+ALTER TABLE group_lock_state ADD COLUMN recovery_baseline_json TEXT;
+ALTER TABLE group_lock_state ADD COLUMN recovery_baseline_sha256 TEXT;
+CREATE TABLE permission_operations (
+ operation_id INTEGER PRIMARY KEY, group_id TEXT NOT NULL REFERENCES groups(group_id),
+ action TEXT NOT NULL, stage TEXT NOT NULL, outcome TEXT NOT NULL,
+ before_json TEXT, requested_json TEXT, observed_json TEXT,
+ original_json TEXT, original_sha256 TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
+);
+CREATE INDEX permission_operations_group ON permission_operations(group_id,operation_id);
+CREATE TABLE permission_recovery_confirmations (
+ token_hash TEXT PRIMARY KEY, group_id TEXT NOT NULL REFERENCES groups(group_id), actor_id TEXT NOT NULL,
+ action TEXT NOT NULL CHECK(action IN ('adopt','restore')), binding_json TEXT NOT NULL,
+ created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, consumed_at INTEGER
+);
+CREATE INDEX permission_recovery_expiry ON permission_recovery_confirmations(expires_at);
diff --git a/migrations/014_diagnostic_panels.sql b/migrations/014_diagnostic_panels.sql
new file mode 100644
index 0000000..982560e
--- /dev/null
+++ b/migrations/014_diagnostic_panels.sql
@@ -0,0 +1,19 @@
+-- SPDX-License-Identifier: AGPL-3.0-only
+CREATE TABLE diagnostic_panels (
+ viewer_id TEXT PRIMARY KEY REFERENCES users(user_id),
+ token TEXT NOT NULL UNIQUE CHECK(length(token)=24),
+ message_id INTEGER,
+ group_id TEXT REFERENCES groups(group_id),
+ report_json TEXT NOT NULL DEFAULT '{}',
+ page INTEGER NOT NULL DEFAULT 0,
+ category TEXT NOT NULL DEFAULT 'all',
+ search TEXT NOT NULL DEFAULT '',
+ prompt TEXT,
+ created_at INTEGER NOT NULL,
+ expires_at INTEGER NOT NULL,
+ busy_until INTEGER NOT NULL DEFAULT 0,
+ busy_owner TEXT,
+ export_queries_json TEXT NOT NULL DEFAULT '[]',
+ export_at INTEGER NOT NULL DEFAULT 0
+);
+CREATE INDEX diagnostic_panels_expiry ON diagnostic_panels(expires_at);
diff --git a/migrations/015_permission_snapshot_provenance.sql b/migrations/015_permission_snapshot_provenance.sql
new file mode 100644
index 0000000..dae9729
--- /dev/null
+++ b/migrations/015_permission_snapshot_provenance.sql
@@ -0,0 +1,4 @@
+-- SPDX-License-Identifier: AGPL-3.0-only
+-- Older snapshots retain their bytes but must not be treated as verified read provenance.
+ALTER TABLE group_lock_state ADD COLUMN snapshot_provenance TEXT NOT NULL DEFAULT 'legacy_unverified' CHECK(snapshot_provenance IN ('legacy_unverified','verified_native_v1'));
+ALTER TABLE group_lock_state ADD COLUMN snapshot_evidence_json TEXT;
diff --git a/migrations/016_delivery_safety.sql b/migrations/016_delivery_safety.sql
new file mode 100644
index 0000000..c6b07b1
--- /dev/null
+++ b/migrations/016_delivery_safety.sql
@@ -0,0 +1,17 @@
+-- SPDX-License-Identifier: AGPL-3.0-only
+-- A durable reservation records uncertainty before each network request. Never rewrite an attempted identity.
+ALTER TABLE scheduled_jobs ADD COLUMN dispatch_started_at INTEGER;
+ALTER TABLE scheduled_jobs ADD COLUMN last_dispatch_at INTEGER;
+-- Earlier releases did not mark the request boundary. Conservatively retain every possibly attempted old delivery.
+UPDATE scheduled_jobs SET dispatch_started_at=COALESCE((SELECT MIN(started_at) FROM delivery_log d WHERE d.job_id=scheduled_jobs.job_id),completed_at,created_at),last_dispatch_at=COALESCE((SELECT MAX(started_at) FROM delivery_log d WHERE d.job_id=scheduled_jobs.job_id),completed_at,created_at)
+WHERE status='sent' OR attempts>deferrals OR EXISTS(SELECT 1 FROM delivery_log d WHERE d.job_id=scheduled_jobs.job_id AND d.outcome IN ('attempting','uncertain','retry','sent'));
+CREATE INDEX jobs_delivery_group ON scheduled_jobs(group_id,module,status,completed_at,last_dispatch_at);
+CREATE INDEX jobs_tefillin_coalesce ON scheduled_jobs(module,status,group_id,run_at,expires_at,dispatch_started_at);
+CREATE INDEX jobs_retention ON scheduled_jobs(status,expires_at,completed_at);
+CREATE INDEX delivery_retention ON delivery_log(job_id,outcome);
+CREATE INDEX cache_retention ON calendar_cache(expires_at);
+CREATE INDEX audit_retention ON audit_log(created_at,audit_id);
+CREATE INDEX jobs_active_window ON scheduled_jobs(run_at,expires_at) WHERE status IN ('pending','leased');
+CREATE INDEX jobs_uncertain_evidence ON scheduled_jobs(last_dispatch_at) WHERE status<>'sent' AND dispatch_started_at IS NOT NULL;
+CREATE INDEX jobs_sent_recent ON scheduled_jobs(completed_at,run_at) WHERE status='sent';
+CREATE INDEX jobs_expired_recent ON scheduled_jobs(expires_at) WHERE status='expired';
diff --git a/patches/broadcastmanager/README.md b/patches/broadcastmanager/README.md
new file mode 100644
index 0000000..8c62dab
--- /dev/null
+++ b/patches/broadcastmanager/README.md
@@ -0,0 +1,56 @@
+# BroadcastManager presentation, message transport and lifecycle hooks
+
+This is a new local compatibility patch authored for the Shabbat upgrade. It is independent of the historical MadelineProto cancellation patch. It adds optional per-instance presentation callbacks to `wizardloop/broadcastmanager` 3.2.2 at commit `f2792a383416b66bb68e55f09414563e540538d6`.
+
+The exact pinned [upstream source](https://github.com/WizardLoop/BroadcastManager/blob/f2792a383416b66bb68e55f09414563e540538d6/src/BroadcastManager.php) was downloaded and byte-matched against the Composer-installed source on 2026-10-13. `baseline/BroadcastManager.php` retains that source and its WizardLoop copyright/AGPL notices. `reviewed/BroadcastManager.php` contains the reviewed resulting source; the unified diff and manifest fix the exact version, commit and source/output/artifact SHA-256 hashes. Both sources are part of the corresponding-source offer. The library's license remains its upstream AGPL license.
+
+`setStatusFormatter(?callable)` receives `(string event, array context): string`. Events are `starting`, `progress`, `pause`, `resume`, `cancel`, and `last_data`. The context includes the default upstream caption. Progress additionally supplies operation type/ID, counters, pending/success totals, rate, progress bar, pause/cancel/done flags and final status; queue contents, recipient IDs and in-flight peer details are excluded. Returned text must be nonempty valid UTF-8 within Telegram's UTF-16 text limit. Set the callback to null to restore exact upstream output.
+
+`setStatusControlsFormatter(?callable)` receives the translated default controls and the bound operation ID/type/pause flag. The application adapter returns native colored MTProto buttons while preserving each original `bm:pause/resume/cancel:` callback. Callback authorization and operator role verification remain the application handler's responsibility. The hook itself performs no RPC.
+
+The application `BroadcastStatusFormatter` uses the same six language catalogs as the private UI for initial captions, complete progress counters, status and controls. Each manager gets its operator's language instance. A current structured `last_data` state can be rendered in the selected locale. A legacy text-only summary cannot reliably be retranslated or reconstructed; it receives an explicit localized recovery notice, and the original dependency file remains available for operator review. The application's structured operator progress/audit view remains available separately.
+
+With all hooks unset, upstream status text, controls and message dispatch remain unchanged. All launched queue and progress futures are now retained and joined before their owner returns. The optional runtime hook enables cancellation-aware sleeps, authorization checks and zero SDK flood-wait limits; the dependency retains its own bounded attempt counter and per-peer retry deadlines. BroadcastManager internal files remain separate third-party state, documented in `docs/BACKUP_RESTORE.md`.
+
+Run `php scripts/apply-patches.php` followed by `php scripts/verify-patches.php` before startup. Composer install/update scripts and the Docker build use these same strict checks. For `composer install --no-scripts`, run both commands explicitly before launching the bot. Never patch an active bot installation. A shared active writer lock prevents modification. Builds read the bundled immutable diff and baseline; they do not download a patch or regenerate one.
+
+The generic `PatchManager` accepts only approved package version/commit and exact source hashes. A pristine approved file is patched atomically and verified; an approved already-patched file succeeds unchanged. Missing patch, wrong version, unknown/partial source, missing/modified baseline, artifact hash mismatch or active writer stops the build. No broad regex/sed replacement or silent fallback is used during installation.
+
+`tests/BroadcastPatchTest.php` exercises clean/repeated installation and verification, wrong version/commit, unknown/partial source and an active writer. It invokes the actual installed patched class's private formatters through reflection without constructing a Telegram API, validates default compatibility, all six localized counters/status/control IDs/styles, per-instance isolation, invalid formatter output, and last-summary handling including a numeric-only operation ID. No broadcast, remote peer mutation or real Telegram status update was used. Native colors/live editing still require a staging Telegram check.
+
+`generate-artifacts.php` is a development-only artifact generator with fixed baseline and previous-source hashes. Maintainers first edit and review `reviewed/BroadcastManager.php`; the generator parses that PHP source and generates exact unified diffs and manifest hashes without changing it. Updating the reviewed patch requires regenerating the unified diff, reviewing it and recording new output/diff hashes in the manifest; builds never execute this generator. Future library versions need a new source/behavior review before being approved.
+
+The unified diff represents empty context lines as an empty removed/added pair. This is standard unified-diff syntax with unchanged old/new hunk counts and identical resulting source; it prevents patch-file context markers from becoming trailing-whitespace violations when the artifact is added to Git. Installer tests verify the exact approved output SHA-256 after applying this representation.
+
+
+## Message transport hook and dev.3 transition (2026-10-15)
+
+The combined reviewed patch additionally exposes `setMessageTransport(?callable)`. Its per-instance callable receives `(string method, array parameters): mixed` for `sendMessage`, `sendMedia`, `sendMultiMedia` and `editMessage`. All six original message dispatch sites, including albums, ordinary text/media, editing and progress/status messages, use this boundary. Pin, unpin and deletion RPCs remain native. No recipient, random ID, retry limit, media, entity, button or album parameter is dropped by the dependency hook. The application uses its Madeline gateway to decorate these messages and enforce the same native entity/button compatibility behavior as the rest of the bot.
+
+A null hook calls the same native namespace method with the same single parameter array as upstream. Resetting it to null restores that dispatch. The hook does not catch exceptions, convert cancellation into success, add retries or share callable state between instances. The existing upstream callers retain their own retry/status-error policies.
+
+The exact dev.3 patched output is preserved under `previous/dev3/BroadcastManager.php` with SHA-256 `3d9425f7911b12c69faae5e87954db134dc3fb7c2f7d654a72f972ebeb84e424`. The manifest's approved intermediate entry pins this source, its bundled `dev3-to-transport-hooks.patch`, and both hashes. Clean upstream source and that exact previous output can be upgraded offline to the same combined reviewed output. Verification alone rejects the previous output until an explicit installation step runs. Unknown, partially changed or modified previous output fails closed.
+
+All approved intermediate source/diff hashes and resulting output are validated even when the installed file is already current. Transition application uses the same shared application writer lock, second source check, complete PHP parse, output hash and atomic file replacement as clean installation. It never changes a running installation. Composer, Docker and the documented `--no-scripts` procedure keep using `apply-patches.php` and `verify-patches.php`; no network patch download occurs.
+
+The expanded tests cover clean and previous-patched installations, repeat application, verify-only rejection of the old output, active writer refusal, modified transition artifacts and per-instance hook behavior for all four native message methods. An isolated child process loads the actual reviewed library with a small local API namespace shim to prove null-hook dispatch and resetting the hook without constructing MadelineProto or connecting to Telegram. Clone-byte tests include the previous source and transition. Real Telegram delivery and visual rendering remain staging checks.
+
+`generate-artifacts.php` regenerates all three unified diffs and their manifest hashes from the reviewed PHP source and immutable baseline/dev.3/dev.8 inputs. This remains a development-only tool; installation never regenerates patches.
+
+## Runtime lifecycle and dev.8 transition (2026-10-16)
+
+`setRuntimeGuard(?callable, ?Amp\Cancellation)` is an optional per-manager hook. The application's guard is local and synchronous: shutdown, current operator capability, and the current creators of scheduled jobs are checked without calling Telegram or a calendar provider. Its cancellation token is shared by the application and cancelled on shutdown.
+
+When enabled, every worker iteration and every message/pin/unpin/delete RPC boundary checks the guard. Native RPCs receive that cancellation token and `floodWaitLimit=0`; message adapter calls retain the token and the application's gateway also forces zero while preserving native RPC errors for the manager's per-peer retries. Sleeps become cancellation-aware. A false authorization guard cancels the remaining queue and records a cancelled operation. An external `CancelledException`, including a timeout or intentional cancellation, retains its identity and propagates after children drain; it is not retried as an ordinary failed recipient.
+
+The manager retains every queue and progress future. Completion and shutdown wake internal sleeping children, join all of them and release the internal references. The local wakeup exception is recognized by object identity only; it cannot swallow arbitrary cancellation. In-flight accepted Telegram requests remain subject to Telegram's outcome, and cancellation cannot recall a message already accepted. Scheduled cancellation is recorded as cancelled with a partial-outcome notice, never replayed automatically as a fresh broadcast.
+
+The application invokes permissions, notifications and scheduled broadcast work in independent native cron loops. Scheduled broadcasts have a non-overlap guard. An empty/future native queue does not cause a permission scan; a due batch checks permission priority before starting and current creator authority again after that potentially asynchronous check. The permission scheduler remains able to run while a native broadcast waits for a reply or a per-peer retry deadline.
+
+The exact dev.8 reviewed source is preserved as `previous/dev8/BroadcastManager.php` (`08db35c271f671041257ebe79d1010cdd7c33c4d5868e4553c78612f9e8bf0f3`). The manifest adds its immutable `dev8-to-runtime-lifecycle.patch` transition. The original 3.2.2 baseline, commit and copyright/license notices are unchanged; the reviewed source, all previous sources and all three diffs are included in the corresponding-source archive.
+
+Offline tests run the actual installed worker and progress methods with controlled futures and without a Telegram API: suspended broadcast plus independent close, 1,000 queued targets cancelled on shutdown, deliberate cancellation identity, role revocation, progress wakeup/join, 1,000 empty/future scheduler cycles with no permission scans, reentrancy, creator attribution and clean/dev.3/dev.8/repeated/partial/unknown patch installation. Live Telegram delivery, real flood quotas and process shutdown during an already accepted request remain staging checks.
+
+`setRuntimeAdmission(?callable)` optionally wraps each actual send/edit/delete/unpin/self-destruct operation. The application supplies one cancellation-aware FIFO shared by manual and scheduled managers: only one native operation can run at once, and a waiting operator's capability and scheduled creator authority are checked again after admission. Shutdown aborts queued admissions without starting them. This caps application native workers at one operation's limit (UI 20, dependency maximum 30), rather than multiplying the cap by the number of operators. The optional hook is unset for upstream consumers.
+
+With runtime hooks enabled, only the latest 128 fully drained completed progress entries remain in each native in-memory index. Active, paused, in-flight and still-joining entries are preserved. Persistent broadcast history and message IDs are not removed; an old completed inline control may report that its operation is no longer active. Tests cover 40 concurrent FIFO admissions with maximum active count one, 40 queued admissions cancelled behind one active operation, all six operation wrappers, cancellation precedence and pruning that preserves active/in-flight/joining entries.
diff --git a/patches/broadcastmanager/baseline/BroadcastManager.php b/patches/broadcastmanager/baseline/BroadcastManager.php
new file mode 100644
index 0000000..d8068bb
--- /dev/null
+++ b/patches/broadcastmanager/baseline/BroadcastManager.php
@@ -0,0 +1,1958 @@
+
+ * @copyright - WizardLoop
+ * @license - https://opensource.org/licenses/AGPL-3.0 AGPLv3
+ */
+
+namespace BroadcastTool;
+
+use danog\MadelineProto\API;
+use danog\MadelineProto\RPCErrorException;
+use InvalidArgumentException;
+use JsonException;
+use RuntimeException;
+use SplQueue;
+use Throwable;
+
+class BroadcastManager
+{
+ private const MAX_ATTEMPTS = 3;
+ private const DEFAULT_CONCURRENCY = 10;
+ private const MAX_CONCURRENCY = 30;
+ private const PROGRESS_UPDATE_INTERVAL = 5.0;
+ private const WORKER_IDLE_SLEEP = 1.0;
+ private const WORKER_RETRY_SLEEP = 1.0;
+ private const WORKER_PAUSED_SLEEP = 1.5;
+ private const WORKER_AFTER_JOB_SLEEP = 0.75;
+ private const SEND_MESSAGE_SLEEP = 0.35;
+ private const SEND_CHUNK_SLEEP = 0.75;
+
+ private const SEND_HARD_FAIL_RPCS = [
+ 'INPUT_USER_DEACTIVATED',
+ 'USER_IS_BOT',
+ 'CHAT_WRITE_FORBIDDEN',
+ 'USER_IS_BLOCKED',
+ 'PEER_ID_INVALID',
+ ];
+
+ private const EDIT_HARD_FAIL_RPCS = [
+ 'INPUT_USER_DEACTIVATED',
+ 'USER_IS_BOT',
+ 'CHAT_WRITE_FORBIDDEN',
+ 'USER_IS_BLOCKED',
+ 'PEER_ID_INVALID',
+ 'MESSAGE_ID_INVALID',
+ 'MESSAGE_EDIT_TIME_EXPIRED',
+ 'MESSAGE_AUTHOR_REQUIRED',
+ ];
+
+ private const DELETE_HARD_FAIL_RPCS = [
+ 'INPUT_USER_DEACTIVATED',
+ 'USER_IS_BOT',
+ 'CHAT_WRITE_FORBIDDEN',
+ 'USER_IS_BLOCKED',
+ 'PEER_ID_INVALID',
+ 'MESSAGE_ID_INVALID',
+ ];
+
+ private API $api;
+ private array $currentBroadcastState = [];
+ private static array $sharedBroadcastState = [];
+ private static string $dataDir = '';
+
+ public function __construct(API $api)
+ {
+ $this->api = $api;
+ }
+
+ /**
+ * Set data dir.
+ */
+ public static function setDataDir(string $path): void
+ {
+ self::$dataDir = rtrim($path, '/\\');
+ }
+
+ /**
+ * Get data dir.
+ */
+ private static function getDataDir(): string
+ {
+ if (!self::$dataDir) {
+ self::$dataDir = __DIR__ . '/../data';
+ }
+
+ return self::$dataDir;
+ }
+
+ /**
+ * Send broadcast.
+ *
+ * @return string ID that can be used for progress/pause/resume/cancel.
+ */
+ public function broadcastWithProgress(
+ array $allUsers,
+ array $messages,
+ $chatId = null,
+ bool $pin = false,
+ int $concurrency = self::DEFAULT_CONCURRENCY,
+ ?int $selfDestructHours = null
+ ): string {
+ $this->validateSelfDestructHours($selfDestructHours);
+
+ $concurrency = $this->clampConcurrency($concurrency);
+ $total = count($allUsers);
+ $broadcastId = $this->createId();
+ $state = $this->createState($broadcastId, 'send', $total, [
+ 'selfDestruct' => [
+ 'enabled' => $selfDestructHours !== null,
+ 'hours' => $selfDestructHours,
+ 'deleteAt' => null,
+ 'deleteJobId' => null,
+ ],
+ ]);
+
+ $this->registerCurrentState($broadcastId, $state);
+ $this->enqueuePeers($state, $allUsers);
+ $this->initializeBroadcastMetadata($broadcastId, 'send', $total, $selfDestructHours);
+
+ $statusId = $this->sendStatusMessage($chatId, 'Gathering peers...', $this->buildStatusControls($state));
+ $this->startProgressLoop($chatId, $statusId, $state, 'Broadcast Progress');
+
+ $this->startQueueWorkers(
+ $state,
+ $concurrency,
+ function (array $job, array &$state) use ($messages, $pin, $broadcastId): void {
+ $peer = (string) $job['peer'];
+ $messageIds = $this->sendMessagesToPeer($peer, $messages);
+ $lastMessageId = $messageIds ? end($messageIds) : null;
+
+ if ($pin && $lastMessageId) {
+ $this->api->messages->updatePinnedMessage([
+ 'peer' => $peer,
+ 'id' => $lastMessageId,
+ 'unpin' => false,
+ ]);
+ }
+
+ $state['sent']++;
+ $state['lastMessageIds'][$peer] = $lastMessageId ? (string) $lastMessageId : '';
+
+ if ($messageIds) {
+ $this->savePeerMessageIds($peer, $messageIds);
+ }
+
+ $this->saveBroadcastPeerMessageIds(
+ $broadcastId,
+ $peer,
+ $messageIds,
+ (int) $state['sent'],
+ (int) $state['failed']
+ );
+ },
+ self::SEND_HARD_FAIL_RPCS
+ );
+
+ $this->waitForCompletion($state);
+ $state['status'] = $state['cancel'] ? 'cancelled' : 'done';
+
+ $this->finalizeBroadcastMetadata(
+ $broadcastId,
+ (string) $state['status'],
+ (int) $state['sent'],
+ (int) $state['failed']
+ );
+
+ $selfDestructJobId = null;
+ if ($selfDestructHours !== null) {
+ $selfDestructJobId = $this->createSelfDestructJob($broadcastId, $selfDestructHours, $chatId, $concurrency);
+ if ($selfDestructJobId !== null) {
+ $metadata = $this->loadBroadcastMetadata($broadcastId);
+ $state['selfDestruct'] = $metadata['selfDestruct'] ?? $state['selfDestruct'];
+ }
+
+ if ($selfDestructJobId !== null && $selfDestructHours === 0) {
+ $this->runSelfDestructJob($selfDestructJobId);
+ }
+ }
+
+ $finalText = $this->buildProgressText($state, 'Broadcast Progress', true);
+ $this->editStatusMessage($chatId, $statusId, $finalText);
+ $this->writeLastBroadcastData($finalText);
+
+ return $broadcastId;
+ }
+
+ /**
+ * Edit the last saved broadcast message for all users.
+ */
+ public function editLastBroadcastForAll(
+ array $allUsers,
+ string $newText,
+ $chatId = null,
+ ?array $buttons = null,
+ $media = null,
+ int $concurrency = self::DEFAULT_CONCURRENCY,
+ string $parseMode = 'HTML',
+ ?string $broadcastId = null
+ ): string {
+ $concurrency = $this->clampConcurrency($concurrency);
+ $targetBroadcastId = $this->normalizeOptionalId($broadcastId);
+ $targets = $targetBroadcastId !== null && $allUsers === []
+ ? $this->broadcastMetadataPeers($targetBroadcastId)
+ : $allUsers;
+ $operationId = $this->createId();
+ $state = $this->createState($operationId, 'edit', count($targets), [
+ 'targetBroadcastId' => $targetBroadcastId,
+ ]);
+
+ $this->registerCurrentState($operationId, $state);
+ $this->enqueuePeers($state, $targets);
+
+ $statusId = $this->sendStatusMessage($chatId, 'Editing last broadcast...', $this->buildStatusControls($state));
+ $this->startProgressLoop($chatId, $statusId, $state, 'Editing Last Broadcast');
+
+ $this->startQueueWorkers(
+ $state,
+ $concurrency,
+ function (array $job, array &$state) use ($newText, $buttons, $media, $parseMode, $targetBroadcastId): void {
+ $peer = (string) $job['peer'];
+ $lastMessageId = $targetBroadcastId !== null
+ ? $this->readBroadcastLastMessageId($targetBroadcastId, $peer)
+ : $this->readLastBroadcastMessageId($peer);
+
+ if ($lastMessageId <= 0) {
+ $state['failed']++;
+ return;
+ }
+
+ $payload = [
+ 'peer' => $peer,
+ 'id' => $lastMessageId,
+ 'message' => $newText,
+ 'parse_mode' => $parseMode,
+ 'floodWaitLimit' => 172800,
+ ];
+
+ if ($buttons !== null) {
+ $payload['reply_markup'] = $buttons;
+ }
+
+ if ($media !== null) {
+ $payload['media'] = $media;
+ }
+
+ $this->api->messages->editMessage($payload);
+ $state['edited']++;
+ },
+ self::EDIT_HARD_FAIL_RPCS,
+ function (RPCErrorException $e, array &$job, array &$state): bool {
+ unset($job);
+
+ if (($e->rpc ?? '') === 'MESSAGE_NOT_MODIFIED' || str_contains($e->getMessage(), 'MESSAGE_NOT_MODIFIED')) {
+ $state['unchanged']++;
+ return true;
+ }
+
+ return false;
+ }
+ );
+
+ $this->waitForCompletion($state);
+ $state['status'] = $state['cancel'] ? 'cancelled' : 'done';
+
+ $this->editStatusMessage($chatId, $statusId, $this->buildProgressText($state, 'Editing Last Broadcast', true));
+
+ return $operationId;
+ }
+
+ /**
+ * Schedule a broadcast. Due broadcasts can be run with runDueScheduledBroadcasts().
+ */
+ public function scheduleBroadcastForAll(
+ array $allUsers,
+ array $messages,
+ int $scheduledAt,
+ $chatId = null,
+ bool $pin = false,
+ int $concurrency = self::DEFAULT_CONCURRENCY,
+ ?int $selfDestructHours = null
+ ): string {
+ $this->validateSelfDestructHours($selfDestructHours);
+ $this->assertJsonEncodable($allUsers, 'allUsers');
+ $this->assertJsonEncodable($messages, 'messages');
+
+ $concurrency = $this->clampConcurrency($concurrency);
+ $scheduleId = $this->createId('schedule');
+ $jobs = $this->loadScheduledBroadcasts();
+
+ $jobs[$scheduleId] = [
+ 'id' => $scheduleId,
+ 'status' => 'scheduled',
+ 'scheduledAt' => $scheduledAt,
+ 'createdAt' => time(),
+ 'allUsers' => array_values($allUsers),
+ 'messages' => array_values($messages),
+ 'chatId' => $chatId,
+ 'pin' => $pin,
+ 'concurrency' => $concurrency,
+ 'selfDestructHours' => $selfDestructHours,
+ 'broadcastId' => null,
+ 'error' => null,
+ ];
+
+ $this->saveScheduledBroadcasts($jobs);
+
+ if ($scheduledAt <= time()) {
+ $this->runScheduledBroadcast($scheduleId);
+ }
+
+ return $scheduleId;
+ }
+
+ /**
+ * Run all scheduled broadcasts whose scheduledAt timestamp has passed.
+ */
+ public function runDueScheduledBroadcasts(): array
+ {
+ $jobs = $this->loadScheduledBroadcasts();
+ $results = [];
+ $now = time();
+
+ foreach ($jobs as $scheduleId => $job) {
+ if (($job['status'] ?? null) !== 'scheduled') {
+ continue;
+ }
+
+ if ((int) ($job['scheduledAt'] ?? 0) > $now) {
+ continue;
+ }
+
+ $results[$scheduleId] = $this->runScheduledBroadcast((string) $scheduleId);
+ }
+
+ return $results;
+ }
+
+ public function cancelScheduledBroadcast(string $scheduleId): bool
+ {
+ $jobs = $this->loadScheduledBroadcasts();
+
+ if (!isset($jobs[$scheduleId]) || ($jobs[$scheduleId]['status'] ?? null) !== 'scheduled') {
+ return false;
+ }
+
+ $jobs[$scheduleId]['status'] = 'cancelled';
+ $jobs[$scheduleId]['cancelledAt'] = time();
+ $this->saveScheduledBroadcasts($jobs);
+
+ return true;
+ }
+
+ public function listScheduledBroadcasts(): array
+ {
+ $items = [];
+
+ foreach ($this->loadScheduledBroadcasts() as $id => $job) {
+ $items[$id] = [
+ 'id' => (string) ($job['id'] ?? $id),
+ 'status' => (string) ($job['status'] ?? 'unknown'),
+ 'scheduledAt' => (int) ($job['scheduledAt'] ?? 0),
+ 'createdAt' => (int) ($job['createdAt'] ?? 0),
+ 'broadcastId' => $job['broadcastId'] ?? null,
+ 'pin' => (bool) ($job['pin'] ?? false),
+ 'concurrency' => (int) ($job['concurrency'] ?? self::DEFAULT_CONCURRENCY),
+ 'selfDestructHours' => $job['selfDestructHours'] ?? null,
+ 'totalUsers' => is_array($job['allUsers'] ?? null) ? count($job['allUsers']) : 0,
+ 'totalMessages' => is_array($job['messages'] ?? null) ? count($job['messages']) : 0,
+ 'error' => $job['error'] ?? null,
+ ];
+ }
+
+ return $items;
+ }
+
+ /**
+ * Deletes the last broadcast message for all users.
+ *
+ * @return string ID that can be used for progress/pause/resume/cancel.
+ */
+ public function deleteLastBroadcastForAll(
+ array $allUsers,
+ $chatId = null,
+ int $concurrency = self::DEFAULT_CONCURRENCY,
+ ?string $broadcastId = null
+ ): string {
+ $concurrency = $this->clampConcurrency($concurrency);
+ $targetBroadcastId = $this->normalizeOptionalId($broadcastId);
+ $targets = $targetBroadcastId !== null && $allUsers === []
+ ? $this->broadcastMetadataPeers($targetBroadcastId)
+ : $allUsers;
+ $operationId = $this->createId();
+ $state = $this->createState($operationId, 'deletelast', count($targets), [
+ 'targetBroadcastId' => $targetBroadcastId,
+ ]);
+
+ $this->registerCurrentState($operationId, $state);
+ $this->enqueuePeers($state, $targets);
+
+ $statusId = $this->sendStatusMessage($chatId, 'Deleting last broadcast...', $this->buildStatusControls($state));
+ $this->startProgressLoop($chatId, $statusId, $state, 'Deleting Last Broadcast');
+
+ $this->startQueueWorkers(
+ $state,
+ $concurrency,
+ function (array $job, array &$state) use ($targetBroadcastId): void {
+ $peer = (string) $job['peer'];
+ $lastMessageId = $targetBroadcastId !== null
+ ? $this->readBroadcastLastMessageId($targetBroadcastId, $peer)
+ : $this->readLastBroadcastMessageId($peer);
+
+ if ($lastMessageId <= 0) {
+ $state['failed']++;
+ return;
+ }
+
+ $this->api->messages->deleteMessages([
+ 'peer' => $peer,
+ 'id' => [$lastMessageId],
+ 'revoke' => true,
+ ]);
+
+ if ($targetBroadcastId !== null) {
+ $this->markBroadcastPeerMessageDeleted($targetBroadcastId, $peer, $lastMessageId);
+ } else {
+ $this->deleteFile($this->peerDataPath($peer, 'lastBroadcast.txt'));
+ }
+
+ $state['deleted']++;
+ },
+ self::DELETE_HARD_FAIL_RPCS
+ );
+
+ $this->waitForCompletion($state);
+ $state['status'] = $state['cancel'] ? 'cancelled' : 'done';
+
+ $this->editStatusMessage($chatId, $statusId, $this->buildProgressText($state, 'Deleting Last Broadcast', true));
+
+ return $operationId;
+ }
+
+ /**
+ * Deletes all broadcast messages for all users.
+ *
+ * @return string ID that can be used for progress/pause/resume/cancel.
+ */
+ public function deleteAllBroadcastsForAll(
+ array $allUsers,
+ $chatId = null,
+ int $concurrency = self::DEFAULT_CONCURRENCY
+ ): string {
+ $concurrency = $this->clampConcurrency($concurrency);
+ $broadcastId = $this->createId();
+ $state = $this->createState($broadcastId, 'deleteall', count($allUsers));
+
+ $this->registerCurrentState($broadcastId, $state);
+ $this->enqueuePeers($state, $allUsers);
+
+ $statusId = $this->sendStatusMessage($chatId, 'Deleting all broadcasts...', $this->buildStatusControls($state));
+ $this->startProgressLoop($chatId, $statusId, $state, 'Deleting All Broadcasts');
+
+ $this->startQueueWorkers(
+ $state,
+ $concurrency,
+ function (array $job, array &$state): void {
+ $peer = (string) $job['peer'];
+ $file = $this->peerDataPath($peer, 'messages.txt');
+
+ if (!is_file($file)) {
+ $state['failed']++;
+ return;
+ }
+
+ $msgIds = array_values(array_filter(
+ array_map('intval', explode("\n", trim((string) file_get_contents($file)))),
+ static fn (int $id): bool => $id > 0
+ ));
+
+ if (!$msgIds) {
+ $state['failed']++;
+ return;
+ }
+
+ $userDeleted = false;
+
+ foreach ($msgIds as $messageId) {
+ try {
+ $this->api->messages->deleteMessages([
+ 'peer' => $peer,
+ 'id' => [$messageId],
+ 'revoke' => true,
+ ]);
+ $userDeleted = true;
+ } catch (RPCErrorException $e) {
+ $message = $e->getMessage();
+
+ if ($this->parseFloodWait($e) !== null) {
+ throw $e;
+ }
+
+ if (str_contains($message, 'USER_IS_BLOCKED') || str_contains($message, 'PEER_ID_INVALID')) {
+ continue;
+ }
+
+ throw $e;
+ }
+ }
+
+ if ($userDeleted) {
+ $state['deleted']++;
+ } else {
+ $state['failed']++;
+ }
+
+ $this->deleteFile($this->peerDataPath($peer, 'lastBroadcast.txt'));
+ $this->deleteFile($file);
+ },
+ self::DELETE_HARD_FAIL_RPCS,
+ null,
+ true
+ );
+
+ $this->waitForCompletion($state);
+ $state['status'] = $state['cancel'] ? 'cancelled' : 'done';
+
+ $this->editStatusMessage($chatId, $statusId, $this->buildProgressText($state, 'Deleting All Broadcasts', true));
+
+ return $broadcastId;
+ }
+
+ /**
+ * Unpin all messages for all users.
+ *
+ * @return string ID that can be used for progress/pause/resume/cancel.
+ */
+ public function unpinAllMessagesForAll(
+ array $allUsers,
+ $chatId = null,
+ int $concurrency = self::DEFAULT_CONCURRENCY
+ ): string {
+ $concurrency = $this->clampConcurrency($concurrency);
+ $broadcastId = $this->createId();
+ $state = $this->createState($broadcastId, 'unpin', count($allUsers));
+
+ $this->registerCurrentState($broadcastId, $state);
+ $this->enqueuePeers($state, $allUsers);
+
+ $statusId = $this->sendStatusMessage($chatId, 'Starting unpin...', $this->buildStatusControls($state));
+ $this->startProgressLoop($chatId, $statusId, $state, 'Unpinning Messages');
+
+ $this->startQueueWorkers(
+ $state,
+ $concurrency,
+ function (array $job, array &$state): void {
+ $this->api->messages->unpinAllMessages([
+ 'peer' => (string) $job['peer'],
+ ]);
+
+ $state['unpin']++;
+ },
+ self::SEND_HARD_FAIL_RPCS
+ );
+
+ $this->waitForCompletion($state);
+ $state['status'] = $state['cancel'] ? 'cancelled' : 'done';
+
+ $this->editStatusMessage($chatId, $statusId, $this->buildProgressText($state, 'Unpinning Messages', true));
+
+ return $broadcastId;
+ }
+
+ public function runDueSelfDestructJobs(): array
+ {
+ $jobs = $this->loadSelfDestructJobs();
+ $results = [];
+ $now = time();
+
+ foreach ($jobs as $jobId => $job) {
+ if (($job['status'] ?? null) !== 'scheduled') {
+ continue;
+ }
+
+ if ((int) ($job['deleteAt'] ?? 0) > $now) {
+ continue;
+ }
+
+ $results[$jobId] = $this->runSelfDestructJob((string) $jobId);
+ }
+
+ return $results;
+ }
+
+ public function cancelSelfDestructJob(string $jobId): bool
+ {
+ $jobs = $this->loadSelfDestructJobs();
+
+ if (!isset($jobs[$jobId]) || ($jobs[$jobId]['status'] ?? null) !== 'scheduled') {
+ return false;
+ }
+
+ $jobs[$jobId]['status'] = 'cancelled';
+ $jobs[$jobId]['cancelledAt'] = time();
+ $this->saveSelfDestructJobs($jobs);
+
+ return true;
+ }
+
+ public function listSelfDestructJobs(): array
+ {
+ $items = [];
+
+ foreach ($this->loadSelfDestructJobs() as $id => $job) {
+ $items[$id] = [
+ 'id' => (string) ($job['id'] ?? $id),
+ 'broadcastId' => (string) ($job['broadcastId'] ?? ''),
+ 'status' => (string) ($job['status'] ?? 'unknown'),
+ 'deleteAt' => (int) ($job['deleteAt'] ?? 0),
+ 'createdAt' => (int) ($job['createdAt'] ?? 0),
+ 'concurrency' => (int) ($job['concurrency'] ?? self::DEFAULT_CONCURRENCY),
+ 'chatId' => $job['chatId'] ?? null,
+ 'totalPeers' => (int) ($job['totalPeers'] ?? 0),
+ 'stats' => $job['stats'] ?? null,
+ 'error' => $job['error'] ?? null,
+ ];
+ }
+
+ return $items;
+ }
+
+ /**
+ * Pause running broadcast.
+ */
+ public function pause(string $id): void
+ {
+ if (isset(self::$sharedBroadcastState[$id])) {
+ self::$sharedBroadcastState[$id]['paused'] = true;
+ }
+
+ if (isset($this->currentBroadcastState[$id])) {
+ $this->currentBroadcastState[$id]['paused'] = true;
+ }
+ }
+
+ /**
+ * Resume running broadcast.
+ */
+ public function resume(string $id): void
+ {
+ if (isset(self::$sharedBroadcastState[$id])) {
+ self::$sharedBroadcastState[$id]['paused'] = false;
+ }
+
+ if (isset($this->currentBroadcastState[$id])) {
+ $this->currentBroadcastState[$id]['paused'] = false;
+ }
+ }
+
+ /**
+ * Cancel running broadcast.
+ */
+ public function cancel(string $id): void
+ {
+ if (isset(self::$sharedBroadcastState[$id])) {
+ self::$sharedBroadcastState[$id]['cancel'] = true;
+ }
+
+ if (isset($this->currentBroadcastState[$id])) {
+ $this->currentBroadcastState[$id]['cancel'] = true;
+ }
+ }
+
+ /**
+ * Check if broadcast is paused.
+ */
+ public function isPaused(string $id): bool
+ {
+ return self::$sharedBroadcastState[$id]['paused'] ?? $this->currentBroadcastState[$id]['paused'] ?? false;
+ }
+
+ /**
+ * Check if broadcast is cancelled.
+ */
+ public function isCancelled(string $id): bool
+ {
+ return self::$sharedBroadcastState[$id]['cancel'] ?? $this->currentBroadcastState[$id]['cancel'] ?? false;
+ }
+
+ /**
+ * Check if broadcast is active.
+ */
+ public function isActive(?string $id = null): bool
+ {
+ if (!$id || (!isset(self::$sharedBroadcastState[$id]) && !isset($this->currentBroadcastState[$id]))) {
+ return false;
+ }
+
+ $state = self::$sharedBroadcastState[$id] ?? $this->currentBroadcastState[$id];
+
+ return (
+ empty($state['done'])
+ && empty($state['cancel'])
+ && empty($state['paused'])
+ );
+ }
+
+ /**
+ * Check if there is a last broadcast message saved for deletion.
+ */
+ public function hasLastBroadcast(): bool
+ {
+ foreach (glob(self::getDataDir() . '/*/lastBroadcast.txt') ?: [] as $file) {
+ if (is_file($file) && trim((string) file_get_contents($file)) !== '') {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Check if there are broadcast messages saved for deletion.
+ */
+ public function hasAllBroadcast(): bool
+ {
+ foreach (glob(self::getDataDir() . '/*/messages.txt') ?: [] as $file) {
+ if (is_file($file) && trim((string) file_get_contents($file)) !== '') {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Get current broadcast progress.
+ */
+ public function progress(?string $id = null): ?array
+ {
+ if (!$id || (!isset(self::$sharedBroadcastState[$id]) && !isset($this->currentBroadcastState[$id]))) {
+ return null;
+ }
+
+ $state = $this->normalizeBroadcastState(self::$sharedBroadcastState[$id] ?? $this->currentBroadcastState[$id]);
+
+ $sent = (int) $state['sent'];
+ $deleted = (int) $state['deleted'];
+ $unpin = (int) $state['unpin'];
+ $edited = (int) $state['edited'];
+ $unchanged = (int) $state['unchanged'];
+ $scheduled = (int) $state['scheduled'];
+ $failed = (int) $state['failed'];
+ $flood = (int) $state['flood'];
+
+ $processed = $this->processedCount($state);
+ $success = $sent + $deleted + $unpin + $edited + $unchanged;
+ $pending = $this->pendingCount($state, $processed);
+ $total = (int) $state['total'];
+ $elapsed = $this->elapsedSeconds($state);
+ $tps = $elapsed > 0 ? round($success / $elapsed, 2) : 0.0;
+
+ $progressPercent = $total > 0
+ ? round(($processed / $total) * 100, 2)
+ : 0.0;
+
+ return [
+ 'processed' => $processed,
+ 'success' => $success,
+ 'failed' => $failed,
+ 'pending' => $pending,
+ 'flood' => $flood,
+ 'progressPercent' => $progressPercent,
+ 'breakdown' => [
+ 'sent' => $sent,
+ 'deleted' => $deleted,
+ 'unpin' => $unpin,
+ 'edited' => $edited,
+ 'unchanged' => $unchanged,
+ 'scheduled' => $scheduled,
+ ],
+ 'edited' => $edited,
+ 'unchanged' => $unchanged,
+ 'scheduled' => $scheduled,
+ 'selfDestruct' => $state['selfDestruct'],
+ 'type' => $state['type'],
+ 'total' => $total,
+ 'elapsed' => $elapsed,
+ 'tps' => $tps,
+ 'done' => (bool) $state['done'],
+ 'paused' => (bool) $state['paused'],
+ 'cancel' => (bool) $state['cancel'],
+ 'startedAt' => $state['startedAt'],
+ ];
+ }
+
+ /**
+ * Last broadcast data.
+ */
+ public function lastBroadcastData(): string|false
+ {
+ $dir = self::getDataDir();
+
+ if (!is_dir($dir)) {
+ mkdir($dir, 0777, true);
+ }
+
+ $path = $dir . '/LastBrodDATA.txt';
+
+ if (!is_file($path)) {
+ return false;
+ }
+
+ return (string) file_get_contents($path);
+ }
+
+ /**
+ * Filter peers.
+ *
+ * allowedTypes: all / users / groups / channels
+ *
+ * @return array{targets: array, failed: int, total: int}
+ */
+ public function filterPeers(
+ array $allUsers,
+ string $filterType = 'users'
+ ): array {
+ $allowedTypes = [
+ 'all' => ['user', 'chat', 'supergroup', 'channel'],
+ 'users' => ['user'],
+ 'groups' => ['chat', 'supergroup'],
+ 'channels' => ['channel'],
+ ];
+
+ $targets = [];
+ $failedCount = 0;
+
+ foreach ($allUsers as $peer) {
+ try {
+ $info = $this->api->getInfo($peer);
+ $type = $info['type'] ?? 'user';
+
+ if (in_array($type, $allowedTypes[$filterType] ?? ['user'], true)) {
+ $targets[] = (string) $peer;
+ }
+ } catch (Throwable $e) {
+ if ($filterType === 'all') {
+ $targets[] = (string) $peer;
+ } else {
+ $failedCount++;
+ $this->logError('Failed to inspect peer type.', $e, ['peer' => (string) $peer]);
+ }
+ }
+ }
+
+ return [
+ 'targets' => $targets,
+ 'failed' => $failedCount,
+ 'total' => count($targets),
+ ];
+ }
+
+ private function createState(string $id, string $type, int $total, array $extra = []): array
+ {
+ return $extra + [
+ 'id' => $id,
+ 'type' => $type,
+ 'status' => 'running',
+ 'total' => max(0, $total),
+ 'sent' => 0,
+ 'deleted' => 0,
+ 'unpin' => 0,
+ 'edited' => 0,
+ 'unchanged' => 0,
+ 'scheduled' => 0,
+ 'failed' => 0,
+ 'flood' => 0,
+ 'queue' => new SplQueue(),
+ 'inFlight' => [],
+ 'lastMessageIds' => [],
+ 'paused' => false,
+ 'cancel' => false,
+ 'done' => false,
+ 'startedAt' => microtime(true),
+ 'selfDestruct' => null,
+ ];
+ }
+
+ private function registerCurrentState(string $id, array &$state): void
+ {
+ $this->currentBroadcastState[$id] =& $state;
+ self::$sharedBroadcastState[$id] =& $state;
+ }
+
+ private function enqueuePeers(array &$state, array $peers): void
+ {
+ foreach ($peers as $peer) {
+ $state['queue']->enqueue([
+ 'peer' => (string) $peer,
+ 'attempts' => 0,
+ 'startedAt' => null,
+ 'availableAt' => 0.0,
+ ]);
+ }
+ }
+
+ private function clampConcurrency(int $concurrency): int
+ {
+ return max(1, min(self::MAX_CONCURRENCY, $concurrency));
+ }
+
+ private function startQueueWorkers(
+ array &$state,
+ int $concurrency,
+ callable $handler,
+ array $hardFailRpcs = [],
+ ?callable $rpcHandler = null,
+ bool $retryThrowable = false
+ ): void {
+ $concurrency = $this->clampConcurrency($concurrency);
+
+ for ($i = 0; $i < $concurrency; $i++) {
+ \Amp\async(function () use (&$state, $handler, $hardFailRpcs, $rpcHandler, $retryThrowable): void {
+ while (!$state['cancel'] && !$state['done']) {
+ if ($state['queue']->isEmpty()) {
+ $this->api->sleep(self::WORKER_IDLE_SLEEP);
+ continue;
+ }
+
+ if ($state['paused']) {
+ $this->api->sleep(self::WORKER_PAUSED_SLEEP);
+ continue;
+ }
+
+ $job = $state['queue']->dequeue();
+
+ if (($job['availableAt'] ?? 0) > microtime(true)) {
+ $state['queue']->enqueue($job);
+ $this->api->sleep(self::WORKER_RETRY_SLEEP);
+ continue;
+ }
+
+ while ($state['paused'] && !$state['cancel'] && !$state['done']) {
+ $this->api->sleep(self::WORKER_PAUSED_SLEEP);
+ }
+
+ if ($state['cancel'] || $state['done']) {
+ continue;
+ }
+
+ $peer = (string) $job['peer'];
+ $job['startedAt'] = microtime(true);
+ $state['inFlight'][$peer] = $job;
+
+ try {
+ $handler($job, $state);
+ unset($state['inFlight'][$peer]);
+ } catch (RPCErrorException $e) {
+ unset($state['inFlight'][$peer]);
+
+ if ($rpcHandler !== null && $rpcHandler($e, $job, $state)) {
+ continue;
+ }
+
+ if ($this->handleRpcRetry($e, $job, $state, $hardFailRpcs)) {
+ continue;
+ }
+
+ $this->logError('RPC error during broadcast job.', $e, [
+ 'type' => (string) ($state['type'] ?? ''),
+ 'peer' => $peer,
+ ]);
+ $this->retryOrFail($job, $state);
+ } catch (Throwable $e) {
+ unset($state['inFlight'][$peer]);
+ $this->logError('Unexpected error during broadcast job.', $e, [
+ 'type' => (string) ($state['type'] ?? ''),
+ 'peer' => $peer,
+ ]);
+
+ if ($retryThrowable) {
+ $this->retryOrFail($job, $state);
+ } else {
+ $state['failed']++;
+ }
+ }
+
+ $this->api->sleep(self::WORKER_AFTER_JOB_SLEEP);
+ }
+ });
+ }
+ }
+
+ private function waitForCompletion(array &$state): void
+ {
+ while (true) {
+ if ($state['queue']->isEmpty() && empty($state['inFlight'])) {
+ break;
+ }
+
+ if ($state['cancel'] && empty($state['inFlight'])) {
+ break;
+ }
+
+ $this->api->sleep(1);
+ }
+
+ $state['done'] = true;
+ }
+
+ private function handleRpcRetry(RPCErrorException $e, array &$job, array &$state, array $hardFailRpcs): bool
+ {
+ if ($this->isHardFailRpc((string) ($e->rpc ?? ''), $hardFailRpcs)) {
+ $state['failed']++;
+ return true;
+ }
+
+ $floodWait = $this->parseFloodWait($e);
+ if ($floodWait !== null) {
+ $state['flood']++;
+ $job['attempts']++;
+ $job['availableAt'] = microtime(true) + $floodWait;
+ $job['startedAt'] = null;
+
+ if ($job['attempts'] >= self::MAX_ATTEMPTS) {
+ $state['failed']++;
+ } else {
+ $state['queue']->enqueue($job);
+ }
+
+ return true;
+ }
+
+ return false;
+ }
+
+ private function isHardFailRpc(string $rpc, array $hardFailRpcs = self::SEND_HARD_FAIL_RPCS): bool
+ {
+ return in_array($rpc, $hardFailRpcs, true);
+ }
+
+ private function parseFloodWait(RPCErrorException $e): ?int
+ {
+ if (preg_match('/FLOOD_WAIT_(\d+)/', $e->getMessage(), $m)) {
+ return (int) $m[1];
+ }
+
+ return null;
+ }
+
+ private function retryOrFail(array &$job, array &$state): void
+ {
+ $job['attempts']++;
+ $job['startedAt'] = null;
+
+ if ($job['attempts'] >= self::MAX_ATTEMPTS) {
+ $state['failed']++;
+ return;
+ }
+
+ $state['queue']->enqueue($job);
+ }
+
+ private function sendMessagesToPeer(string $peer, array $messages): array
+ {
+ $messageIds = [];
+ $albumMessages = [];
+
+ foreach ($messages as $message) {
+ if (isset($message['albumFile']) && is_file((string) $message['albumFile'])) {
+ $decoded = json_decode((string) file_get_contents((string) $message['albumFile']), true);
+ $albumMessages = is_array($decoded) ? $decoded : [];
+ }
+ }
+
+ if ($albumMessages) {
+ foreach (array_chunk($albumMessages, 10) as $chunk) {
+ $multi = [];
+
+ foreach ($chunk as $item) {
+ $media = ($item['media']['type'] ?? null) === 'photo'
+ ? ['_' => 'inputMediaPhoto', 'id' => $item['media']['botApiFileId']]
+ : ['_' => 'inputMediaDocument', 'id' => $item['media']['botApiFileId']];
+
+ $multi[] = [
+ '_' => 'inputSingleMedia',
+ 'media' => $media,
+ 'message' => $item['caption'] ?? '',
+ 'entities' => $item['entities'] ?? [],
+ ];
+ }
+
+ foreach ($this->api->messages->sendMultiMedia(['peer' => $peer, 'multi_media' => $multi]) as $update) {
+ $messageId = (int) $this->api->extractMessageId($update);
+
+ if ($messageId > 0) {
+ $messageIds[] = $messageId;
+ }
+ }
+
+ $this->api->sleep(self::SEND_CHUNK_SLEEP);
+ }
+
+ return $messageIds;
+ }
+
+ foreach ($messages as $message) {
+ $method = isset($message['media']) ? 'sendMedia' : 'sendMessage';
+ $payload = $message + [
+ 'peer' => $peer,
+ 'floodWaitLimit' => 172800,
+ ];
+
+ if (isset($message['buttons'])) {
+ $payload['reply_markup'] = $message['buttons'];
+ }
+
+ $result = $this->api->messages->{$method}($payload);
+ $messageId = (int) $this->api->extractMessageId($result);
+
+ if ($messageId > 0) {
+ $messageIds[] = $messageId;
+ }
+
+ $this->api->sleep(self::SEND_MESSAGE_SLEEP);
+ }
+
+ return $messageIds;
+ }
+
+ private function savePeerMessageIds(string $peer, array $messageIds): void
+ {
+ $messageIds = array_values(array_filter(array_map('intval', $messageIds), static fn (int $id): bool => $id > 0));
+
+ if (!$messageIds) {
+ return;
+ }
+
+ $dir = self::getDataDir() . '/' . $peer;
+ $this->ensureDirectory($dir);
+
+ try {
+ file_put_contents($dir . '/messages.txt', implode("\n", $messageIds) . "\n", FILE_APPEND | LOCK_EX);
+ file_put_contents($dir . '/lastBroadcast.txt', (string) end($messageIds), LOCK_EX);
+ } catch (Throwable $e) {
+ $this->logError('Failed to save peer message ids.', $e, ['peer' => $peer]);
+ }
+ }
+
+ private function initializeBroadcastMetadata(string $broadcastId, string $type, int $total, ?int $selfDestructHours): void
+ {
+ $metadata = [
+ 'id' => $broadcastId,
+ 'type' => $type,
+ 'createdAt' => time(),
+ 'status' => 'running',
+ 'total' => $total,
+ 'sent' => 0,
+ 'failed' => 0,
+ 'peers' => [],
+ 'selfDestruct' => [
+ 'enabled' => $selfDestructHours !== null,
+ 'hours' => $selfDestructHours,
+ 'deleteAt' => null,
+ 'deleteJobId' => null,
+ ],
+ ];
+
+ $this->saveBroadcastMetadata($broadcastId, $metadata);
+ }
+
+ private function saveBroadcastPeerMessageIds(
+ string $broadcastId,
+ string $peer,
+ array $messageIds,
+ int $sent,
+ int $failed
+ ): void {
+ $metadata = $this->loadBroadcastMetadata($broadcastId);
+ $messageIds = array_values(array_filter(array_map('intval', $messageIds), static fn (int $id): bool => $id > 0));
+
+ $metadata['sent'] = $sent;
+ $metadata['failed'] = $failed;
+ if (!isset($metadata['peers']) || !is_array($metadata['peers'])) {
+ $metadata['peers'] = [];
+ }
+
+ $metadata['peers'][$peer] = [
+ 'lastMessageId' => $messageIds ? end($messageIds) : null,
+ 'messageIds' => $messageIds,
+ 'status' => 'sent',
+ ];
+
+ $this->saveBroadcastMetadata($broadcastId, $metadata);
+ }
+
+ private function finalizeBroadcastMetadata(string $broadcastId, string $status, int $sent, int $failed): void
+ {
+ $metadata = $this->loadBroadcastMetadata($broadcastId);
+ $metadata['status'] = $status;
+ $metadata['sent'] = $sent;
+ $metadata['failed'] = $failed;
+ $metadata['finishedAt'] = time();
+
+ $this->saveBroadcastMetadata($broadcastId, $metadata);
+ }
+
+ private function saveBroadcastMetadata(string $broadcastId, array $metadata): void
+ {
+ try {
+ $this->writeJsonFileAtomic($this->broadcastMetadataPath($broadcastId), $metadata);
+ } catch (Throwable $e) {
+ $this->logError('Failed to save broadcast metadata.', $e, ['broadcastId' => $broadcastId]);
+ }
+ }
+
+ private function loadBroadcastMetadata(string $broadcastId): array
+ {
+ return $this->readJsonFile($this->broadcastMetadataPath($broadcastId), [
+ 'id' => $broadcastId,
+ 'type' => 'send',
+ 'createdAt' => time(),
+ 'status' => 'running',
+ 'total' => 0,
+ 'sent' => 0,
+ 'failed' => 0,
+ 'peers' => [],
+ 'selfDestruct' => [
+ 'enabled' => false,
+ 'hours' => null,
+ 'deleteAt' => null,
+ 'deleteJobId' => null,
+ ],
+ ]);
+ }
+
+ private function readJsonFile(string $path, array $default = []): array
+ {
+ if (!is_file($path)) {
+ return $default;
+ }
+
+ try {
+ $content = (string) file_get_contents($path);
+
+ if (trim($content) === '') {
+ return $default;
+ }
+
+ $decoded = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
+ return is_array($decoded) ? $decoded : $default;
+ } catch (Throwable $e) {
+ $this->logError('Failed to read JSON file.', $e, ['path' => $path]);
+ return $default;
+ }
+ }
+
+ private function writeJsonFileAtomic(string $path, array $data): void
+ {
+ $this->ensureDirectory(dirname($path));
+
+ $tmp = $path . '.tmp.' . bin2hex(random_bytes(4));
+
+ try {
+ $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
+
+ if (file_put_contents($tmp, $json . "\n", LOCK_EX) === false) {
+ throw new RuntimeException('Unable to write temporary JSON file.');
+ }
+
+ if (!@rename($tmp, $path)) {
+ if (is_file($path)) {
+ @unlink($path);
+ }
+
+ if (!@rename($tmp, $path)) {
+ throw new RuntimeException('Unable to rename temporary JSON file.');
+ }
+ }
+ } catch (Throwable $e) {
+ if (is_file($tmp)) {
+ @unlink($tmp);
+ }
+
+ $this->logError('Failed to write JSON file atomically.', $e, ['path' => $path]);
+ throw $e;
+ }
+ }
+
+ private function logError(string $message, ?Throwable $e = null, array $context = []): void
+ {
+ try {
+ $this->ensureDirectory(self::getDataDir());
+ $entry = [
+ 'time' => date('c'),
+ 'message' => $message,
+ 'error' => $e ? $e->getMessage() : null,
+ 'rpc' => $e instanceof RPCErrorException ? ($e->rpc ?? null) : null,
+ 'context' => $context,
+ ];
+
+ file_put_contents(
+ self::getDataDir() . '/broadcast-errors.log',
+ json_encode($entry, JSON_UNESCAPED_SLASHES) . "\n",
+ FILE_APPEND | LOCK_EX
+ );
+ } catch (Throwable) {
+ // Logging must never break bot execution.
+ }
+ }
+
+ private function runScheduledBroadcast(string $scheduleId): array
+ {
+ $jobs = $this->loadScheduledBroadcasts();
+
+ if (!isset($jobs[$scheduleId])) {
+ return ['status' => 'missing'];
+ }
+
+ if (($jobs[$scheduleId]['status'] ?? null) !== 'scheduled') {
+ return [
+ 'status' => (string) ($jobs[$scheduleId]['status'] ?? 'unknown'),
+ 'broadcastId' => $jobs[$scheduleId]['broadcastId'] ?? null,
+ ];
+ }
+
+ $jobs[$scheduleId]['status'] = 'running';
+ $jobs[$scheduleId]['startedAt'] = time();
+ $this->saveScheduledBroadcasts($jobs);
+
+ try {
+ $job = $jobs[$scheduleId];
+ $broadcastId = $this->broadcastWithProgress(
+ $job['allUsers'] ?? [],
+ $job['messages'] ?? [],
+ $job['chatId'] ?? null,
+ (bool) ($job['pin'] ?? false),
+ (int) ($job['concurrency'] ?? self::DEFAULT_CONCURRENCY),
+ $job['selfDestructHours'] ?? null
+ );
+
+ $progress = $this->progress($broadcastId);
+ $jobs = $this->loadScheduledBroadcasts();
+ $jobs[$scheduleId]['status'] = ($progress['cancel'] ?? false) ? 'cancelled' : 'done';
+ $jobs[$scheduleId]['broadcastId'] = $broadcastId;
+ $jobs[$scheduleId]['finishedAt'] = time();
+ $jobs[$scheduleId]['error'] = null;
+ $this->saveScheduledBroadcasts($jobs);
+
+ return [
+ 'status' => $jobs[$scheduleId]['status'],
+ 'broadcastId' => $broadcastId,
+ ];
+ } catch (Throwable $e) {
+ $jobs = $this->loadScheduledBroadcasts();
+ $jobs[$scheduleId]['status'] = 'failed';
+ $jobs[$scheduleId]['error'] = substr($e->getMessage(), 0, 500);
+ $jobs[$scheduleId]['failedAt'] = time();
+ $this->saveScheduledBroadcasts($jobs);
+ $this->logError('Scheduled broadcast failed.', $e, ['scheduleId' => $scheduleId]);
+
+ return [
+ 'status' => 'failed',
+ 'error' => $jobs[$scheduleId]['error'],
+ ];
+ }
+ }
+
+ private function createSelfDestructJob(string $broadcastId, int $hours, $chatId, int $concurrency): ?string
+ {
+ $metadata = $this->loadBroadcastMetadata($broadcastId);
+ $peers = array_filter(
+ $metadata['peers'] ?? [],
+ static fn ($peerData): bool => is_array($peerData) && !empty($peerData['messageIds'])
+ );
+
+ if (!$peers) {
+ return null;
+ }
+
+ $jobId = $this->createId('selfdestruct');
+ $deleteAt = time() + ($hours * 3600);
+ $jobs = $this->loadSelfDestructJobs();
+
+ $jobs[$jobId] = [
+ 'id' => $jobId,
+ 'broadcastId' => $broadcastId,
+ 'status' => 'scheduled',
+ 'deleteAt' => $deleteAt,
+ 'createdAt' => time(),
+ 'concurrency' => $this->clampConcurrency($concurrency),
+ 'chatId' => $chatId,
+ 'totalPeers' => count($peers),
+ 'stats' => null,
+ 'error' => null,
+ ];
+
+ $this->saveSelfDestructJobs($jobs);
+
+ $metadata['selfDestruct'] = [
+ 'enabled' => true,
+ 'hours' => $hours,
+ 'deleteAt' => $deleteAt,
+ 'deleteJobId' => $jobId,
+ ];
+ $this->saveBroadcastMetadata($broadcastId, $metadata);
+
+ return $jobId;
+ }
+
+ private function runSelfDestructJob(string $jobId): array
+ {
+ $jobs = $this->loadSelfDestructJobs();
+
+ if (!isset($jobs[$jobId])) {
+ return ['status' => 'missing'];
+ }
+
+ if (($jobs[$jobId]['status'] ?? null) !== 'scheduled') {
+ return [
+ 'status' => (string) ($jobs[$jobId]['status'] ?? 'unknown'),
+ 'stats' => $jobs[$jobId]['stats'] ?? null,
+ ];
+ }
+
+ $jobs[$jobId]['status'] = 'running';
+ $jobs[$jobId]['startedAt'] = time();
+ $this->saveSelfDestructJobs($jobs);
+
+ try {
+ $job = $jobs[$jobId];
+ $metadata = $this->loadBroadcastMetadata((string) $job['broadcastId']);
+ $peerJobs = [];
+
+ foreach (($metadata['peers'] ?? []) as $peer => $peerData) {
+ if (!is_array($peerData)) {
+ continue;
+ }
+
+ $messageIds = array_values(array_filter(
+ array_map('intval', $peerData['messageIds'] ?? []),
+ static fn (int $id): bool => $id > 0
+ ));
+
+ if (!$messageIds) {
+ continue;
+ }
+
+ $peerJobs[] = [
+ 'peer' => (string) $peer,
+ 'messageIds' => $messageIds,
+ 'attempts' => 0,
+ 'startedAt' => null,
+ 'availableAt' => 0.0,
+ ];
+ }
+
+ $state = $this->createState($jobId, 'selfdestruct', count($peerJobs), [
+ 'selfDestruct' => [
+ 'jobId' => $jobId,
+ 'broadcastId' => (string) $job['broadcastId'],
+ 'deleteAt' => (int) $job['deleteAt'],
+ ],
+ ]);
+ $this->registerCurrentState($jobId, $state);
+
+ foreach ($peerJobs as $peerJob) {
+ $state['queue']->enqueue($peerJob);
+ }
+
+ $this->startQueueWorkers(
+ $state,
+ (int) ($job['concurrency'] ?? self::DEFAULT_CONCURRENCY),
+ function (array $peerJob, array &$state): void {
+ $messageIds = array_values(array_filter(
+ array_map('intval', $peerJob['messageIds'] ?? []),
+ static fn (int $id): bool => $id > 0
+ ));
+
+ if (!$messageIds) {
+ return;
+ }
+
+ $this->api->messages->deleteMessages([
+ 'peer' => (string) $peerJob['peer'],
+ 'id' => $messageIds,
+ 'revoke' => true,
+ ]);
+
+ $state['deleted']++;
+ },
+ self::DELETE_HARD_FAIL_RPCS
+ );
+
+ $this->waitForCompletion($state);
+ $state['status'] = $state['cancel'] ? 'cancelled' : 'done';
+
+ $stats = [
+ 'deleted' => (int) $state['deleted'],
+ 'failed' => (int) $state['failed'],
+ 'flood' => (int) $state['flood'],
+ 'total' => (int) $state['total'],
+ ];
+
+ $jobs = $this->loadSelfDestructJobs();
+ $jobs[$jobId]['status'] = $state['cancel'] ? 'cancelled' : 'done';
+ $jobs[$jobId]['finishedAt'] = time();
+ $jobs[$jobId]['stats'] = $stats;
+ $jobs[$jobId]['error'] = null;
+ $this->saveSelfDestructJobs($jobs);
+
+ return [
+ 'status' => $jobs[$jobId]['status'],
+ 'stats' => $stats,
+ ];
+ } catch (Throwable $e) {
+ $jobs = $this->loadSelfDestructJobs();
+ $jobs[$jobId]['status'] = 'failed';
+ $jobs[$jobId]['failedAt'] = time();
+ $jobs[$jobId]['error'] = substr($e->getMessage(), 0, 500);
+ $this->saveSelfDestructJobs($jobs);
+ $this->logError('Self-destruct job failed.', $e, ['jobId' => $jobId]);
+
+ return [
+ 'status' => 'failed',
+ 'error' => $jobs[$jobId]['error'],
+ ];
+ }
+ }
+
+ private function normalizeBroadcastState(array $state): array
+ {
+ return [
+ 'id' => $state['id'] ?? null,
+ 'type' => $state['type'] ?? null,
+ 'sent' => $state['sent'] ?? 0,
+ 'deleted' => $state['deleted'] ?? 0,
+ 'unpin' => $state['unpin'] ?? 0,
+ 'edited' => $state['edited'] ?? 0,
+ 'unchanged' => $state['unchanged'] ?? 0,
+ 'scheduled' => $state['scheduled'] ?? 0,
+ 'failed' => $state['failed'] ?? 0,
+ 'flood' => $state['flood'] ?? 0,
+ 'total' => $state['total'] ?? null,
+ 'queue' => $state['queue'] ?? null,
+ 'inFlight' => $state['inFlight'] ?? [],
+ 'done' => $state['done'] ?? false,
+ 'paused' => $state['paused'] ?? false,
+ 'cancel' => $state['cancel'] ?? false,
+ 'startedAt' => $state['startedAt'] ?? null,
+ 'selfDestruct' => $state['selfDestruct'] ?? null,
+ ];
+ }
+
+ private function processedCount(array $state): int
+ {
+ return (int) $state['sent']
+ + (int) $state['deleted']
+ + (int) $state['unpin']
+ + (int) $state['edited']
+ + (int) $state['unchanged']
+ + (int) $state['scheduled']
+ + (int) $state['failed'];
+ }
+
+ private function pendingCount(array $state, int $processed): int
+ {
+ if (isset($state['total']) && is_int($state['total'])) {
+ return max(0, $state['total'] - $processed);
+ }
+
+ return ($state['queue'] instanceof SplQueue) ? $state['queue']->count() : 0;
+ }
+
+ private function elapsedSeconds(array $state): float
+ {
+ if (!isset($state['startedAt']) || !$state['startedAt']) {
+ return 0.0;
+ }
+
+ return max(0.0, microtime(true) - (float) $state['startedAt']);
+ }
+
+ private function buildProgressText(array $state, string $title, bool $final = false): string
+ {
+ $normalized = $this->normalizeBroadcastState($state);
+ $processed = $this->processedCount($normalized);
+ $total = (int) ($normalized['total'] ?? $processed);
+ $pending = $this->pendingCount($normalized, $processed);
+ $success = (int) $normalized['sent']
+ + (int) $normalized['deleted']
+ + (int) $normalized['unpin']
+ + (int) $normalized['edited']
+ + (int) $normalized['unchanged'];
+ $elapsed = $this->elapsedSeconds($normalized);
+ $tps = $elapsed > 0 ? round($success / $elapsed, 2) : 0.0;
+
+ $lines = [
+ '' . $title . '',
+ '',
+ '' . $this->progressBar($processed, max(1, $total)) . '',
+ '',
+ 'Processed: ' . $processed . ' / ' . $total,
+ ];
+
+ foreach (['sent' => 'Sent', 'edited' => 'Edited', 'unchanged' => 'Unchanged', 'deleted' => 'Deleted', 'unpin' => 'Unpinned'] as $key => $label) {
+ if ((int) $normalized[$key] > 0 || $normalized['type'] === $key || ($key === 'edited' && $normalized['type'] === 'edit')) {
+ $lines[] = $label . ': ' . (int) $normalized[$key];
+ }
+ }
+
+ $lines[] = 'Failed: ' . (int) $normalized['failed'];
+ $lines[] = 'FLOOD_WAIT: ' . (int) $normalized['flood'];
+ $lines[] = 'Pending: ' . $pending;
+ $lines[] = 'TPS: ' . $tps . '/s';
+
+ if ($normalized['paused']) {
+ $lines[] = 'Paused';
+ }
+
+ if ($normalized['cancel']) {
+ $lines[] = 'Cancelled';
+ } elseif ($final) {
+ $lines[] = 'Finished';
+ }
+
+ return implode("\n", $lines);
+ }
+
+ /**
+ * Progress bar.
+ */
+ private function progressBar(int $current, int $total): string
+ {
+ $len = 20;
+ $filled = (int) round($current / max($total, 1) * $len);
+
+ return str_repeat('#', $filled)
+ . str_repeat('-', max(0, $len - $filled))
+ . ' '
+ . round(($current / max($total, 1)) * 100)
+ . '%';
+ }
+
+ private function buildStatusControls(array $state): ?array
+ {
+ if (($state['done'] ?? false) || empty($state['id'])) {
+ return null;
+ }
+
+ $id = (string) $state['id'];
+ $toggleAction = !empty($state['paused']) ? 'resume' : 'pause';
+ $toggleText = !empty($state['paused']) ? 'Resume' : 'Pause';
+
+ return [
+ 'inline_keyboard' => [
+ [
+ ['text' => $toggleText, 'callback_data' => 'bm:' . $toggleAction . ':' . $id],
+ ['text' => 'Cancel', 'callback_data' => 'bm:cancel:' . $id],
+ ],
+ ],
+ ];
+ }
+
+ private function sendStatusMessage($chatId, string $message, ?array $replyMarkup = null): ?int
+ {
+ if (!$chatId) {
+ return null;
+ }
+
+ try {
+ $payload = [
+ 'peer' => $chatId,
+ 'message' => $message,
+ 'parse_mode' => 'HTML',
+ ];
+
+ if ($replyMarkup !== null) {
+ $payload['reply_markup'] = $replyMarkup;
+ }
+
+ $status = $this->api->messages->sendMessage($payload);
+
+ return (int) $this->api->extractMessageId($status);
+ } catch (Throwable $e) {
+ $this->logError('Failed to send status message.', $e);
+ return null;
+ }
+ }
+
+ private function startProgressLoop($chatId, ?int $statusId, array &$state, string $title): void
+ {
+ if (!$chatId || !$statusId) {
+ return;
+ }
+
+ \Amp\async(function () use ($chatId, $statusId, &$state, $title): void {
+ $last = '';
+ $loggedFailures = 0;
+
+ while (!$state['done']) {
+ $text = $this->buildProgressText($state, $title);
+ $replyMarkup = $this->buildStatusControls($state);
+ $fingerprint = $text . "\n" . json_encode($replyMarkup);
+
+ if ($fingerprint !== $last) {
+ try {
+ $payload = [
+ 'peer' => $chatId,
+ 'id' => $statusId,
+ 'message' => $text,
+ 'parse_mode' => 'HTML',
+ ];
+
+ if ($replyMarkup !== null) {
+ $payload['reply_markup'] = $replyMarkup;
+ }
+
+ $this->api->messages->editMessage($payload);
+ $last = $fingerprint;
+ } catch (RPCErrorException $e) {
+ if (
+ ($e->rpc ?? '') === 'MESSAGE_NOT_MODIFIED'
+ || str_contains($e->getMessage(), 'MESSAGE_NOT_MODIFIED')
+ ) {
+ $last = $fingerprint;
+ $this->api->sleep(self::PROGRESS_UPDATE_INTERVAL);
+ continue;
+ }
+
+ if ($loggedFailures < 3) {
+ $loggedFailures++;
+ $this->logError('Failed to update status message.', $e);
+ }
+ } catch (Throwable $e) {
+ if ($loggedFailures < 3) {
+ $loggedFailures++;
+ $this->logError('Failed to update status message.', $e);
+ }
+ }
+ }
+
+ $this->api->sleep(self::PROGRESS_UPDATE_INTERVAL);
+ }
+ });
+ }
+
+ private function editStatusMessage($chatId, ?int $statusId, string $text, ?array $replyMarkup = null): void
+ {
+ if (!$chatId || !$statusId) {
+ return;
+ }
+
+ try {
+ $payload = [
+ 'peer' => $chatId,
+ 'id' => $statusId,
+ 'message' => $text,
+ 'parse_mode' => 'HTML',
+ ];
+
+ if ($replyMarkup !== null) {
+ $payload['reply_markup'] = $replyMarkup;
+ }
+
+ $this->api->messages->editMessage($payload);
+ } catch (RPCErrorException $e) {
+ if (
+ ($e->rpc ?? '') === 'MESSAGE_NOT_MODIFIED'
+ || str_contains($e->getMessage(), 'MESSAGE_NOT_MODIFIED')
+ ) {
+ return;
+ }
+
+ $this->logError('Failed to edit final status message.', $e);
+ } catch (Throwable $e) {
+ $this->logError('Failed to edit final status message.', $e);
+ }
+ }
+
+ private function writeLastBroadcastData(string $text): void
+ {
+ try {
+ $this->ensureDirectory(self::getDataDir());
+ file_put_contents(self::getDataDir() . '/LastBrodDATA.txt', $text, LOCK_EX);
+ } catch (Throwable $e) {
+ $this->logError('Failed to save last broadcast data.', $e);
+ }
+ }
+
+ private function readLastBroadcastMessageId(string $peer): int
+ {
+ $file = $this->peerDataPath($peer, 'lastBroadcast.txt');
+
+ if (!is_file($file)) {
+ return 0;
+ }
+
+ return (int) trim((string) file_get_contents($file));
+ }
+
+ private function readBroadcastLastMessageId(string $broadcastId, string $peer): int
+ {
+ $metadata = $this->loadBroadcastMetadata($broadcastId);
+ $peerData = $metadata['peers'][$peer] ?? null;
+
+ if (!is_array($peerData)) {
+ return 0;
+ }
+
+ $lastMessageId = (int) ($peerData['lastMessageId'] ?? 0);
+ if ($lastMessageId > 0) {
+ return $lastMessageId;
+ }
+
+ $messageIds = array_values(array_filter(
+ array_map('intval', $peerData['messageIds'] ?? []),
+ static fn (int $id): bool => $id > 0
+ ));
+
+ return $messageIds ? (int) end($messageIds) : 0;
+ }
+
+ private function broadcastMetadataPeers(string $broadcastId): array
+ {
+ $metadata = $this->loadBroadcastMetadata($broadcastId);
+
+ if (!isset($metadata['peers']) || !is_array($metadata['peers'])) {
+ return [];
+ }
+
+ return array_values(array_map('strval', array_keys($metadata['peers'])));
+ }
+
+ private function markBroadcastPeerMessageDeleted(string $broadcastId, string $peer, int $messageId): void
+ {
+ $metadata = $this->loadBroadcastMetadata($broadcastId);
+ $peerData = $metadata['peers'][$peer] ?? null;
+
+ if (!is_array($peerData)) {
+ return;
+ }
+
+ $messageIds = array_values(array_filter(
+ array_map('intval', $peerData['messageIds'] ?? []),
+ static fn (int $id): bool => $id > 0 && $id !== $messageId
+ ));
+
+ $metadata['peers'][$peer]['messageIds'] = $messageIds;
+ $metadata['peers'][$peer]['lastMessageId'] = $messageIds ? end($messageIds) : null;
+ $metadata['peers'][$peer]['status'] = $messageIds ? 'partial' : 'deleted';
+
+ $this->saveBroadcastMetadata($broadcastId, $metadata);
+ }
+
+ private function peerDataPath(string $peer, string $file): string
+ {
+ return self::getDataDir() . '/' . $peer . '/' . $file;
+ }
+
+ private function broadcastMetadataPath(string $broadcastId): string
+ {
+ return self::getDataDir() . '/broadcasts/' . $broadcastId . '.json';
+ }
+
+ private function scheduledBroadcastsPath(): string
+ {
+ return self::getDataDir() . '/scheduled-broadcasts.json';
+ }
+
+ private function selfDestructJobsPath(): string
+ {
+ return self::getDataDir() . '/self-destruct-jobs.json';
+ }
+
+ private function loadScheduledBroadcasts(): array
+ {
+ return $this->readJsonFile($this->scheduledBroadcastsPath(), []);
+ }
+
+ private function saveScheduledBroadcasts(array $jobs): void
+ {
+ $this->writeJsonFileAtomic($this->scheduledBroadcastsPath(), $jobs);
+ }
+
+ private function loadSelfDestructJobs(): array
+ {
+ return $this->readJsonFile($this->selfDestructJobsPath(), []);
+ }
+
+ private function saveSelfDestructJobs(array $jobs): void
+ {
+ $this->writeJsonFileAtomic($this->selfDestructJobsPath(), $jobs);
+ }
+
+ private function validateSelfDestructHours(?int $hours): void
+ {
+ if ($hours === null) {
+ return;
+ }
+
+ if ($hours < 0 || $hours > 48) {
+ throw new InvalidArgumentException('selfDestructHours must be null or an integer between 0 and 48.');
+ }
+ }
+
+ private function normalizeOptionalId(?string $id): ?string
+ {
+ if ($id === null) {
+ return null;
+ }
+
+ $id = trim($id);
+
+ return $id === '' ? null : $id;
+ }
+
+ private function assertJsonEncodable(mixed $value, string $name): void
+ {
+ try {
+ json_encode($value, JSON_THROW_ON_ERROR);
+ } catch (JsonException $e) {
+ throw new InvalidArgumentException($name . ' must be JSON encodable: ' . $e->getMessage(), 0, $e);
+ }
+ }
+
+ private function createId(string $prefix = ''): string
+ {
+ $id = bin2hex(random_bytes(8));
+
+ return $prefix === '' ? $id : $prefix . '_' . $id;
+ }
+
+ private function ensureDirectory(string $dir): void
+ {
+ if (is_dir($dir)) {
+ return;
+ }
+
+ if (!mkdir($dir, 0777, true) && !is_dir($dir)) {
+ throw new RuntimeException('Unable to create directory: ' . $dir);
+ }
+ }
+
+ private function deleteFile(string $file): void
+ {
+ try {
+ if (is_file($file)) {
+ @unlink($file);
+ }
+ } catch (Throwable $e) {
+ $this->logError('Failed to delete file.', $e, ['file' => $file]);
+ }
+ }
+}
diff --git a/patches/broadcastmanager/dev3-to-transport-hooks.patch b/patches/broadcastmanager/dev3-to-transport-hooks.patch
new file mode 100644
index 0000000..36f040d
--- /dev/null
+++ b/patches/broadcastmanager/dev3-to-transport-hooks.patch
@@ -0,0 +1,620 @@
+--- a/src/BroadcastManager.php
++++ b/src/BroadcastManager.php
+@@ -61,6 +61,11 @@ class BroadcastManager
+ 'MESSAGE_ID_INVALID',
+ ];
+-
++
++ private ?\Amp\CancelledException $runtimeGuardFailure = null;
++ private ?\Closure $runtimeAdmission = null;
++ private ?\Closure $runtimeGuard = null;
++ private ?\Amp\Cancellation $runtimeCancellation = null;
++ private ?\Closure $messageTransport = null;
+ private ?\Closure $statusFormatter = null;
+ private ?\Closure $statusControlsFormatter = null;
+ private API $api;
+@@ -73,6 +78,110 @@ class BroadcastManager
+ $this->api = $api;
+ }
+-
++
++ /** Optional local lifecycle policy. Disabled hooks preserve upstream API behavior. */
++ public function setRuntimeGuard(?callable $guard, ?\Amp\Cancellation $cancellation = null): void
++ {
++ $this->runtimeGuard = $guard === null ? null : \Closure::fromCallable($guard);
++ $this->runtimeCancellation = $cancellation;
++ $this->runtimeGuardFailure = null;
++ }
++
++ /** Optional application-wide admission. Upstream callers run immediately. */
++ public function setRuntimeAdmission(?callable $admission): void
++ {
++ $this->runtimeAdmission = $admission === null ? null : \Closure::fromCallable($admission);
++ }
++
++ private function withAdmission(callable $work): mixed
++ {
++ return $this->runtimeAdmission === null ? $work() : ($this->runtimeAdmission)($work);
++ }
++
++ /** Keep recent completed controls without retaining unbounded runtime state. */
++ private function pruneCompletedRuntimeStates(): void
++ {
++ if ($this->runtimeGuard === null && $this->runtimeAdmission === null) {
++ return;
++ }
++ $kept = 0;
++ foreach (array_reverse(array_keys(self::$sharedBroadcastState)) as $id) {
++ $state = self::$sharedBroadcastState[$id];
++ if (empty($state['done']) || !empty($state['inFlight']) || isset($state['runtimeWorkers'])) {
++ continue;
++ }
++ if (++$kept > 128) {
++ unset(self::$sharedBroadcastState[$id], $this->currentBroadcastState[$id]);
++ }
++ }
++ $kept = 0;
++ foreach (array_reverse(array_keys($this->currentBroadcastState)) as $id) {
++ $state = $this->currentBroadcastState[$id];
++ if (!empty($state['done']) && empty($state['inFlight']) && !isset($state['runtimeWorkers']) && ++$kept > 128) {
++ unset($this->currentBroadcastState[$id]);
++ }
++ }
++ }
++ private function checkRuntime(): void
++ {
++ $this->runtimeCancellation?->throwIfRequested();
++ if ($this->runtimeGuard !== null && ($this->runtimeGuard)() !== true) {
++ $this->runtimeGuardFailure ??= new \Amp\CancelledException(new RuntimeException('Broadcast runtime authorization or shutdown guard rejected work.'));
++ throw $this->runtimeGuardFailure;
++ }
++ }
++
++ private function runtimeSleep(float $seconds): void
++ {
++ if ($this->runtimeGuard === null && $this->runtimeCancellation === null) {
++ $this->api->sleep($seconds);
++ return;
++ }
++ $this->checkRuntime();
++ \Amp\delay($seconds, cancellation: $this->runtimeCancellation);
++ $this->checkRuntime();
++ }
++
++ private function workerSleep(float $seconds, array &$state): void
++ {
++ if ($this->runtimeGuard === null && $this->runtimeCancellation === null) {
++ $this->api->sleep($seconds);
++ return;
++ }
++ $this->checkRuntime();
++ $signals = [$state['runtimeWake']->getCancellation()];
++ if ($this->runtimeCancellation !== null) {
++ $signals[] = $this->runtimeCancellation;
++ }
++ \Amp\delay($seconds, cancellation: new \Amp\CompositeCancellation(...$signals));
++ $this->checkRuntime();
++ }
++ private function callNativeMessage(string $method, array $parameters): mixed
++ {
++ $this->checkRuntime();
++ if ($this->runtimeGuard !== null || $this->runtimeCancellation !== null) {
++ $parameters['floodWaitLimit'] = 0;
++ $parameters['cancellation'] = $this->runtimeCancellation;
++ }
++ return $this->api->messages->{$method}($parameters);
++ }
++
++ /** Every launched child is retained and drained by waitForCompletion. */
++ private function launchWorker(array &$state, callable $work): void
++ {
++ $state['runtimeWake'] ??= new \Amp\DeferredCancellation();
++ $state['runtimeWakeReason'] ??= new RuntimeException('Completed broadcast worker scope.');
++ $state['runtimeWorkers'][] = \Amp\async(static function () use (&$state, $work): void {
++ try {
++ $work();
++ } catch (Throwable $error) {
++ if ($error instanceof \Amp\CancelledException && $error->getPrevious() === $state['runtimeWakeReason']) {
++ return;
++ }
++ $state['cancel'] = true;
++ throw $error;
++ }
++ });
++ }
+ /** Optional per-instance presentation hook; default upstream output is unchanged. */
+ public function setStatusFormatter(?callable $formatter): void
+ {
+@@ -97,6 +206,25 @@ class BroadcastManager
+ }
+ return $text;
+ }
++ /** Optional per-instance message adapter; null preserves exact native dispatch. */
++ public function setMessageTransport(?callable $transport): void
++ {
++ $this->messageTransport = $transport === null ? null : \Closure::fromCallable($transport);
++ }
++
++ private function callMessage(string $method, array $parameters): mixed
++ {
++ $this->checkRuntime();
++ if ($this->runtimeCancellation !== null) {
++ $parameters['cancellation'] = $this->runtimeCancellation;
++ }
++ if (!in_array($method, ['sendMessage', 'sendMedia', 'sendMultiMedia', 'editMessage'], true)) {
++ throw new InvalidArgumentException('Unsupported broadcast message transport method.');
++ }
++ return $this->messageTransport === null
++ ? $this->callNativeMessage($method, $parameters)
++ : ($this->messageTransport)($method, $parameters);
++ }
+ /**
+ * Set data dir.
+ */
+@@ -129,6 +257,17 @@ class BroadcastManager
+ bool $pin = false,
+ int $concurrency = self::DEFAULT_CONCURRENCY,
+ ?int $selfDestructHours = null
++ ): string {
++ return $this->withAdmission(fn(): string => $this->doBroadcastWithProgress($allUsers, $messages, $chatId, $pin, $concurrency, $selfDestructHours));
++ }
++
++ private function doBroadcastWithProgress(
++ array $allUsers,
++ array $messages,
++ $chatId = null,
++ bool $pin = false,
++ int $concurrency = self::DEFAULT_CONCURRENCY,
++ ?int $selfDestructHours = null
+ ): string {
+ $this->validateSelfDestructHours($selfDestructHours);
+-
++
+@@ -160,7 +299,7 @@ class BroadcastManager
+ $lastMessageId = $messageIds ? end($messageIds) : null;
+-
++
+ if ($pin && $lastMessageId) {
+- $this->api->messages->updatePinnedMessage([
++ $this->callNativeMessage('updatePinnedMessage', [
+ 'peer' => $peer,
+ 'id' => $lastMessageId,
+ 'unpin' => false,
+@@ -227,6 +366,19 @@ class BroadcastManager
+ int $concurrency = self::DEFAULT_CONCURRENCY,
+ string $parseMode = 'HTML',
+ ?string $broadcastId = null
++ ): string {
++ return $this->withAdmission(fn(): string => $this->doEditLastBroadcastForAll($allUsers, $newText, $chatId, $buttons, $media, $concurrency, $parseMode, $broadcastId));
++ }
++
++ private function doEditLastBroadcastForAll(
++ array $allUsers,
++ string $newText,
++ $chatId = null,
++ ?array $buttons = null,
++ $media = null,
++ int $concurrency = self::DEFAULT_CONCURRENCY,
++ string $parseMode = 'HTML',
++ ?string $broadcastId = null
+ ): string {
+ $concurrency = $this->clampConcurrency($concurrency);
+ $targetBroadcastId = $this->normalizeOptionalId($broadcastId);
+@@ -274,7 +426,7 @@ class BroadcastManager
+ $payload['media'] = $media;
+ }
+-
++
+- $this->api->messages->editMessage($payload);
++ $this->callMessage('editMessage', $payload);
+ $state['edited']++;
+ },
+ self::EDIT_HARD_FAIL_RPCS,
+@@ -352,6 +504,14 @@ class BroadcastManager
+ $now = time();
+-
++
+ foreach ($jobs as $scheduleId => $job) {
++ try {
++ $this->checkRuntime();
++ } catch (\Amp\CancelledException $error) {
++ if ($error !== $this->runtimeGuardFailure) {
++ throw $error;
++ }
++ break;
++ }
+ if (($job['status'] ?? null) !== 'scheduled') {
+ continue;
+ }
+@@ -414,6 +574,15 @@ class BroadcastManager
+ $chatId = null,
+ int $concurrency = self::DEFAULT_CONCURRENCY,
+ ?string $broadcastId = null
++ ): string {
++ return $this->withAdmission(fn(): string => $this->doDeleteLastBroadcastForAll($allUsers, $chatId, $concurrency, $broadcastId));
++ }
++
++ private function doDeleteLastBroadcastForAll(
++ array $allUsers,
++ $chatId = null,
++ int $concurrency = self::DEFAULT_CONCURRENCY,
++ ?string $broadcastId = null
+ ): string {
+ $concurrency = $this->clampConcurrency($concurrency);
+ $targetBroadcastId = $this->normalizeOptionalId($broadcastId);
+@@ -445,7 +614,7 @@ class BroadcastManager
+ return;
+ }
+-
++
+- $this->api->messages->deleteMessages([
++ $this->callNativeMessage('deleteMessages', [
+ 'peer' => $peer,
+ 'id' => [$lastMessageId],
+ 'revoke' => true,
+@@ -479,6 +648,14 @@ class BroadcastManager
+ array $allUsers,
+ $chatId = null,
+ int $concurrency = self::DEFAULT_CONCURRENCY
++ ): string {
++ return $this->withAdmission(fn(): string => $this->doDeleteAllBroadcastsForAll($allUsers, $chatId, $concurrency));
++ }
++
++ private function doDeleteAllBroadcastsForAll(
++ array $allUsers,
++ $chatId = null,
++ int $concurrency = self::DEFAULT_CONCURRENCY
+ ): string {
+ $concurrency = $this->clampConcurrency($concurrency);
+ $broadcastId = $this->createId();
+@@ -516,7 +693,7 @@ class BroadcastManager
+-
++
+ foreach ($msgIds as $messageId) {
+ try {
+- $this->api->messages->deleteMessages([
++ $this->callNativeMessage('deleteMessages', [
+ 'peer' => $peer,
+ 'id' => [$messageId],
+ 'revoke' => true,
+@@ -568,6 +745,14 @@ class BroadcastManager
+ array $allUsers,
+ $chatId = null,
+ int $concurrency = self::DEFAULT_CONCURRENCY
++ ): string {
++ return $this->withAdmission(fn(): string => $this->doUnpinAllMessagesForAll($allUsers, $chatId, $concurrency));
++ }
++
++ private function doUnpinAllMessagesForAll(
++ array $allUsers,
++ $chatId = null,
++ int $concurrency = self::DEFAULT_CONCURRENCY
+ ): string {
+ $concurrency = $this->clampConcurrency($concurrency);
+ $broadcastId = $this->createId();
+@@ -583,7 +768,7 @@ class BroadcastManager
+ $state,
+ $concurrency,
+ function (array $job, array &$state): void {
+- $this->api->messages->unpinAllMessages([
++ $this->callNativeMessage('unpinAllMessages', [
+ 'peer' => (string) $job['peer'],
+ ]);
+-
++
+@@ -607,6 +792,14 @@ class BroadcastManager
+ $now = time();
+-
++
+ foreach ($jobs as $jobId => $job) {
++ try {
++ $this->checkRuntime();
++ } catch (\Amp\CancelledException $error) {
++ if ($error !== $this->runtimeGuardFailure) {
++ throw $error;
++ }
++ break;
++ }
+ if (($job['status'] ?? null) !== 'scheduled') {
+ continue;
+ }
+@@ -921,6 +1114,7 @@ class BroadcastManager
+ private function registerCurrentState(string $id, array &$state): void
+ {
+ $this->currentBroadcastState[$id] =& $state;
++ $this->pruneCompletedRuntimeStates();
+ self::$sharedBroadcastState[$id] =& $state;
+ }
+-
++
+@@ -952,15 +1146,16 @@ class BroadcastManager
+ $concurrency = $this->clampConcurrency($concurrency);
+-
++
+ for ($i = 0; $i < $concurrency; $i++) {
+- \Amp\async(function () use (&$state, $handler, $hardFailRpcs, $rpcHandler, $retryThrowable): void {
++ $this->launchWorker($state, function () use (&$state, $handler, $hardFailRpcs, $rpcHandler, $retryThrowable): void {
+ while (!$state['cancel'] && !$state['done']) {
++ $this->checkRuntime();
+ if ($state['queue']->isEmpty()) {
+- $this->api->sleep(self::WORKER_IDLE_SLEEP);
++ $this->workerSleep(self::WORKER_IDLE_SLEEP, $state);
+ continue;
+ }
+-
++
+ if ($state['paused']) {
+- $this->api->sleep(self::WORKER_PAUSED_SLEEP);
++ $this->workerSleep(self::WORKER_PAUSED_SLEEP, $state);
+ continue;
+ }
+-
++
+@@ -968,12 +1163,12 @@ class BroadcastManager
+-
++
+ if (($job['availableAt'] ?? 0) > microtime(true)) {
+ $state['queue']->enqueue($job);
+- $this->api->sleep(self::WORKER_RETRY_SLEEP);
++ $this->workerSleep(self::WORKER_RETRY_SLEEP, $state);
+ continue;
+ }
+-
++
+ while ($state['paused'] && !$state['cancel'] && !$state['done']) {
+- $this->api->sleep(self::WORKER_PAUSED_SLEEP);
++ $this->workerSleep(self::WORKER_PAUSED_SLEEP, $state);
+ }
+-
++
+ if ($state['cancel'] || $state['done']) {
+@@ -1003,6 +1198,9 @@ class BroadcastManager
+ 'peer' => $peer,
+ ]);
+ $this->retryOrFail($job, $state);
++ } catch (\Amp\CancelledException $e) {
++ unset($state['inFlight'][$peer]);
++ throw $e;
+ } catch (Throwable $e) {
+ unset($state['inFlight'][$peer]);
+ $this->logError('Unexpected error during broadcast job.', $e, [
+@@ -1017,7 +1215,7 @@ class BroadcastManager
+ }
+ }
+-
++
+- $this->api->sleep(self::WORKER_AFTER_JOB_SLEEP);
++ $this->workerSleep(self::WORKER_AFTER_JOB_SLEEP, $state);
+ }
+ });
+ }
+@@ -1025,21 +1223,44 @@ class BroadcastManager
+-
++
+ private function waitForCompletion(array &$state): void
+ {
+- while (true) {
+- if ($state['queue']->isEmpty() && empty($state['inFlight'])) {
+- break;
++ $failure = null;
++ try {
++ while (true) {
++ $this->checkRuntime();
++ if (($state['queue']->isEmpty() || $state['cancel']) && empty($state['inFlight'])) {
++ break;
++ }
++ $this->runtimeSleep(1);
+ }
+-
+- if ($state['cancel'] && empty($state['inFlight'])) {
+- break;
++ } catch (Throwable $error) {
++ $state['cancel'] = true;
++ $failure = $error;
++ } finally {
++ $state['done'] = true;
++ if (isset($state['runtimeWake'])) {
++ $state['runtimeWake']->cancel($state['runtimeWakeReason']);
++ }
++ // Do not cancel the join itself: in-flight operations must finish unwinding.
++ foreach ($state['runtimeWorkers'] ?? [] as $worker) {
++ try {
++ $worker->await();
++ } catch (Throwable $error) {
++ $state['cancel'] = true;
++ if ($failure === null || $failure === $this->runtimeGuardFailure) {
++ $failure = $error;
++ }
++ }
++ }
++ unset($state['runtimeWorkers'], $state['runtimeWake'], $state['runtimeWakeReason']);
++ $this->pruneCompletedRuntimeStates();
++ if ($state['cancel']) {
++ $state['queue'] = new SplQueue();
+ }
+-
+- $this->api->sleep(1);
+ }
+-
+- $state['done'] = true;
++ if ($failure !== null && $failure !== $this->runtimeGuardFailure) {
++ throw $failure;
++ }
+ }
+-
+ private function handleRpcRetry(RPCErrorException $e, array &$job, array &$state, array $hardFailRpcs): bool
+ {
+ if ($this->isHardFailRpc((string) ($e->rpc ?? ''), $hardFailRpcs)) {
+@@ -1122,7 +1343,7 @@ class BroadcastManager
+ ];
+ }
+-
++
+- foreach ($this->api->messages->sendMultiMedia(['peer' => $peer, 'multi_media' => $multi]) as $update) {
++ foreach ($this->callMessage('sendMultiMedia', ['peer' => $peer, 'multi_media' => $multi]) as $update) {
+ $messageId = (int) $this->api->extractMessageId($update);
+-
++
+ if ($messageId > 0) {
+@@ -1130,7 +1351,7 @@ class BroadcastManager
+ }
+ }
+-
++
+- $this->api->sleep(self::SEND_CHUNK_SLEEP);
++ $this->runtimeSleep(self::SEND_CHUNK_SLEEP);
+ }
+-
++
+ return $messageIds;
+@@ -1147,14 +1368,14 @@ class BroadcastManager
+ $payload['reply_markup'] = $message['buttons'];
+ }
+-
++
+- $result = $this->api->messages->{$method}($payload);
++ $result = $this->callMessage($method, $payload);
+ $messageId = (int) $this->api->extractMessageId($result);
+-
++
+ if ($messageId > 0) {
+ $messageIds[] = $messageId;
+ }
+-
++
+- $this->api->sleep(self::SEND_MESSAGE_SLEEP);
++ $this->runtimeSleep(self::SEND_MESSAGE_SLEEP);
+ }
+-
++
+ return $messageIds;
+@@ -1383,6 +1604,13 @@ class BroadcastManager
+ 'status' => $jobs[$scheduleId]['status'],
+ 'broadcastId' => $broadcastId,
+ ];
++ } catch (\Amp\CancelledException $e) {
++ $jobs = $this->loadScheduledBroadcasts();
++ $jobs[$scheduleId]['status'] = 'cancelled';
++ $jobs[$scheduleId]['finishedAt'] = time();
++ $jobs[$scheduleId]['error'] = 'Runtime cancelled; delivery outcome may be partial.';
++ $this->saveScheduledBroadcasts($jobs);
++ throw $e;
+ } catch (Throwable $e) {
+ $jobs = $this->loadScheduledBroadcasts();
+ $jobs[$scheduleId]['status'] = 'failed';
+@@ -1440,8 +1668,11 @@ class BroadcastManager
+ return $jobId;
+ }
+-
++
+- private function runSelfDestructJob(string $jobId): array
+- {
++ private function runSelfDestructJob(string $jobId): array {
++ return $this->withAdmission(fn(): array => $this->doRunSelfDestructJob($jobId));
++ }
++
++ private function doRunSelfDestructJob(string $jobId): array {
+ $jobs = $this->loadSelfDestructJobs();
+-
++
+ if (!isset($jobs[$jobId])) {
+@@ -1513,7 +1744,7 @@ class BroadcastManager
+ return;
+ }
+-
++
+- $this->api->messages->deleteMessages([
++ $this->callNativeMessage('deleteMessages', [
+ 'peer' => (string) $peerJob['peer'],
+ 'id' => $messageIds,
+ 'revoke' => true,
+@@ -1545,6 +1776,13 @@ class BroadcastManager
+ 'status' => $jobs[$jobId]['status'],
+ 'stats' => $stats,
+ ];
++ } catch (\Amp\CancelledException $e) {
++ $jobs = $this->loadSelfDestructJobs();
++ $jobs[$jobId]['status'] = 'cancelled';
++ $jobs[$jobId]['finishedAt'] = time();
++ $jobs[$jobId]['error'] = 'Runtime cancelled; delivery outcome may be partial.';
++ $this->saveSelfDestructJobs($jobs);
++ throw $e;
+ } catch (Throwable $e) {
+ $jobs = $this->loadSelfDestructJobs();
+ $jobs[$jobId]['status'] = 'failed';
+@@ -1724,7 +1962,7 @@ class BroadcastManager
+ $payload['reply_markup'] = $replyMarkup;
+ }
+-
++
+- $status = $this->api->messages->sendMessage($payload);
++ $status = $this->callMessage('sendMessage', $payload);
+-
++
+ return (int) $this->api->extractMessageId($status);
+ } catch (Throwable $e) {
+@@ -1739,11 +1977,12 @@ class BroadcastManager
+ return;
+ }
+-
++
+- \Amp\async(function () use ($chatId, $statusId, &$state, $title): void {
++ $this->launchWorker($state, function () use ($chatId, $statusId, &$state, $title): void {
+ $last = '';
+ $loggedFailures = 0;
+-
++
+ while (!$state['done']) {
++ $this->checkRuntime();
+ $text = $this->buildProgressText($state, $title);
+ $replyMarkup = $this->buildStatusControls($state);
+ $fingerprint = $text . "\n" . json_encode($replyMarkup);
+@@ -1761,7 +2000,7 @@ class BroadcastManager
+ $payload['reply_markup'] = $replyMarkup;
+ }
+-
++
+- $this->api->messages->editMessage($payload);
++ $this->callMessage('editMessage', $payload);
+ $last = $fingerprint;
+ } catch (RPCErrorException $e) {
+ if (
+@@ -1769,7 +2008,7 @@ class BroadcastManager
+ || str_contains($e->getMessage(), 'MESSAGE_NOT_MODIFIED')
+ ) {
+ $last = $fingerprint;
+- $this->api->sleep(self::PROGRESS_UPDATE_INTERVAL);
++ $this->workerSleep(self::PROGRESS_UPDATE_INTERVAL, $state);
+ continue;
+ }
+-
++
+@@ -1777,6 +2016,8 @@ class BroadcastManager
+ $loggedFailures++;
+ $this->logError('Failed to update status message.', $e);
+ }
++ } catch (\Amp\CancelledException $e) {
++ throw $e;
+ } catch (Throwable $e) {
+ if ($loggedFailures < 3) {
+ $loggedFailures++;
+@@ -1785,7 +2026,7 @@ class BroadcastManager
+ }
+ }
+-
++
+- $this->api->sleep(self::PROGRESS_UPDATE_INTERVAL);
++ $this->workerSleep(self::PROGRESS_UPDATE_INTERVAL, $state);
+ }
+ });
+ }
+@@ -1808,7 +2049,7 @@ class BroadcastManager
+ $payload['reply_markup'] = $replyMarkup;
+ }
+-
++
+- $this->api->messages->editMessage($payload);
++ $this->callMessage('editMessage', $payload);
+ } catch (RPCErrorException $e) {
+ if (
+ ($e->rpc ?? '') === 'MESSAGE_NOT_MODIFIED'
diff --git a/patches/broadcastmanager/dev8-to-runtime-lifecycle.patch b/patches/broadcastmanager/dev8-to-runtime-lifecycle.patch
new file mode 100644
index 0000000..ee3756f
--- /dev/null
+++ b/patches/broadcastmanager/dev8-to-runtime-lifecycle.patch
@@ -0,0 +1,549 @@
+--- a/src/BroadcastManager.php
++++ b/src/BroadcastManager.php
+@@ -61,6 +61,10 @@ class BroadcastManager
+ 'MESSAGE_ID_INVALID',
+ ];
+-
++
++ private ?\Amp\CancelledException $runtimeGuardFailure = null;
++ private ?\Closure $runtimeAdmission = null;
++ private ?\Closure $runtimeGuard = null;
++ private ?\Amp\Cancellation $runtimeCancellation = null;
+ private ?\Closure $messageTransport = null;
+ private ?\Closure $statusFormatter = null;
+ private ?\Closure $statusControlsFormatter = null;
+@@ -74,6 +78,110 @@ class BroadcastManager
+ $this->api = $api;
+ }
+-
++
++ /** Optional local lifecycle policy. Disabled hooks preserve upstream API behavior. */
++ public function setRuntimeGuard(?callable $guard, ?\Amp\Cancellation $cancellation = null): void
++ {
++ $this->runtimeGuard = $guard === null ? null : \Closure::fromCallable($guard);
++ $this->runtimeCancellation = $cancellation;
++ $this->runtimeGuardFailure = null;
++ }
++
++ /** Optional application-wide admission. Upstream callers run immediately. */
++ public function setRuntimeAdmission(?callable $admission): void
++ {
++ $this->runtimeAdmission = $admission === null ? null : \Closure::fromCallable($admission);
++ }
++
++ private function withAdmission(callable $work): mixed
++ {
++ return $this->runtimeAdmission === null ? $work() : ($this->runtimeAdmission)($work);
++ }
++
++ /** Keep recent completed controls without retaining unbounded runtime state. */
++ private function pruneCompletedRuntimeStates(): void
++ {
++ if ($this->runtimeGuard === null && $this->runtimeAdmission === null) {
++ return;
++ }
++ $kept = 0;
++ foreach (array_reverse(array_keys(self::$sharedBroadcastState)) as $id) {
++ $state = self::$sharedBroadcastState[$id];
++ if (empty($state['done']) || !empty($state['inFlight']) || isset($state['runtimeWorkers'])) {
++ continue;
++ }
++ if (++$kept > 128) {
++ unset(self::$sharedBroadcastState[$id], $this->currentBroadcastState[$id]);
++ }
++ }
++ $kept = 0;
++ foreach (array_reverse(array_keys($this->currentBroadcastState)) as $id) {
++ $state = $this->currentBroadcastState[$id];
++ if (!empty($state['done']) && empty($state['inFlight']) && !isset($state['runtimeWorkers']) && ++$kept > 128) {
++ unset($this->currentBroadcastState[$id]);
++ }
++ }
++ }
++ private function checkRuntime(): void
++ {
++ $this->runtimeCancellation?->throwIfRequested();
++ if ($this->runtimeGuard !== null && ($this->runtimeGuard)() !== true) {
++ $this->runtimeGuardFailure ??= new \Amp\CancelledException(new RuntimeException('Broadcast runtime authorization or shutdown guard rejected work.'));
++ throw $this->runtimeGuardFailure;
++ }
++ }
++
++ private function runtimeSleep(float $seconds): void
++ {
++ if ($this->runtimeGuard === null && $this->runtimeCancellation === null) {
++ $this->api->sleep($seconds);
++ return;
++ }
++ $this->checkRuntime();
++ \Amp\delay($seconds, cancellation: $this->runtimeCancellation);
++ $this->checkRuntime();
++ }
++
++ private function workerSleep(float $seconds, array &$state): void
++ {
++ if ($this->runtimeGuard === null && $this->runtimeCancellation === null) {
++ $this->api->sleep($seconds);
++ return;
++ }
++ $this->checkRuntime();
++ $signals = [$state['runtimeWake']->getCancellation()];
++ if ($this->runtimeCancellation !== null) {
++ $signals[] = $this->runtimeCancellation;
++ }
++ \Amp\delay($seconds, cancellation: new \Amp\CompositeCancellation(...$signals));
++ $this->checkRuntime();
++ }
++ private function callNativeMessage(string $method, array $parameters): mixed
++ {
++ $this->checkRuntime();
++ if ($this->runtimeGuard !== null || $this->runtimeCancellation !== null) {
++ $parameters['floodWaitLimit'] = 0;
++ $parameters['cancellation'] = $this->runtimeCancellation;
++ }
++ return $this->api->messages->{$method}($parameters);
++ }
++
++ /** Every launched child is retained and drained by waitForCompletion. */
++ private function launchWorker(array &$state, callable $work): void
++ {
++ $state['runtimeWake'] ??= new \Amp\DeferredCancellation();
++ $state['runtimeWakeReason'] ??= new RuntimeException('Completed broadcast worker scope.');
++ $state['runtimeWorkers'][] = \Amp\async(static function () use (&$state, $work): void {
++ try {
++ $work();
++ } catch (Throwable $error) {
++ if ($error instanceof \Amp\CancelledException && $error->getPrevious() === $state['runtimeWakeReason']) {
++ return;
++ }
++ $state['cancel'] = true;
++ throw $error;
++ }
++ });
++ }
+ /** Optional per-instance presentation hook; default upstream output is unchanged. */
+ public function setStatusFormatter(?callable $formatter): void
+ {
+@@ -106,11 +214,15 @@ class BroadcastManager
+-
++
+ private function callMessage(string $method, array $parameters): mixed
+ {
++ $this->checkRuntime();
++ if ($this->runtimeCancellation !== null) {
++ $parameters['cancellation'] = $this->runtimeCancellation;
++ }
+ if (!in_array($method, ['sendMessage', 'sendMedia', 'sendMultiMedia', 'editMessage'], true)) {
+ throw new InvalidArgumentException('Unsupported broadcast message transport method.');
+ }
+ return $this->messageTransport === null
+- ? $this->api->messages->{$method}($parameters)
++ ? $this->callNativeMessage($method, $parameters)
+ : ($this->messageTransport)($method, $parameters);
+ }
+ /**
+@@ -145,6 +257,17 @@ class BroadcastManager
+ bool $pin = false,
+ int $concurrency = self::DEFAULT_CONCURRENCY,
+ ?int $selfDestructHours = null
++ ): string {
++ return $this->withAdmission(fn(): string => $this->doBroadcastWithProgress($allUsers, $messages, $chatId, $pin, $concurrency, $selfDestructHours));
++ }
++
++ private function doBroadcastWithProgress(
++ array $allUsers,
++ array $messages,
++ $chatId = null,
++ bool $pin = false,
++ int $concurrency = self::DEFAULT_CONCURRENCY,
++ ?int $selfDestructHours = null
+ ): string {
+ $this->validateSelfDestructHours($selfDestructHours);
+-
++
+@@ -176,7 +299,7 @@ class BroadcastManager
+ $lastMessageId = $messageIds ? end($messageIds) : null;
+-
++
+ if ($pin && $lastMessageId) {
+- $this->api->messages->updatePinnedMessage([
++ $this->callNativeMessage('updatePinnedMessage', [
+ 'peer' => $peer,
+ 'id' => $lastMessageId,
+ 'unpin' => false,
+@@ -243,6 +366,19 @@ class BroadcastManager
+ int $concurrency = self::DEFAULT_CONCURRENCY,
+ string $parseMode = 'HTML',
+ ?string $broadcastId = null
++ ): string {
++ return $this->withAdmission(fn(): string => $this->doEditLastBroadcastForAll($allUsers, $newText, $chatId, $buttons, $media, $concurrency, $parseMode, $broadcastId));
++ }
++
++ private function doEditLastBroadcastForAll(
++ array $allUsers,
++ string $newText,
++ $chatId = null,
++ ?array $buttons = null,
++ $media = null,
++ int $concurrency = self::DEFAULT_CONCURRENCY,
++ string $parseMode = 'HTML',
++ ?string $broadcastId = null
+ ): string {
+ $concurrency = $this->clampConcurrency($concurrency);
+ $targetBroadcastId = $this->normalizeOptionalId($broadcastId);
+@@ -368,6 +504,14 @@ class BroadcastManager
+ $now = time();
+-
++
+ foreach ($jobs as $scheduleId => $job) {
++ try {
++ $this->checkRuntime();
++ } catch (\Amp\CancelledException $error) {
++ if ($error !== $this->runtimeGuardFailure) {
++ throw $error;
++ }
++ break;
++ }
+ if (($job['status'] ?? null) !== 'scheduled') {
+ continue;
+ }
+@@ -430,6 +574,15 @@ class BroadcastManager
+ $chatId = null,
+ int $concurrency = self::DEFAULT_CONCURRENCY,
+ ?string $broadcastId = null
++ ): string {
++ return $this->withAdmission(fn(): string => $this->doDeleteLastBroadcastForAll($allUsers, $chatId, $concurrency, $broadcastId));
++ }
++
++ private function doDeleteLastBroadcastForAll(
++ array $allUsers,
++ $chatId = null,
++ int $concurrency = self::DEFAULT_CONCURRENCY,
++ ?string $broadcastId = null
+ ): string {
+ $concurrency = $this->clampConcurrency($concurrency);
+ $targetBroadcastId = $this->normalizeOptionalId($broadcastId);
+@@ -461,7 +614,7 @@ class BroadcastManager
+ return;
+ }
+-
++
+- $this->api->messages->deleteMessages([
++ $this->callNativeMessage('deleteMessages', [
+ 'peer' => $peer,
+ 'id' => [$lastMessageId],
+ 'revoke' => true,
+@@ -495,6 +648,14 @@ class BroadcastManager
+ array $allUsers,
+ $chatId = null,
+ int $concurrency = self::DEFAULT_CONCURRENCY
++ ): string {
++ return $this->withAdmission(fn(): string => $this->doDeleteAllBroadcastsForAll($allUsers, $chatId, $concurrency));
++ }
++
++ private function doDeleteAllBroadcastsForAll(
++ array $allUsers,
++ $chatId = null,
++ int $concurrency = self::DEFAULT_CONCURRENCY
+ ): string {
+ $concurrency = $this->clampConcurrency($concurrency);
+ $broadcastId = $this->createId();
+@@ -532,7 +693,7 @@ class BroadcastManager
+-
++
+ foreach ($msgIds as $messageId) {
+ try {
+- $this->api->messages->deleteMessages([
++ $this->callNativeMessage('deleteMessages', [
+ 'peer' => $peer,
+ 'id' => [$messageId],
+ 'revoke' => true,
+@@ -584,6 +745,14 @@ class BroadcastManager
+ array $allUsers,
+ $chatId = null,
+ int $concurrency = self::DEFAULT_CONCURRENCY
++ ): string {
++ return $this->withAdmission(fn(): string => $this->doUnpinAllMessagesForAll($allUsers, $chatId, $concurrency));
++ }
++
++ private function doUnpinAllMessagesForAll(
++ array $allUsers,
++ $chatId = null,
++ int $concurrency = self::DEFAULT_CONCURRENCY
+ ): string {
+ $concurrency = $this->clampConcurrency($concurrency);
+ $broadcastId = $this->createId();
+@@ -599,7 +768,7 @@ class BroadcastManager
+ $state,
+ $concurrency,
+ function (array $job, array &$state): void {
+- $this->api->messages->unpinAllMessages([
++ $this->callNativeMessage('unpinAllMessages', [
+ 'peer' => (string) $job['peer'],
+ ]);
+-
++
+@@ -623,6 +792,14 @@ class BroadcastManager
+ $now = time();
+-
++
+ foreach ($jobs as $jobId => $job) {
++ try {
++ $this->checkRuntime();
++ } catch (\Amp\CancelledException $error) {
++ if ($error !== $this->runtimeGuardFailure) {
++ throw $error;
++ }
++ break;
++ }
+ if (($job['status'] ?? null) !== 'scheduled') {
+ continue;
+ }
+@@ -937,6 +1114,7 @@ class BroadcastManager
+ private function registerCurrentState(string $id, array &$state): void
+ {
+ $this->currentBroadcastState[$id] =& $state;
++ $this->pruneCompletedRuntimeStates();
+ self::$sharedBroadcastState[$id] =& $state;
+ }
+-
++
+@@ -968,15 +1146,16 @@ class BroadcastManager
+ $concurrency = $this->clampConcurrency($concurrency);
+-
++
+ for ($i = 0; $i < $concurrency; $i++) {
+- \Amp\async(function () use (&$state, $handler, $hardFailRpcs, $rpcHandler, $retryThrowable): void {
++ $this->launchWorker($state, function () use (&$state, $handler, $hardFailRpcs, $rpcHandler, $retryThrowable): void {
+ while (!$state['cancel'] && !$state['done']) {
++ $this->checkRuntime();
+ if ($state['queue']->isEmpty()) {
+- $this->api->sleep(self::WORKER_IDLE_SLEEP);
++ $this->workerSleep(self::WORKER_IDLE_SLEEP, $state);
+ continue;
+ }
+-
++
+ if ($state['paused']) {
+- $this->api->sleep(self::WORKER_PAUSED_SLEEP);
++ $this->workerSleep(self::WORKER_PAUSED_SLEEP, $state);
+ continue;
+ }
+-
++
+@@ -984,12 +1163,12 @@ class BroadcastManager
+-
++
+ if (($job['availableAt'] ?? 0) > microtime(true)) {
+ $state['queue']->enqueue($job);
+- $this->api->sleep(self::WORKER_RETRY_SLEEP);
++ $this->workerSleep(self::WORKER_RETRY_SLEEP, $state);
+ continue;
+ }
+-
++
+ while ($state['paused'] && !$state['cancel'] && !$state['done']) {
+- $this->api->sleep(self::WORKER_PAUSED_SLEEP);
++ $this->workerSleep(self::WORKER_PAUSED_SLEEP, $state);
+ }
+-
++
+ if ($state['cancel'] || $state['done']) {
+@@ -1019,6 +1198,9 @@ class BroadcastManager
+ 'peer' => $peer,
+ ]);
+ $this->retryOrFail($job, $state);
++ } catch (\Amp\CancelledException $e) {
++ unset($state['inFlight'][$peer]);
++ throw $e;
+ } catch (Throwable $e) {
+ unset($state['inFlight'][$peer]);
+ $this->logError('Unexpected error during broadcast job.', $e, [
+@@ -1033,7 +1215,7 @@ class BroadcastManager
+ }
+ }
+-
++
+- $this->api->sleep(self::WORKER_AFTER_JOB_SLEEP);
++ $this->workerSleep(self::WORKER_AFTER_JOB_SLEEP, $state);
+ }
+ });
+ }
+@@ -1041,21 +1223,44 @@ class BroadcastManager
+-
++
+ private function waitForCompletion(array &$state): void
+ {
+- while (true) {
+- if ($state['queue']->isEmpty() && empty($state['inFlight'])) {
+- break;
++ $failure = null;
++ try {
++ while (true) {
++ $this->checkRuntime();
++ if (($state['queue']->isEmpty() || $state['cancel']) && empty($state['inFlight'])) {
++ break;
++ }
++ $this->runtimeSleep(1);
+ }
+-
+- if ($state['cancel'] && empty($state['inFlight'])) {
+- break;
++ } catch (Throwable $error) {
++ $state['cancel'] = true;
++ $failure = $error;
++ } finally {
++ $state['done'] = true;
++ if (isset($state['runtimeWake'])) {
++ $state['runtimeWake']->cancel($state['runtimeWakeReason']);
++ }
++ // Do not cancel the join itself: in-flight operations must finish unwinding.
++ foreach ($state['runtimeWorkers'] ?? [] as $worker) {
++ try {
++ $worker->await();
++ } catch (Throwable $error) {
++ $state['cancel'] = true;
++ if ($failure === null || $failure === $this->runtimeGuardFailure) {
++ $failure = $error;
++ }
++ }
++ }
++ unset($state['runtimeWorkers'], $state['runtimeWake'], $state['runtimeWakeReason']);
++ $this->pruneCompletedRuntimeStates();
++ if ($state['cancel']) {
++ $state['queue'] = new SplQueue();
+ }
+-
+- $this->api->sleep(1);
+ }
+-
+- $state['done'] = true;
++ if ($failure !== null && $failure !== $this->runtimeGuardFailure) {
++ throw $failure;
++ }
+ }
+-
+ private function handleRpcRetry(RPCErrorException $e, array &$job, array &$state, array $hardFailRpcs): bool
+ {
+ if ($this->isHardFailRpc((string) ($e->rpc ?? ''), $hardFailRpcs)) {
+@@ -1146,7 +1351,7 @@ class BroadcastManager
+ }
+ }
+-
++
+- $this->api->sleep(self::SEND_CHUNK_SLEEP);
++ $this->runtimeSleep(self::SEND_CHUNK_SLEEP);
+ }
+-
++
+ return $messageIds;
+@@ -1170,7 +1375,7 @@ class BroadcastManager
+ $messageIds[] = $messageId;
+ }
+-
++
+- $this->api->sleep(self::SEND_MESSAGE_SLEEP);
++ $this->runtimeSleep(self::SEND_MESSAGE_SLEEP);
+ }
+-
++
+ return $messageIds;
+@@ -1399,6 +1604,13 @@ class BroadcastManager
+ 'status' => $jobs[$scheduleId]['status'],
+ 'broadcastId' => $broadcastId,
+ ];
++ } catch (\Amp\CancelledException $e) {
++ $jobs = $this->loadScheduledBroadcasts();
++ $jobs[$scheduleId]['status'] = 'cancelled';
++ $jobs[$scheduleId]['finishedAt'] = time();
++ $jobs[$scheduleId]['error'] = 'Runtime cancelled; delivery outcome may be partial.';
++ $this->saveScheduledBroadcasts($jobs);
++ throw $e;
+ } catch (Throwable $e) {
+ $jobs = $this->loadScheduledBroadcasts();
+ $jobs[$scheduleId]['status'] = 'failed';
+@@ -1456,8 +1668,11 @@ class BroadcastManager
+ return $jobId;
+ }
+-
++
+- private function runSelfDestructJob(string $jobId): array
+- {
++ private function runSelfDestructJob(string $jobId): array {
++ return $this->withAdmission(fn(): array => $this->doRunSelfDestructJob($jobId));
++ }
++
++ private function doRunSelfDestructJob(string $jobId): array {
+ $jobs = $this->loadSelfDestructJobs();
+-
++
+ if (!isset($jobs[$jobId])) {
+@@ -1529,7 +1744,7 @@ class BroadcastManager
+ return;
+ }
+-
++
+- $this->api->messages->deleteMessages([
++ $this->callNativeMessage('deleteMessages', [
+ 'peer' => (string) $peerJob['peer'],
+ 'id' => $messageIds,
+ 'revoke' => true,
+@@ -1561,6 +1776,13 @@ class BroadcastManager
+ 'status' => $jobs[$jobId]['status'],
+ 'stats' => $stats,
+ ];
++ } catch (\Amp\CancelledException $e) {
++ $jobs = $this->loadSelfDestructJobs();
++ $jobs[$jobId]['status'] = 'cancelled';
++ $jobs[$jobId]['finishedAt'] = time();
++ $jobs[$jobId]['error'] = 'Runtime cancelled; delivery outcome may be partial.';
++ $this->saveSelfDestructJobs($jobs);
++ throw $e;
+ } catch (Throwable $e) {
+ $jobs = $this->loadSelfDestructJobs();
+ $jobs[$jobId]['status'] = 'failed';
+@@ -1755,11 +1977,12 @@ class BroadcastManager
+ return;
+ }
+-
++
+- \Amp\async(function () use ($chatId, $statusId, &$state, $title): void {
++ $this->launchWorker($state, function () use ($chatId, $statusId, &$state, $title): void {
+ $last = '';
+ $loggedFailures = 0;
+-
++
+ while (!$state['done']) {
++ $this->checkRuntime();
+ $text = $this->buildProgressText($state, $title);
+ $replyMarkup = $this->buildStatusControls($state);
+ $fingerprint = $text . "\n" . json_encode($replyMarkup);
+@@ -1785,7 +2008,7 @@ class BroadcastManager
+ || str_contains($e->getMessage(), 'MESSAGE_NOT_MODIFIED')
+ ) {
+ $last = $fingerprint;
+- $this->api->sleep(self::PROGRESS_UPDATE_INTERVAL);
++ $this->workerSleep(self::PROGRESS_UPDATE_INTERVAL, $state);
+ continue;
+ }
+-
++
+@@ -1793,6 +2016,8 @@ class BroadcastManager
+ $loggedFailures++;
+ $this->logError('Failed to update status message.', $e);
+ }
++ } catch (\Amp\CancelledException $e) {
++ throw $e;
+ } catch (Throwable $e) {
+ if ($loggedFailures < 3) {
+ $loggedFailures++;
+@@ -1801,7 +2026,7 @@ class BroadcastManager
+ }
+ }
+-
++
+- $this->api->sleep(self::PROGRESS_UPDATE_INTERVAL);
++ $this->workerSleep(self::PROGRESS_UPDATE_INTERVAL, $state);
+ }
+ });
+ }
diff --git a/patches/broadcastmanager/generate-artifacts.php b/patches/broadcastmanager/generate-artifacts.php
new file mode 100644
index 0000000..c6d8df0
--- /dev/null
+++ b/patches/broadcastmanager/generate-artifacts.php
@@ -0,0 +1,67 @@
+ '2a9a42dc6e3f6d7b51c178e7d9db962a5b5ac424046af18afcfcdb12d41dc6e8',
+ 'previous/dev3/BroadcastManager.php' => '3d9425f7911b12c69faae5e87954db134dc3fb7c2f7d654a72f972ebeb84e424',
+ 'previous/dev8/BroadcastManager.php' => '08db35c271f671041257ebe79d1010cdd7c33c4d5868e4553c78612f9e8bf0f3',
+];
+foreach ($approvedInputs as $path => $hash) {
+ if (hash_file('sha256', $base . $path) !== $hash) {
+ throw new RuntimeException('Immutable approved source changed: ' . $path);
+ }
+}
+$reviewed = (string)file_get_contents($base . 'reviewed/BroadcastManager.php');
+token_get_all($reviewed, TOKEN_PARSE);
+$git = PHP_OS_FAMILY === 'Windows' && is_file('C:/Program Files/Git/cmd/git.exe') ? 'C:/Program Files/Git/cmd/git.exe' : 'git';
+$makeDiff = static function (string $before, string $after) use ($git): string {
+ $process = proc_open([$git, '-c', 'core.autocrlf=false', 'diff', '--no-index', '--no-ext-diff', '--text', '--unified=3', '--', $before, $after], [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
+ if (!is_resource($process)) {
+ throw new RuntimeException('Cannot run the development diff generator.');
+ }
+ fclose($pipes[0]);
+ $diff = stream_get_contents($pipes[1]);
+ $errors = stream_get_contents($pipes[2]);
+ fclose($pipes[1]);
+ fclose($pipes[2]);
+ if (proc_close($process) !== 1 || !is_string($diff) || !str_contains($diff, '@@ ')) {
+ throw new RuntimeException('Cannot generate unified diff: ' . $errors);
+ }
+ $diff = substr($diff, strpos($diff, '@@ '));
+ $lines = [];
+ foreach (explode("\n", $diff) as $line) {
+ if ($line === ' ') {
+ $lines[] = '-';
+ $lines[] = '+';
+ } else {
+ $lines[] = $line;
+ }
+ }
+ return "--- a/src/BroadcastManager.php\n+++ b/src/BroadcastManager.php\n" . implode("\n", $lines);
+};
+$manifest = json_decode((string)file_get_contents($base . 'manifest.json'), true, 64, JSON_THROW_ON_ERROR);
+if ($manifest['approved'][0]['version'] !== '3.2.2' || $manifest['approved'][0]['commit'] !== 'f2792a383416b66bb68e55f09414563e540538d6') {
+ throw new RuntimeException('A different dependency needs a separate source and behavior review.');
+}
+$manifest['reviewed_at'] = '2026-10-16';
+$manifest['provenance']['kind'] = 'new-local-presentation-message-transport-and-runtime-lifecycle-hooks';
+$manifest['provenance']['note'] = 'Optional lifecycle guard and cancellation, tracked worker/progress draining and bounded flood behavior, retaining exact dev.3 and dev.8 intermediate sources and the pinned upstream baseline.';
+$entry = &$manifest['approved'][0];
+$entry['patched_sha256'] = hash('sha256', $reviewed);
+$entry['intermediates'] = [];
+foreach (['baseline/BroadcastManager.php' => 'status-localization-hooks.patch', 'previous/dev3/BroadcastManager.php' => 'dev3-to-transport-hooks.patch', 'previous/dev8/BroadcastManager.php' => 'dev8-to-runtime-lifecycle.patch'] as $source => $patch) {
+ $diff = $makeDiff($base . $source, $base . 'reviewed/BroadcastManager.php');
+ file_put_contents($base . $patch, $diff);
+ if (str_starts_with($source, 'baseline/')) {
+ $entry['patch_sha256'] = hash('sha256', $diff);
+ } else {
+ $entry['intermediates'][] = ['source' => $source, 'sha256' => $approvedInputs[$source], 'patch' => $patch, 'patch_sha256' => hash('sha256', $diff)];
+ }
+}
+file_put_contents($base . 'manifest.json', json_encode($manifest, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
+echo $entry['patched_sha256'], "\n";
diff --git a/patches/broadcastmanager/manifest.json b/patches/broadcastmanager/manifest.json
new file mode 100644
index 0000000..5803cb5
--- /dev/null
+++ b/patches/broadcastmanager/manifest.json
@@ -0,0 +1,40 @@
+{
+ "schema_version": 1,
+ "reviewed_at": "2026-10-16",
+ "package": "wizardloop/broadcastmanager",
+ "target": "src/BroadcastManager.php",
+ "provenance": {
+ "kind": "new-local-presentation-message-transport-and-runtime-lifecycle-hooks",
+ "note": "Optional lifecycle guard and cancellation, tracked worker/progress draining and bounded flood behavior, retaining exact dev.3 and dev.8 intermediate sources and the pinned upstream baseline.",
+ "source_url": "https://github.com/WizardLoop/BroadcastManager/blob/f2792a383416b66bb68e55f09414563e540538d6/src/BroadcastManager.php",
+ "source_verified_against_locked_installed_file": true
+ },
+ "approved": [
+ {
+ "version": "3.2.2",
+ "commit": "f2792a383416b66bb68e55f09414563e540538d6",
+ "mode": "patch",
+ "baseline": "baseline/BroadcastManager.php",
+ "reviewed_output": "reviewed/BroadcastManager.php",
+ "original_sha256": "2a9a42dc6e3f6d7b51c178e7d9db962a5b5ac424046af18afcfcdb12d41dc6e8",
+ "patched_sha256": "d665b24009aabfb5b09e00edad1271f1501bf0ef797d5a2593129da91c8cc435",
+ "patch": "status-localization-hooks.patch",
+ "patch_sha256": "8d115dfd42568181c365001373772e3b52b06e7c9fbc4433878e30d0390097bb",
+ "intermediates": [
+ {
+ "source": "previous/dev3/BroadcastManager.php",
+ "sha256": "3d9425f7911b12c69faae5e87954db134dc3fb7c2f7d654a72f972ebeb84e424",
+ "patch": "dev3-to-transport-hooks.patch",
+ "patch_sha256": "42af3d2eb99003512477607f0cf34760d5f0b6b5169467a8115fae5ef3c69374"
+ },
+ {
+ "source": "previous/dev8/BroadcastManager.php",
+ "sha256": "08db35c271f671041257ebe79d1010cdd7c33c4d5868e4553c78612f9e8bf0f3",
+ "patch": "dev8-to-runtime-lifecycle.patch",
+ "patch_sha256": "2630fa3f02b9bb361e1a4ade2b3edccc40d4c0fc6468272b6bc6ced89cbf0a84"
+ }
+ ]
+ }
+ ],
+ "official_versions": []
+}
diff --git a/patches/broadcastmanager/previous/dev3/BroadcastManager.php b/patches/broadcastmanager/previous/dev3/BroadcastManager.php
new file mode 100644
index 0000000..013a9cc
--- /dev/null
+++ b/patches/broadcastmanager/previous/dev3/BroadcastManager.php
@@ -0,0 +1,2000 @@
+
+ * @copyright - WizardLoop
+ * @license - https://opensource.org/licenses/AGPL-3.0 AGPLv3
+ */
+
+namespace BroadcastTool;
+
+use danog\MadelineProto\API;
+use danog\MadelineProto\RPCErrorException;
+use InvalidArgumentException;
+use JsonException;
+use RuntimeException;
+use SplQueue;
+use Throwable;
+
+class BroadcastManager
+{
+ private const MAX_ATTEMPTS = 3;
+ private const DEFAULT_CONCURRENCY = 10;
+ private const MAX_CONCURRENCY = 30;
+ private const PROGRESS_UPDATE_INTERVAL = 5.0;
+ private const WORKER_IDLE_SLEEP = 1.0;
+ private const WORKER_RETRY_SLEEP = 1.0;
+ private const WORKER_PAUSED_SLEEP = 1.5;
+ private const WORKER_AFTER_JOB_SLEEP = 0.75;
+ private const SEND_MESSAGE_SLEEP = 0.35;
+ private const SEND_CHUNK_SLEEP = 0.75;
+
+ private const SEND_HARD_FAIL_RPCS = [
+ 'INPUT_USER_DEACTIVATED',
+ 'USER_IS_BOT',
+ 'CHAT_WRITE_FORBIDDEN',
+ 'USER_IS_BLOCKED',
+ 'PEER_ID_INVALID',
+ ];
+
+ private const EDIT_HARD_FAIL_RPCS = [
+ 'INPUT_USER_DEACTIVATED',
+ 'USER_IS_BOT',
+ 'CHAT_WRITE_FORBIDDEN',
+ 'USER_IS_BLOCKED',
+ 'PEER_ID_INVALID',
+ 'MESSAGE_ID_INVALID',
+ 'MESSAGE_EDIT_TIME_EXPIRED',
+ 'MESSAGE_AUTHOR_REQUIRED',
+ ];
+
+ private const DELETE_HARD_FAIL_RPCS = [
+ 'INPUT_USER_DEACTIVATED',
+ 'USER_IS_BOT',
+ 'CHAT_WRITE_FORBIDDEN',
+ 'USER_IS_BLOCKED',
+ 'PEER_ID_INVALID',
+ 'MESSAGE_ID_INVALID',
+ ];
+
+ private ?\Closure $statusFormatter = null;
+ private ?\Closure $statusControlsFormatter = null;
+ private API $api;
+ private array $currentBroadcastState = [];
+ private static array $sharedBroadcastState = [];
+ private static string $dataDir = '';
+
+ public function __construct(API $api)
+ {
+ $this->api = $api;
+ }
+
+ /** Optional per-instance presentation hook; default upstream output is unchanged. */
+ public function setStatusFormatter(?callable $formatter): void
+ {
+ $this->statusFormatter = $formatter === null ? null : \Closure::fromCallable($formatter);
+ }
+
+ /** Optional native-markup hook. Preserve each supplied bm callback identifier. */
+ public function setStatusControlsFormatter(?callable $formatter): void
+ {
+ $this->statusControlsFormatter = $formatter === null ? null : \Closure::fromCallable($formatter);
+ }
+
+ private function formatStatus(string $event, string $default, array $context): string
+ {
+ if ($this->statusFormatter === null) {
+ return $default;
+ }
+ $text = ($this->statusFormatter)($event, $context + ['default' => $default]);
+ if (!is_string($text) || trim($text) === '' || !mb_check_encoding($text, 'UTF-8')
+ || strlen(mb_convert_encoding($text, 'UTF-16LE', 'UTF-8')) > 8192) {
+ throw new InvalidArgumentException('Status formatter must return bounded non-empty UTF-8 text.');
+ }
+ return $text;
+ }
+ /**
+ * Set data dir.
+ */
+ public static function setDataDir(string $path): void
+ {
+ self::$dataDir = rtrim($path, '/\\');
+ }
+
+ /**
+ * Get data dir.
+ */
+ private static function getDataDir(): string
+ {
+ if (!self::$dataDir) {
+ self::$dataDir = __DIR__ . '/../data';
+ }
+
+ return self::$dataDir;
+ }
+
+ /**
+ * Send broadcast.
+ *
+ * @return string ID that can be used for progress/pause/resume/cancel.
+ */
+ public function broadcastWithProgress(
+ array $allUsers,
+ array $messages,
+ $chatId = null,
+ bool $pin = false,
+ int $concurrency = self::DEFAULT_CONCURRENCY,
+ ?int $selfDestructHours = null
+ ): string {
+ $this->validateSelfDestructHours($selfDestructHours);
+
+ $concurrency = $this->clampConcurrency($concurrency);
+ $total = count($allUsers);
+ $broadcastId = $this->createId();
+ $state = $this->createState($broadcastId, 'send', $total, [
+ 'selfDestruct' => [
+ 'enabled' => $selfDestructHours !== null,
+ 'hours' => $selfDestructHours,
+ 'deleteAt' => null,
+ 'deleteJobId' => null,
+ ],
+ ]);
+
+ $this->registerCurrentState($broadcastId, $state);
+ $this->enqueuePeers($state, $allUsers);
+ $this->initializeBroadcastMetadata($broadcastId, 'send', $total, $selfDestructHours);
+
+ $statusId = $this->sendStatusMessage($chatId, $this->formatStatus('starting', 'Gathering peers...', ['type' => $state['type'], 'id' => $state['id'], 'total' => $state['total']]), $this->buildStatusControls($state));
+ $this->startProgressLoop($chatId, $statusId, $state, 'Broadcast Progress');
+
+ $this->startQueueWorkers(
+ $state,
+ $concurrency,
+ function (array $job, array &$state) use ($messages, $pin, $broadcastId): void {
+ $peer = (string) $job['peer'];
+ $messageIds = $this->sendMessagesToPeer($peer, $messages);
+ $lastMessageId = $messageIds ? end($messageIds) : null;
+
+ if ($pin && $lastMessageId) {
+ $this->api->messages->updatePinnedMessage([
+ 'peer' => $peer,
+ 'id' => $lastMessageId,
+ 'unpin' => false,
+ ]);
+ }
+
+ $state['sent']++;
+ $state['lastMessageIds'][$peer] = $lastMessageId ? (string) $lastMessageId : '';
+
+ if ($messageIds) {
+ $this->savePeerMessageIds($peer, $messageIds);
+ }
+
+ $this->saveBroadcastPeerMessageIds(
+ $broadcastId,
+ $peer,
+ $messageIds,
+ (int) $state['sent'],
+ (int) $state['failed']
+ );
+ },
+ self::SEND_HARD_FAIL_RPCS
+ );
+
+ $this->waitForCompletion($state);
+ $state['status'] = $state['cancel'] ? 'cancelled' : 'done';
+
+ $this->finalizeBroadcastMetadata(
+ $broadcastId,
+ (string) $state['status'],
+ (int) $state['sent'],
+ (int) $state['failed']
+ );
+
+ $selfDestructJobId = null;
+ if ($selfDestructHours !== null) {
+ $selfDestructJobId = $this->createSelfDestructJob($broadcastId, $selfDestructHours, $chatId, $concurrency);
+ if ($selfDestructJobId !== null) {
+ $metadata = $this->loadBroadcastMetadata($broadcastId);
+ $state['selfDestruct'] = $metadata['selfDestruct'] ?? $state['selfDestruct'];
+ }
+
+ if ($selfDestructJobId !== null && $selfDestructHours === 0) {
+ $this->runSelfDestructJob($selfDestructJobId);
+ }
+ }
+
+ $finalText = $this->buildProgressText($state, 'Broadcast Progress', true);
+ $this->editStatusMessage($chatId, $statusId, $finalText);
+ $this->writeLastBroadcastData($finalText);
+
+ return $broadcastId;
+ }
+
+ /**
+ * Edit the last saved broadcast message for all users.
+ */
+ public function editLastBroadcastForAll(
+ array $allUsers,
+ string $newText,
+ $chatId = null,
+ ?array $buttons = null,
+ $media = null,
+ int $concurrency = self::DEFAULT_CONCURRENCY,
+ string $parseMode = 'HTML',
+ ?string $broadcastId = null
+ ): string {
+ $concurrency = $this->clampConcurrency($concurrency);
+ $targetBroadcastId = $this->normalizeOptionalId($broadcastId);
+ $targets = $targetBroadcastId !== null && $allUsers === []
+ ? $this->broadcastMetadataPeers($targetBroadcastId)
+ : $allUsers;
+ $operationId = $this->createId();
+ $state = $this->createState($operationId, 'edit', count($targets), [
+ 'targetBroadcastId' => $targetBroadcastId,
+ ]);
+
+ $this->registerCurrentState($operationId, $state);
+ $this->enqueuePeers($state, $targets);
+
+ $statusId = $this->sendStatusMessage($chatId, $this->formatStatus('starting', 'Editing last broadcast...', ['type' => $state['type'], 'id' => $state['id'], 'total' => $state['total']]), $this->buildStatusControls($state));
+ $this->startProgressLoop($chatId, $statusId, $state, 'Editing Last Broadcast');
+
+ $this->startQueueWorkers(
+ $state,
+ $concurrency,
+ function (array $job, array &$state) use ($newText, $buttons, $media, $parseMode, $targetBroadcastId): void {
+ $peer = (string) $job['peer'];
+ $lastMessageId = $targetBroadcastId !== null
+ ? $this->readBroadcastLastMessageId($targetBroadcastId, $peer)
+ : $this->readLastBroadcastMessageId($peer);
+
+ if ($lastMessageId <= 0) {
+ $state['failed']++;
+ return;
+ }
+
+ $payload = [
+ 'peer' => $peer,
+ 'id' => $lastMessageId,
+ 'message' => $newText,
+ 'parse_mode' => $parseMode,
+ 'floodWaitLimit' => 172800,
+ ];
+
+ if ($buttons !== null) {
+ $payload['reply_markup'] = $buttons;
+ }
+
+ if ($media !== null) {
+ $payload['media'] = $media;
+ }
+
+ $this->api->messages->editMessage($payload);
+ $state['edited']++;
+ },
+ self::EDIT_HARD_FAIL_RPCS,
+ function (RPCErrorException $e, array &$job, array &$state): bool {
+ unset($job);
+
+ if (($e->rpc ?? '') === 'MESSAGE_NOT_MODIFIED' || str_contains($e->getMessage(), 'MESSAGE_NOT_MODIFIED')) {
+ $state['unchanged']++;
+ return true;
+ }
+
+ return false;
+ }
+ );
+
+ $this->waitForCompletion($state);
+ $state['status'] = $state['cancel'] ? 'cancelled' : 'done';
+
+ $this->editStatusMessage($chatId, $statusId, $this->buildProgressText($state, 'Editing Last Broadcast', true));
+
+ return $operationId;
+ }
+
+ /**
+ * Schedule a broadcast. Due broadcasts can be run with runDueScheduledBroadcasts().
+ */
+ public function scheduleBroadcastForAll(
+ array $allUsers,
+ array $messages,
+ int $scheduledAt,
+ $chatId = null,
+ bool $pin = false,
+ int $concurrency = self::DEFAULT_CONCURRENCY,
+ ?int $selfDestructHours = null
+ ): string {
+ $this->validateSelfDestructHours($selfDestructHours);
+ $this->assertJsonEncodable($allUsers, 'allUsers');
+ $this->assertJsonEncodable($messages, 'messages');
+
+ $concurrency = $this->clampConcurrency($concurrency);
+ $scheduleId = $this->createId('schedule');
+ $jobs = $this->loadScheduledBroadcasts();
+
+ $jobs[$scheduleId] = [
+ 'id' => $scheduleId,
+ 'status' => 'scheduled',
+ 'scheduledAt' => $scheduledAt,
+ 'createdAt' => time(),
+ 'allUsers' => array_values($allUsers),
+ 'messages' => array_values($messages),
+ 'chatId' => $chatId,
+ 'pin' => $pin,
+ 'concurrency' => $concurrency,
+ 'selfDestructHours' => $selfDestructHours,
+ 'broadcastId' => null,
+ 'error' => null,
+ ];
+
+ $this->saveScheduledBroadcasts($jobs);
+
+ if ($scheduledAt <= time()) {
+ $this->runScheduledBroadcast($scheduleId);
+ }
+
+ return $scheduleId;
+ }
+
+ /**
+ * Run all scheduled broadcasts whose scheduledAt timestamp has passed.
+ */
+ public function runDueScheduledBroadcasts(): array
+ {
+ $jobs = $this->loadScheduledBroadcasts();
+ $results = [];
+ $now = time();
+
+ foreach ($jobs as $scheduleId => $job) {
+ if (($job['status'] ?? null) !== 'scheduled') {
+ continue;
+ }
+
+ if ((int) ($job['scheduledAt'] ?? 0) > $now) {
+ continue;
+ }
+
+ $results[$scheduleId] = $this->runScheduledBroadcast((string) $scheduleId);
+ }
+
+ return $results;
+ }
+
+ public function cancelScheduledBroadcast(string $scheduleId): bool
+ {
+ $jobs = $this->loadScheduledBroadcasts();
+
+ if (!isset($jobs[$scheduleId]) || ($jobs[$scheduleId]['status'] ?? null) !== 'scheduled') {
+ return false;
+ }
+
+ $jobs[$scheduleId]['status'] = 'cancelled';
+ $jobs[$scheduleId]['cancelledAt'] = time();
+ $this->saveScheduledBroadcasts($jobs);
+
+ return true;
+ }
+
+ public function listScheduledBroadcasts(): array
+ {
+ $items = [];
+
+ foreach ($this->loadScheduledBroadcasts() as $id => $job) {
+ $items[$id] = [
+ 'id' => (string) ($job['id'] ?? $id),
+ 'status' => (string) ($job['status'] ?? 'unknown'),
+ 'scheduledAt' => (int) ($job['scheduledAt'] ?? 0),
+ 'createdAt' => (int) ($job['createdAt'] ?? 0),
+ 'broadcastId' => $job['broadcastId'] ?? null,
+ 'pin' => (bool) ($job['pin'] ?? false),
+ 'concurrency' => (int) ($job['concurrency'] ?? self::DEFAULT_CONCURRENCY),
+ 'selfDestructHours' => $job['selfDestructHours'] ?? null,
+ 'totalUsers' => is_array($job['allUsers'] ?? null) ? count($job['allUsers']) : 0,
+ 'totalMessages' => is_array($job['messages'] ?? null) ? count($job['messages']) : 0,
+ 'error' => $job['error'] ?? null,
+ ];
+ }
+
+ return $items;
+ }
+
+ /**
+ * Deletes the last broadcast message for all users.
+ *
+ * @return string ID that can be used for progress/pause/resume/cancel.
+ */
+ public function deleteLastBroadcastForAll(
+ array $allUsers,
+ $chatId = null,
+ int $concurrency = self::DEFAULT_CONCURRENCY,
+ ?string $broadcastId = null
+ ): string {
+ $concurrency = $this->clampConcurrency($concurrency);
+ $targetBroadcastId = $this->normalizeOptionalId($broadcastId);
+ $targets = $targetBroadcastId !== null && $allUsers === []
+ ? $this->broadcastMetadataPeers($targetBroadcastId)
+ : $allUsers;
+ $operationId = $this->createId();
+ $state = $this->createState($operationId, 'deletelast', count($targets), [
+ 'targetBroadcastId' => $targetBroadcastId,
+ ]);
+
+ $this->registerCurrentState($operationId, $state);
+ $this->enqueuePeers($state, $targets);
+
+ $statusId = $this->sendStatusMessage($chatId, $this->formatStatus('starting', 'Deleting last broadcast...', ['type' => $state['type'], 'id' => $state['id'], 'total' => $state['total']]), $this->buildStatusControls($state));
+ $this->startProgressLoop($chatId, $statusId, $state, 'Deleting Last Broadcast');
+
+ $this->startQueueWorkers(
+ $state,
+ $concurrency,
+ function (array $job, array &$state) use ($targetBroadcastId): void {
+ $peer = (string) $job['peer'];
+ $lastMessageId = $targetBroadcastId !== null
+ ? $this->readBroadcastLastMessageId($targetBroadcastId, $peer)
+ : $this->readLastBroadcastMessageId($peer);
+
+ if ($lastMessageId <= 0) {
+ $state['failed']++;
+ return;
+ }
+
+ $this->api->messages->deleteMessages([
+ 'peer' => $peer,
+ 'id' => [$lastMessageId],
+ 'revoke' => true,
+ ]);
+
+ if ($targetBroadcastId !== null) {
+ $this->markBroadcastPeerMessageDeleted($targetBroadcastId, $peer, $lastMessageId);
+ } else {
+ $this->deleteFile($this->peerDataPath($peer, 'lastBroadcast.txt'));
+ }
+
+ $state['deleted']++;
+ },
+ self::DELETE_HARD_FAIL_RPCS
+ );
+
+ $this->waitForCompletion($state);
+ $state['status'] = $state['cancel'] ? 'cancelled' : 'done';
+
+ $this->editStatusMessage($chatId, $statusId, $this->buildProgressText($state, 'Deleting Last Broadcast', true));
+
+ return $operationId;
+ }
+
+ /**
+ * Deletes all broadcast messages for all users.
+ *
+ * @return string ID that can be used for progress/pause/resume/cancel.
+ */
+ public function deleteAllBroadcastsForAll(
+ array $allUsers,
+ $chatId = null,
+ int $concurrency = self::DEFAULT_CONCURRENCY
+ ): string {
+ $concurrency = $this->clampConcurrency($concurrency);
+ $broadcastId = $this->createId();
+ $state = $this->createState($broadcastId, 'deleteall', count($allUsers));
+
+ $this->registerCurrentState($broadcastId, $state);
+ $this->enqueuePeers($state, $allUsers);
+
+ $statusId = $this->sendStatusMessage($chatId, $this->formatStatus('starting', 'Deleting all broadcasts...', ['type' => $state['type'], 'id' => $state['id'], 'total' => $state['total']]), $this->buildStatusControls($state));
+ $this->startProgressLoop($chatId, $statusId, $state, 'Deleting All Broadcasts');
+
+ $this->startQueueWorkers(
+ $state,
+ $concurrency,
+ function (array $job, array &$state): void {
+ $peer = (string) $job['peer'];
+ $file = $this->peerDataPath($peer, 'messages.txt');
+
+ if (!is_file($file)) {
+ $state['failed']++;
+ return;
+ }
+
+ $msgIds = array_values(array_filter(
+ array_map('intval', explode("\n", trim((string) file_get_contents($file)))),
+ static fn (int $id): bool => $id > 0
+ ));
+
+ if (!$msgIds) {
+ $state['failed']++;
+ return;
+ }
+
+ $userDeleted = false;
+
+ foreach ($msgIds as $messageId) {
+ try {
+ $this->api->messages->deleteMessages([
+ 'peer' => $peer,
+ 'id' => [$messageId],
+ 'revoke' => true,
+ ]);
+ $userDeleted = true;
+ } catch (RPCErrorException $e) {
+ $message = $e->getMessage();
+
+ if ($this->parseFloodWait($e) !== null) {
+ throw $e;
+ }
+
+ if (str_contains($message, 'USER_IS_BLOCKED') || str_contains($message, 'PEER_ID_INVALID')) {
+ continue;
+ }
+
+ throw $e;
+ }
+ }
+
+ if ($userDeleted) {
+ $state['deleted']++;
+ } else {
+ $state['failed']++;
+ }
+
+ $this->deleteFile($this->peerDataPath($peer, 'lastBroadcast.txt'));
+ $this->deleteFile($file);
+ },
+ self::DELETE_HARD_FAIL_RPCS,
+ null,
+ true
+ );
+
+ $this->waitForCompletion($state);
+ $state['status'] = $state['cancel'] ? 'cancelled' : 'done';
+
+ $this->editStatusMessage($chatId, $statusId, $this->buildProgressText($state, 'Deleting All Broadcasts', true));
+
+ return $broadcastId;
+ }
+
+ /**
+ * Unpin all messages for all users.
+ *
+ * @return string ID that can be used for progress/pause/resume/cancel.
+ */
+ public function unpinAllMessagesForAll(
+ array $allUsers,
+ $chatId = null,
+ int $concurrency = self::DEFAULT_CONCURRENCY
+ ): string {
+ $concurrency = $this->clampConcurrency($concurrency);
+ $broadcastId = $this->createId();
+ $state = $this->createState($broadcastId, 'unpin', count($allUsers));
+
+ $this->registerCurrentState($broadcastId, $state);
+ $this->enqueuePeers($state, $allUsers);
+
+ $statusId = $this->sendStatusMessage($chatId, $this->formatStatus('starting', 'Starting unpin...', ['type' => $state['type'], 'id' => $state['id'], 'total' => $state['total']]), $this->buildStatusControls($state));
+ $this->startProgressLoop($chatId, $statusId, $state, 'Unpinning Messages');
+
+ $this->startQueueWorkers(
+ $state,
+ $concurrency,
+ function (array $job, array &$state): void {
+ $this->api->messages->unpinAllMessages([
+ 'peer' => (string) $job['peer'],
+ ]);
+
+ $state['unpin']++;
+ },
+ self::SEND_HARD_FAIL_RPCS
+ );
+
+ $this->waitForCompletion($state);
+ $state['status'] = $state['cancel'] ? 'cancelled' : 'done';
+
+ $this->editStatusMessage($chatId, $statusId, $this->buildProgressText($state, 'Unpinning Messages', true));
+
+ return $broadcastId;
+ }
+
+ public function runDueSelfDestructJobs(): array
+ {
+ $jobs = $this->loadSelfDestructJobs();
+ $results = [];
+ $now = time();
+
+ foreach ($jobs as $jobId => $job) {
+ if (($job['status'] ?? null) !== 'scheduled') {
+ continue;
+ }
+
+ if ((int) ($job['deleteAt'] ?? 0) > $now) {
+ continue;
+ }
+
+ $results[$jobId] = $this->runSelfDestructJob((string) $jobId);
+ }
+
+ return $results;
+ }
+
+ public function cancelSelfDestructJob(string $jobId): bool
+ {
+ $jobs = $this->loadSelfDestructJobs();
+
+ if (!isset($jobs[$jobId]) || ($jobs[$jobId]['status'] ?? null) !== 'scheduled') {
+ return false;
+ }
+
+ $jobs[$jobId]['status'] = 'cancelled';
+ $jobs[$jobId]['cancelledAt'] = time();
+ $this->saveSelfDestructJobs($jobs);
+
+ return true;
+ }
+
+ public function listSelfDestructJobs(): array
+ {
+ $items = [];
+
+ foreach ($this->loadSelfDestructJobs() as $id => $job) {
+ $items[$id] = [
+ 'id' => (string) ($job['id'] ?? $id),
+ 'broadcastId' => (string) ($job['broadcastId'] ?? ''),
+ 'status' => (string) ($job['status'] ?? 'unknown'),
+ 'deleteAt' => (int) ($job['deleteAt'] ?? 0),
+ 'createdAt' => (int) ($job['createdAt'] ?? 0),
+ 'concurrency' => (int) ($job['concurrency'] ?? self::DEFAULT_CONCURRENCY),
+ 'chatId' => $job['chatId'] ?? null,
+ 'totalPeers' => (int) ($job['totalPeers'] ?? 0),
+ 'stats' => $job['stats'] ?? null,
+ 'error' => $job['error'] ?? null,
+ ];
+ }
+
+ return $items;
+ }
+
+ /**
+ * Pause running broadcast.
+ */
+ public function pause(string $id): void
+ {
+ if (isset(self::$sharedBroadcastState[$id])) {
+ self::$sharedBroadcastState[$id]['paused'] = true;
+ }
+
+ if (isset($this->currentBroadcastState[$id])) {
+ $this->currentBroadcastState[$id]['paused'] = true;
+ }
+ }
+
+ /**
+ * Resume running broadcast.
+ */
+ public function resume(string $id): void
+ {
+ if (isset(self::$sharedBroadcastState[$id])) {
+ self::$sharedBroadcastState[$id]['paused'] = false;
+ }
+
+ if (isset($this->currentBroadcastState[$id])) {
+ $this->currentBroadcastState[$id]['paused'] = false;
+ }
+ }
+
+ /**
+ * Cancel running broadcast.
+ */
+ public function cancel(string $id): void
+ {
+ if (isset(self::$sharedBroadcastState[$id])) {
+ self::$sharedBroadcastState[$id]['cancel'] = true;
+ }
+
+ if (isset($this->currentBroadcastState[$id])) {
+ $this->currentBroadcastState[$id]['cancel'] = true;
+ }
+ }
+
+ /**
+ * Check if broadcast is paused.
+ */
+ public function isPaused(string $id): bool
+ {
+ return self::$sharedBroadcastState[$id]['paused'] ?? $this->currentBroadcastState[$id]['paused'] ?? false;
+ }
+
+ /**
+ * Check if broadcast is cancelled.
+ */
+ public function isCancelled(string $id): bool
+ {
+ return self::$sharedBroadcastState[$id]['cancel'] ?? $this->currentBroadcastState[$id]['cancel'] ?? false;
+ }
+
+ /**
+ * Check if broadcast is active.
+ */
+ public function isActive(?string $id = null): bool
+ {
+ if (!$id || (!isset(self::$sharedBroadcastState[$id]) && !isset($this->currentBroadcastState[$id]))) {
+ return false;
+ }
+
+ $state = self::$sharedBroadcastState[$id] ?? $this->currentBroadcastState[$id];
+
+ return (
+ empty($state['done'])
+ && empty($state['cancel'])
+ && empty($state['paused'])
+ );
+ }
+
+ /**
+ * Check if there is a last broadcast message saved for deletion.
+ */
+ public function hasLastBroadcast(): bool
+ {
+ foreach (glob(self::getDataDir() . '/*/lastBroadcast.txt') ?: [] as $file) {
+ if (is_file($file) && trim((string) file_get_contents($file)) !== '') {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Check if there are broadcast messages saved for deletion.
+ */
+ public function hasAllBroadcast(): bool
+ {
+ foreach (glob(self::getDataDir() . '/*/messages.txt') ?: [] as $file) {
+ if (is_file($file) && trim((string) file_get_contents($file)) !== '') {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Get current broadcast progress.
+ */
+ public function progress(?string $id = null): ?array
+ {
+ if (!$id || (!isset(self::$sharedBroadcastState[$id]) && !isset($this->currentBroadcastState[$id]))) {
+ return null;
+ }
+
+ $state = $this->normalizeBroadcastState(self::$sharedBroadcastState[$id] ?? $this->currentBroadcastState[$id]);
+
+ $sent = (int) $state['sent'];
+ $deleted = (int) $state['deleted'];
+ $unpin = (int) $state['unpin'];
+ $edited = (int) $state['edited'];
+ $unchanged = (int) $state['unchanged'];
+ $scheduled = (int) $state['scheduled'];
+ $failed = (int) $state['failed'];
+ $flood = (int) $state['flood'];
+
+ $processed = $this->processedCount($state);
+ $success = $sent + $deleted + $unpin + $edited + $unchanged;
+ $pending = $this->pendingCount($state, $processed);
+ $total = (int) $state['total'];
+ $elapsed = $this->elapsedSeconds($state);
+ $tps = $elapsed > 0 ? round($success / $elapsed, 2) : 0.0;
+
+ $progressPercent = $total > 0
+ ? round(($processed / $total) * 100, 2)
+ : 0.0;
+
+ return [
+ 'processed' => $processed,
+ 'success' => $success,
+ 'failed' => $failed,
+ 'pending' => $pending,
+ 'flood' => $flood,
+ 'progressPercent' => $progressPercent,
+ 'breakdown' => [
+ 'sent' => $sent,
+ 'deleted' => $deleted,
+ 'unpin' => $unpin,
+ 'edited' => $edited,
+ 'unchanged' => $unchanged,
+ 'scheduled' => $scheduled,
+ ],
+ 'edited' => $edited,
+ 'unchanged' => $unchanged,
+ 'scheduled' => $scheduled,
+ 'selfDestruct' => $state['selfDestruct'],
+ 'type' => $state['type'],
+ 'total' => $total,
+ 'elapsed' => $elapsed,
+ 'tps' => $tps,
+ 'done' => (bool) $state['done'],
+ 'paused' => (bool) $state['paused'],
+ 'cancel' => (bool) $state['cancel'],
+ 'startedAt' => $state['startedAt'],
+ ];
+ }
+
+ /**
+ * Last broadcast data.
+ */
+ public function lastBroadcastData(): string|false
+ {
+ $dir = self::getDataDir();
+
+ if (!is_dir($dir)) {
+ mkdir($dir, 0777, true);
+ }
+
+ $path = $dir . '/LastBrodDATA.txt';
+
+ if (!is_file($path)) {
+ return false;
+ }
+
+ $last = array_key_last(self::$sharedBroadcastState);
+ return $this->formatStatus('last_data', (string) file_get_contents($path), [
+ 'progress' => $last === null ? null : $this->progress((string) $last),
+ ]);
+ }
+
+ /**
+ * Filter peers.
+ *
+ * allowedTypes: all / users / groups / channels
+ *
+ * @return array{targets: array, failed: int, total: int}
+ */
+ public function filterPeers(
+ array $allUsers,
+ string $filterType = 'users'
+ ): array {
+ $allowedTypes = [
+ 'all' => ['user', 'chat', 'supergroup', 'channel'],
+ 'users' => ['user'],
+ 'groups' => ['chat', 'supergroup'],
+ 'channels' => ['channel'],
+ ];
+
+ $targets = [];
+ $failedCount = 0;
+
+ foreach ($allUsers as $peer) {
+ try {
+ $info = $this->api->getInfo($peer);
+ $type = $info['type'] ?? 'user';
+
+ if (in_array($type, $allowedTypes[$filterType] ?? ['user'], true)) {
+ $targets[] = (string) $peer;
+ }
+ } catch (Throwable $e) {
+ if ($filterType === 'all') {
+ $targets[] = (string) $peer;
+ } else {
+ $failedCount++;
+ $this->logError('Failed to inspect peer type.', $e, ['peer' => (string) $peer]);
+ }
+ }
+ }
+
+ return [
+ 'targets' => $targets,
+ 'failed' => $failedCount,
+ 'total' => count($targets),
+ ];
+ }
+
+ private function createState(string $id, string $type, int $total, array $extra = []): array
+ {
+ return $extra + [
+ 'id' => $id,
+ 'type' => $type,
+ 'status' => 'running',
+ 'total' => max(0, $total),
+ 'sent' => 0,
+ 'deleted' => 0,
+ 'unpin' => 0,
+ 'edited' => 0,
+ 'unchanged' => 0,
+ 'scheduled' => 0,
+ 'failed' => 0,
+ 'flood' => 0,
+ 'queue' => new SplQueue(),
+ 'inFlight' => [],
+ 'lastMessageIds' => [],
+ 'paused' => false,
+ 'cancel' => false,
+ 'done' => false,
+ 'startedAt' => microtime(true),
+ 'selfDestruct' => null,
+ ];
+ }
+
+ private function registerCurrentState(string $id, array &$state): void
+ {
+ $this->currentBroadcastState[$id] =& $state;
+ self::$sharedBroadcastState[$id] =& $state;
+ }
+
+ private function enqueuePeers(array &$state, array $peers): void
+ {
+ foreach ($peers as $peer) {
+ $state['queue']->enqueue([
+ 'peer' => (string) $peer,
+ 'attempts' => 0,
+ 'startedAt' => null,
+ 'availableAt' => 0.0,
+ ]);
+ }
+ }
+
+ private function clampConcurrency(int $concurrency): int
+ {
+ return max(1, min(self::MAX_CONCURRENCY, $concurrency));
+ }
+
+ private function startQueueWorkers(
+ array &$state,
+ int $concurrency,
+ callable $handler,
+ array $hardFailRpcs = [],
+ ?callable $rpcHandler = null,
+ bool $retryThrowable = false
+ ): void {
+ $concurrency = $this->clampConcurrency($concurrency);
+
+ for ($i = 0; $i < $concurrency; $i++) {
+ \Amp\async(function () use (&$state, $handler, $hardFailRpcs, $rpcHandler, $retryThrowable): void {
+ while (!$state['cancel'] && !$state['done']) {
+ if ($state['queue']->isEmpty()) {
+ $this->api->sleep(self::WORKER_IDLE_SLEEP);
+ continue;
+ }
+
+ if ($state['paused']) {
+ $this->api->sleep(self::WORKER_PAUSED_SLEEP);
+ continue;
+ }
+
+ $job = $state['queue']->dequeue();
+
+ if (($job['availableAt'] ?? 0) > microtime(true)) {
+ $state['queue']->enqueue($job);
+ $this->api->sleep(self::WORKER_RETRY_SLEEP);
+ continue;
+ }
+
+ while ($state['paused'] && !$state['cancel'] && !$state['done']) {
+ $this->api->sleep(self::WORKER_PAUSED_SLEEP);
+ }
+
+ if ($state['cancel'] || $state['done']) {
+ continue;
+ }
+
+ $peer = (string) $job['peer'];
+ $job['startedAt'] = microtime(true);
+ $state['inFlight'][$peer] = $job;
+
+ try {
+ $handler($job, $state);
+ unset($state['inFlight'][$peer]);
+ } catch (RPCErrorException $e) {
+ unset($state['inFlight'][$peer]);
+
+ if ($rpcHandler !== null && $rpcHandler($e, $job, $state)) {
+ continue;
+ }
+
+ if ($this->handleRpcRetry($e, $job, $state, $hardFailRpcs)) {
+ continue;
+ }
+
+ $this->logError('RPC error during broadcast job.', $e, [
+ 'type' => (string) ($state['type'] ?? ''),
+ 'peer' => $peer,
+ ]);
+ $this->retryOrFail($job, $state);
+ } catch (Throwable $e) {
+ unset($state['inFlight'][$peer]);
+ $this->logError('Unexpected error during broadcast job.', $e, [
+ 'type' => (string) ($state['type'] ?? ''),
+ 'peer' => $peer,
+ ]);
+
+ if ($retryThrowable) {
+ $this->retryOrFail($job, $state);
+ } else {
+ $state['failed']++;
+ }
+ }
+
+ $this->api->sleep(self::WORKER_AFTER_JOB_SLEEP);
+ }
+ });
+ }
+ }
+
+ private function waitForCompletion(array &$state): void
+ {
+ while (true) {
+ if ($state['queue']->isEmpty() && empty($state['inFlight'])) {
+ break;
+ }
+
+ if ($state['cancel'] && empty($state['inFlight'])) {
+ break;
+ }
+
+ $this->api->sleep(1);
+ }
+
+ $state['done'] = true;
+ }
+
+ private function handleRpcRetry(RPCErrorException $e, array &$job, array &$state, array $hardFailRpcs): bool
+ {
+ if ($this->isHardFailRpc((string) ($e->rpc ?? ''), $hardFailRpcs)) {
+ $state['failed']++;
+ return true;
+ }
+
+ $floodWait = $this->parseFloodWait($e);
+ if ($floodWait !== null) {
+ $state['flood']++;
+ $job['attempts']++;
+ $job['availableAt'] = microtime(true) + $floodWait;
+ $job['startedAt'] = null;
+
+ if ($job['attempts'] >= self::MAX_ATTEMPTS) {
+ $state['failed']++;
+ } else {
+ $state['queue']->enqueue($job);
+ }
+
+ return true;
+ }
+
+ return false;
+ }
+
+ private function isHardFailRpc(string $rpc, array $hardFailRpcs = self::SEND_HARD_FAIL_RPCS): bool
+ {
+ return in_array($rpc, $hardFailRpcs, true);
+ }
+
+ private function parseFloodWait(RPCErrorException $e): ?int
+ {
+ if (preg_match('/FLOOD_WAIT_(\d+)/', $e->getMessage(), $m)) {
+ return (int) $m[1];
+ }
+
+ return null;
+ }
+
+ private function retryOrFail(array &$job, array &$state): void
+ {
+ $job['attempts']++;
+ $job['startedAt'] = null;
+
+ if ($job['attempts'] >= self::MAX_ATTEMPTS) {
+ $state['failed']++;
+ return;
+ }
+
+ $state['queue']->enqueue($job);
+ }
+
+ private function sendMessagesToPeer(string $peer, array $messages): array
+ {
+ $messageIds = [];
+ $albumMessages = [];
+
+ foreach ($messages as $message) {
+ if (isset($message['albumFile']) && is_file((string) $message['albumFile'])) {
+ $decoded = json_decode((string) file_get_contents((string) $message['albumFile']), true);
+ $albumMessages = is_array($decoded) ? $decoded : [];
+ }
+ }
+
+ if ($albumMessages) {
+ foreach (array_chunk($albumMessages, 10) as $chunk) {
+ $multi = [];
+
+ foreach ($chunk as $item) {
+ $media = ($item['media']['type'] ?? null) === 'photo'
+ ? ['_' => 'inputMediaPhoto', 'id' => $item['media']['botApiFileId']]
+ : ['_' => 'inputMediaDocument', 'id' => $item['media']['botApiFileId']];
+
+ $multi[] = [
+ '_' => 'inputSingleMedia',
+ 'media' => $media,
+ 'message' => $item['caption'] ?? '',
+ 'entities' => $item['entities'] ?? [],
+ ];
+ }
+
+ foreach ($this->api->messages->sendMultiMedia(['peer' => $peer, 'multi_media' => $multi]) as $update) {
+ $messageId = (int) $this->api->extractMessageId($update);
+
+ if ($messageId > 0) {
+ $messageIds[] = $messageId;
+ }
+ }
+
+ $this->api->sleep(self::SEND_CHUNK_SLEEP);
+ }
+
+ return $messageIds;
+ }
+
+ foreach ($messages as $message) {
+ $method = isset($message['media']) ? 'sendMedia' : 'sendMessage';
+ $payload = $message + [
+ 'peer' => $peer,
+ 'floodWaitLimit' => 172800,
+ ];
+
+ if (isset($message['buttons'])) {
+ $payload['reply_markup'] = $message['buttons'];
+ }
+
+ $result = $this->api->messages->{$method}($payload);
+ $messageId = (int) $this->api->extractMessageId($result);
+
+ if ($messageId > 0) {
+ $messageIds[] = $messageId;
+ }
+
+ $this->api->sleep(self::SEND_MESSAGE_SLEEP);
+ }
+
+ return $messageIds;
+ }
+
+ private function savePeerMessageIds(string $peer, array $messageIds): void
+ {
+ $messageIds = array_values(array_filter(array_map('intval', $messageIds), static fn (int $id): bool => $id > 0));
+
+ if (!$messageIds) {
+ return;
+ }
+
+ $dir = self::getDataDir() . '/' . $peer;
+ $this->ensureDirectory($dir);
+
+ try {
+ file_put_contents($dir . '/messages.txt', implode("\n", $messageIds) . "\n", FILE_APPEND | LOCK_EX);
+ file_put_contents($dir . '/lastBroadcast.txt', (string) end($messageIds), LOCK_EX);
+ } catch (Throwable $e) {
+ $this->logError('Failed to save peer message ids.', $e, ['peer' => $peer]);
+ }
+ }
+
+ private function initializeBroadcastMetadata(string $broadcastId, string $type, int $total, ?int $selfDestructHours): void
+ {
+ $metadata = [
+ 'id' => $broadcastId,
+ 'type' => $type,
+ 'createdAt' => time(),
+ 'status' => 'running',
+ 'total' => $total,
+ 'sent' => 0,
+ 'failed' => 0,
+ 'peers' => [],
+ 'selfDestruct' => [
+ 'enabled' => $selfDestructHours !== null,
+ 'hours' => $selfDestructHours,
+ 'deleteAt' => null,
+ 'deleteJobId' => null,
+ ],
+ ];
+
+ $this->saveBroadcastMetadata($broadcastId, $metadata);
+ }
+
+ private function saveBroadcastPeerMessageIds(
+ string $broadcastId,
+ string $peer,
+ array $messageIds,
+ int $sent,
+ int $failed
+ ): void {
+ $metadata = $this->loadBroadcastMetadata($broadcastId);
+ $messageIds = array_values(array_filter(array_map('intval', $messageIds), static fn (int $id): bool => $id > 0));
+
+ $metadata['sent'] = $sent;
+ $metadata['failed'] = $failed;
+ if (!isset($metadata['peers']) || !is_array($metadata['peers'])) {
+ $metadata['peers'] = [];
+ }
+
+ $metadata['peers'][$peer] = [
+ 'lastMessageId' => $messageIds ? end($messageIds) : null,
+ 'messageIds' => $messageIds,
+ 'status' => 'sent',
+ ];
+
+ $this->saveBroadcastMetadata($broadcastId, $metadata);
+ }
+
+ private function finalizeBroadcastMetadata(string $broadcastId, string $status, int $sent, int $failed): void
+ {
+ $metadata = $this->loadBroadcastMetadata($broadcastId);
+ $metadata['status'] = $status;
+ $metadata['sent'] = $sent;
+ $metadata['failed'] = $failed;
+ $metadata['finishedAt'] = time();
+
+ $this->saveBroadcastMetadata($broadcastId, $metadata);
+ }
+
+ private function saveBroadcastMetadata(string $broadcastId, array $metadata): void
+ {
+ try {
+ $this->writeJsonFileAtomic($this->broadcastMetadataPath($broadcastId), $metadata);
+ } catch (Throwable $e) {
+ $this->logError('Failed to save broadcast metadata.', $e, ['broadcastId' => $broadcastId]);
+ }
+ }
+
+ private function loadBroadcastMetadata(string $broadcastId): array
+ {
+ return $this->readJsonFile($this->broadcastMetadataPath($broadcastId), [
+ 'id' => $broadcastId,
+ 'type' => 'send',
+ 'createdAt' => time(),
+ 'status' => 'running',
+ 'total' => 0,
+ 'sent' => 0,
+ 'failed' => 0,
+ 'peers' => [],
+ 'selfDestruct' => [
+ 'enabled' => false,
+ 'hours' => null,
+ 'deleteAt' => null,
+ 'deleteJobId' => null,
+ ],
+ ]);
+ }
+
+ private function readJsonFile(string $path, array $default = []): array
+ {
+ if (!is_file($path)) {
+ return $default;
+ }
+
+ try {
+ $content = (string) file_get_contents($path);
+
+ if (trim($content) === '') {
+ return $default;
+ }
+
+ $decoded = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
+ return is_array($decoded) ? $decoded : $default;
+ } catch (Throwable $e) {
+ $this->logError('Failed to read JSON file.', $e, ['path' => $path]);
+ return $default;
+ }
+ }
+
+ private function writeJsonFileAtomic(string $path, array $data): void
+ {
+ $this->ensureDirectory(dirname($path));
+
+ $tmp = $path . '.tmp.' . bin2hex(random_bytes(4));
+
+ try {
+ $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
+
+ if (file_put_contents($tmp, $json . "\n", LOCK_EX) === false) {
+ throw new RuntimeException('Unable to write temporary JSON file.');
+ }
+
+ if (!@rename($tmp, $path)) {
+ if (is_file($path)) {
+ @unlink($path);
+ }
+
+ if (!@rename($tmp, $path)) {
+ throw new RuntimeException('Unable to rename temporary JSON file.');
+ }
+ }
+ } catch (Throwable $e) {
+ if (is_file($tmp)) {
+ @unlink($tmp);
+ }
+
+ $this->logError('Failed to write JSON file atomically.', $e, ['path' => $path]);
+ throw $e;
+ }
+ }
+
+ private function logError(string $message, ?Throwable $e = null, array $context = []): void
+ {
+ try {
+ $this->ensureDirectory(self::getDataDir());
+ $entry = [
+ 'time' => date('c'),
+ 'message' => $message,
+ 'error' => $e ? $e->getMessage() : null,
+ 'rpc' => $e instanceof RPCErrorException ? ($e->rpc ?? null) : null,
+ 'context' => $context,
+ ];
+
+ file_put_contents(
+ self::getDataDir() . '/broadcast-errors.log',
+ json_encode($entry, JSON_UNESCAPED_SLASHES) . "\n",
+ FILE_APPEND | LOCK_EX
+ );
+ } catch (Throwable) {
+ // Logging must never break bot execution.
+ }
+ }
+
+ private function runScheduledBroadcast(string $scheduleId): array
+ {
+ $jobs = $this->loadScheduledBroadcasts();
+
+ if (!isset($jobs[$scheduleId])) {
+ return ['status' => 'missing'];
+ }
+
+ if (($jobs[$scheduleId]['status'] ?? null) !== 'scheduled') {
+ return [
+ 'status' => (string) ($jobs[$scheduleId]['status'] ?? 'unknown'),
+ 'broadcastId' => $jobs[$scheduleId]['broadcastId'] ?? null,
+ ];
+ }
+
+ $jobs[$scheduleId]['status'] = 'running';
+ $jobs[$scheduleId]['startedAt'] = time();
+ $this->saveScheduledBroadcasts($jobs);
+
+ try {
+ $job = $jobs[$scheduleId];
+ $broadcastId = $this->broadcastWithProgress(
+ $job['allUsers'] ?? [],
+ $job['messages'] ?? [],
+ $job['chatId'] ?? null,
+ (bool) ($job['pin'] ?? false),
+ (int) ($job['concurrency'] ?? self::DEFAULT_CONCURRENCY),
+ $job['selfDestructHours'] ?? null
+ );
+
+ $progress = $this->progress($broadcastId);
+ $jobs = $this->loadScheduledBroadcasts();
+ $jobs[$scheduleId]['status'] = ($progress['cancel'] ?? false) ? 'cancelled' : 'done';
+ $jobs[$scheduleId]['broadcastId'] = $broadcastId;
+ $jobs[$scheduleId]['finishedAt'] = time();
+ $jobs[$scheduleId]['error'] = null;
+ $this->saveScheduledBroadcasts($jobs);
+
+ return [
+ 'status' => $jobs[$scheduleId]['status'],
+ 'broadcastId' => $broadcastId,
+ ];
+ } catch (Throwable $e) {
+ $jobs = $this->loadScheduledBroadcasts();
+ $jobs[$scheduleId]['status'] = 'failed';
+ $jobs[$scheduleId]['error'] = substr($e->getMessage(), 0, 500);
+ $jobs[$scheduleId]['failedAt'] = time();
+ $this->saveScheduledBroadcasts($jobs);
+ $this->logError('Scheduled broadcast failed.', $e, ['scheduleId' => $scheduleId]);
+
+ return [
+ 'status' => 'failed',
+ 'error' => $jobs[$scheduleId]['error'],
+ ];
+ }
+ }
+
+ private function createSelfDestructJob(string $broadcastId, int $hours, $chatId, int $concurrency): ?string
+ {
+ $metadata = $this->loadBroadcastMetadata($broadcastId);
+ $peers = array_filter(
+ $metadata['peers'] ?? [],
+ static fn ($peerData): bool => is_array($peerData) && !empty($peerData['messageIds'])
+ );
+
+ if (!$peers) {
+ return null;
+ }
+
+ $jobId = $this->createId('selfdestruct');
+ $deleteAt = time() + ($hours * 3600);
+ $jobs = $this->loadSelfDestructJobs();
+
+ $jobs[$jobId] = [
+ 'id' => $jobId,
+ 'broadcastId' => $broadcastId,
+ 'status' => 'scheduled',
+ 'deleteAt' => $deleteAt,
+ 'createdAt' => time(),
+ 'concurrency' => $this->clampConcurrency($concurrency),
+ 'chatId' => $chatId,
+ 'totalPeers' => count($peers),
+ 'stats' => null,
+ 'error' => null,
+ ];
+
+ $this->saveSelfDestructJobs($jobs);
+
+ $metadata['selfDestruct'] = [
+ 'enabled' => true,
+ 'hours' => $hours,
+ 'deleteAt' => $deleteAt,
+ 'deleteJobId' => $jobId,
+ ];
+ $this->saveBroadcastMetadata($broadcastId, $metadata);
+
+ return $jobId;
+ }
+
+ private function runSelfDestructJob(string $jobId): array
+ {
+ $jobs = $this->loadSelfDestructJobs();
+
+ if (!isset($jobs[$jobId])) {
+ return ['status' => 'missing'];
+ }
+
+ if (($jobs[$jobId]['status'] ?? null) !== 'scheduled') {
+ return [
+ 'status' => (string) ($jobs[$jobId]['status'] ?? 'unknown'),
+ 'stats' => $jobs[$jobId]['stats'] ?? null,
+ ];
+ }
+
+ $jobs[$jobId]['status'] = 'running';
+ $jobs[$jobId]['startedAt'] = time();
+ $this->saveSelfDestructJobs($jobs);
+
+ try {
+ $job = $jobs[$jobId];
+ $metadata = $this->loadBroadcastMetadata((string) $job['broadcastId']);
+ $peerJobs = [];
+
+ foreach (($metadata['peers'] ?? []) as $peer => $peerData) {
+ if (!is_array($peerData)) {
+ continue;
+ }
+
+ $messageIds = array_values(array_filter(
+ array_map('intval', $peerData['messageIds'] ?? []),
+ static fn (int $id): bool => $id > 0
+ ));
+
+ if (!$messageIds) {
+ continue;
+ }
+
+ $peerJobs[] = [
+ 'peer' => (string) $peer,
+ 'messageIds' => $messageIds,
+ 'attempts' => 0,
+ 'startedAt' => null,
+ 'availableAt' => 0.0,
+ ];
+ }
+
+ $state = $this->createState($jobId, 'selfdestruct', count($peerJobs), [
+ 'selfDestruct' => [
+ 'jobId' => $jobId,
+ 'broadcastId' => (string) $job['broadcastId'],
+ 'deleteAt' => (int) $job['deleteAt'],
+ ],
+ ]);
+ $this->registerCurrentState($jobId, $state);
+
+ foreach ($peerJobs as $peerJob) {
+ $state['queue']->enqueue($peerJob);
+ }
+
+ $this->startQueueWorkers(
+ $state,
+ (int) ($job['concurrency'] ?? self::DEFAULT_CONCURRENCY),
+ function (array $peerJob, array &$state): void {
+ $messageIds = array_values(array_filter(
+ array_map('intval', $peerJob['messageIds'] ?? []),
+ static fn (int $id): bool => $id > 0
+ ));
+
+ if (!$messageIds) {
+ return;
+ }
+
+ $this->api->messages->deleteMessages([
+ 'peer' => (string) $peerJob['peer'],
+ 'id' => $messageIds,
+ 'revoke' => true,
+ ]);
+
+ $state['deleted']++;
+ },
+ self::DELETE_HARD_FAIL_RPCS
+ );
+
+ $this->waitForCompletion($state);
+ $state['status'] = $state['cancel'] ? 'cancelled' : 'done';
+
+ $stats = [
+ 'deleted' => (int) $state['deleted'],
+ 'failed' => (int) $state['failed'],
+ 'flood' => (int) $state['flood'],
+ 'total' => (int) $state['total'],
+ ];
+
+ $jobs = $this->loadSelfDestructJobs();
+ $jobs[$jobId]['status'] = $state['cancel'] ? 'cancelled' : 'done';
+ $jobs[$jobId]['finishedAt'] = time();
+ $jobs[$jobId]['stats'] = $stats;
+ $jobs[$jobId]['error'] = null;
+ $this->saveSelfDestructJobs($jobs);
+
+ return [
+ 'status' => $jobs[$jobId]['status'],
+ 'stats' => $stats,
+ ];
+ } catch (Throwable $e) {
+ $jobs = $this->loadSelfDestructJobs();
+ $jobs[$jobId]['status'] = 'failed';
+ $jobs[$jobId]['failedAt'] = time();
+ $jobs[$jobId]['error'] = substr($e->getMessage(), 0, 500);
+ $this->saveSelfDestructJobs($jobs);
+ $this->logError('Self-destruct job failed.', $e, ['jobId' => $jobId]);
+
+ return [
+ 'status' => 'failed',
+ 'error' => $jobs[$jobId]['error'],
+ ];
+ }
+ }
+
+ private function normalizeBroadcastState(array $state): array
+ {
+ return [
+ 'id' => $state['id'] ?? null,
+ 'type' => $state['type'] ?? null,
+ 'sent' => $state['sent'] ?? 0,
+ 'deleted' => $state['deleted'] ?? 0,
+ 'unpin' => $state['unpin'] ?? 0,
+ 'edited' => $state['edited'] ?? 0,
+ 'unchanged' => $state['unchanged'] ?? 0,
+ 'scheduled' => $state['scheduled'] ?? 0,
+ 'failed' => $state['failed'] ?? 0,
+ 'flood' => $state['flood'] ?? 0,
+ 'total' => $state['total'] ?? null,
+ 'queue' => $state['queue'] ?? null,
+ 'inFlight' => $state['inFlight'] ?? [],
+ 'done' => $state['done'] ?? false,
+ 'paused' => $state['paused'] ?? false,
+ 'cancel' => $state['cancel'] ?? false,
+ 'startedAt' => $state['startedAt'] ?? null,
+ 'selfDestruct' => $state['selfDestruct'] ?? null,
+ ];
+ }
+
+ private function processedCount(array $state): int
+ {
+ return (int) $state['sent']
+ + (int) $state['deleted']
+ + (int) $state['unpin']
+ + (int) $state['edited']
+ + (int) $state['unchanged']
+ + (int) $state['scheduled']
+ + (int) $state['failed'];
+ }
+
+ private function pendingCount(array $state, int $processed): int
+ {
+ if (isset($state['total']) && is_int($state['total'])) {
+ return max(0, $state['total'] - $processed);
+ }
+
+ return ($state['queue'] instanceof SplQueue) ? $state['queue']->count() : 0;
+ }
+
+ private function elapsedSeconds(array $state): float
+ {
+ if (!isset($state['startedAt']) || !$state['startedAt']) {
+ return 0.0;
+ }
+
+ return max(0.0, microtime(true) - (float) $state['startedAt']);
+ }
+
+ private function buildProgressText(array $state, string $title, bool $final = false): string
+ {
+ $normalized = $this->normalizeBroadcastState($state);
+ $processed = $this->processedCount($normalized);
+ $total = (int) ($normalized['total'] ?? $processed);
+ $pending = $this->pendingCount($normalized, $processed);
+ $success = (int) $normalized['sent']
+ + (int) $normalized['deleted']
+ + (int) $normalized['unpin']
+ + (int) $normalized['edited']
+ + (int) $normalized['unchanged'];
+ $elapsed = $this->elapsedSeconds($normalized);
+ $tps = $elapsed > 0 ? round($success / $elapsed, 2) : 0.0;
+
+ if ($this->statusFormatter !== null) {
+ $context = array_intersect_key($normalized, array_flip([
+ 'id', 'type', 'sent', 'edited', 'unchanged', 'deleted', 'unpin', 'failed', 'flood', 'paused', 'cancel', 'done',
+ ]));
+ return $this->formatStatus('progress', '', $context + [
+ 'processed' => $processed, 'total' => $total, 'pending' => $pending, 'success' => $success,
+ 'tps' => $tps, 'progress_bar' => $this->progressBar($processed, max(1, $total)), 'final' => $final,
+ ]);
+ }
+ $lines = [
+ '' . $title . '',
+ '',
+ '' . $this->progressBar($processed, max(1, $total)) . '',
+ '',
+ 'Processed: ' . $processed . ' / ' . $total,
+ ];
+
+ foreach (['sent' => 'Sent', 'edited' => 'Edited', 'unchanged' => 'Unchanged', 'deleted' => 'Deleted', 'unpin' => 'Unpinned'] as $key => $label) {
+ if ((int) $normalized[$key] > 0 || $normalized['type'] === $key || ($key === 'edited' && $normalized['type'] === 'edit')) {
+ $lines[] = $label . ': ' . (int) $normalized[$key];
+ }
+ }
+
+ $lines[] = 'Failed: ' . (int) $normalized['failed'];
+ $lines[] = 'FLOOD_WAIT: ' . (int) $normalized['flood'];
+ $lines[] = 'Pending: ' . $pending;
+ $lines[] = 'TPS: ' . $tps . '/s';
+
+ if ($normalized['paused']) {
+ $lines[] = 'Paused';
+ }
+
+ if ($normalized['cancel']) {
+ $lines[] = 'Cancelled';
+ } elseif ($final) {
+ $lines[] = 'Finished';
+ }
+
+ return implode("\n", $lines);
+ }
+
+ /**
+ * Progress bar.
+ */
+ private function progressBar(int $current, int $total): string
+ {
+ $len = 20;
+ $filled = (int) round($current / max($total, 1) * $len);
+
+ return str_repeat('#', $filled)
+ . str_repeat('-', max(0, $len - $filled))
+ . ' '
+ . round(($current / max($total, 1)) * 100)
+ . '%';
+ }
+
+ private function buildStatusControls(array $state): ?array
+ {
+ if (($state['done'] ?? false) || empty($state['id'])) {
+ return null;
+ }
+
+ $id = (string) $state['id'];
+ $toggleAction = !empty($state['paused']) ? 'resume' : 'pause';
+ $context = ['id' => $id, 'type' => $state['type'] ?? null, 'paused' => !empty($state['paused'])];
+ $toggleText = $this->formatStatus($toggleAction, !empty($state['paused']) ? 'Resume' : 'Pause', $context);
+
+ $controls = [
+ 'inline_keyboard' => [
+ [
+ ['text' => $toggleText, 'callback_data' => 'bm:' . $toggleAction . ':' . $id],
+ ['text' => $this->formatStatus('cancel', 'Cancel', $context), 'callback_data' => 'bm:cancel:' . $id],
+ ],
+ ],
+ ];
+ return $this->statusControlsFormatter === null
+ ? $controls
+ : ($this->statusControlsFormatter)($controls, $context);
+ }
+
+ private function sendStatusMessage($chatId, string $message, ?array $replyMarkup = null): ?int
+ {
+ if (!$chatId) {
+ return null;
+ }
+
+ try {
+ $payload = [
+ 'peer' => $chatId,
+ 'message' => $message,
+ 'parse_mode' => 'HTML',
+ ];
+
+ if ($replyMarkup !== null) {
+ $payload['reply_markup'] = $replyMarkup;
+ }
+
+ $status = $this->api->messages->sendMessage($payload);
+
+ return (int) $this->api->extractMessageId($status);
+ } catch (Throwable $e) {
+ $this->logError('Failed to send status message.', $e);
+ return null;
+ }
+ }
+
+ private function startProgressLoop($chatId, ?int $statusId, array &$state, string $title): void
+ {
+ if (!$chatId || !$statusId) {
+ return;
+ }
+
+ \Amp\async(function () use ($chatId, $statusId, &$state, $title): void {
+ $last = '';
+ $loggedFailures = 0;
+
+ while (!$state['done']) {
+ $text = $this->buildProgressText($state, $title);
+ $replyMarkup = $this->buildStatusControls($state);
+ $fingerprint = $text . "\n" . json_encode($replyMarkup);
+
+ if ($fingerprint !== $last) {
+ try {
+ $payload = [
+ 'peer' => $chatId,
+ 'id' => $statusId,
+ 'message' => $text,
+ 'parse_mode' => 'HTML',
+ ];
+
+ if ($replyMarkup !== null) {
+ $payload['reply_markup'] = $replyMarkup;
+ }
+
+ $this->api->messages->editMessage($payload);
+ $last = $fingerprint;
+ } catch (RPCErrorException $e) {
+ if (
+ ($e->rpc ?? '') === 'MESSAGE_NOT_MODIFIED'
+ || str_contains($e->getMessage(), 'MESSAGE_NOT_MODIFIED')
+ ) {
+ $last = $fingerprint;
+ $this->api->sleep(self::PROGRESS_UPDATE_INTERVAL);
+ continue;
+ }
+
+ if ($loggedFailures < 3) {
+ $loggedFailures++;
+ $this->logError('Failed to update status message.', $e);
+ }
+ } catch (Throwable $e) {
+ if ($loggedFailures < 3) {
+ $loggedFailures++;
+ $this->logError('Failed to update status message.', $e);
+ }
+ }
+ }
+
+ $this->api->sleep(self::PROGRESS_UPDATE_INTERVAL);
+ }
+ });
+ }
+
+ private function editStatusMessage($chatId, ?int $statusId, string $text, ?array $replyMarkup = null): void
+ {
+ if (!$chatId || !$statusId) {
+ return;
+ }
+
+ try {
+ $payload = [
+ 'peer' => $chatId,
+ 'id' => $statusId,
+ 'message' => $text,
+ 'parse_mode' => 'HTML',
+ ];
+
+ if ($replyMarkup !== null) {
+ $payload['reply_markup'] = $replyMarkup;
+ }
+
+ $this->api->messages->editMessage($payload);
+ } catch (RPCErrorException $e) {
+ if (
+ ($e->rpc ?? '') === 'MESSAGE_NOT_MODIFIED'
+ || str_contains($e->getMessage(), 'MESSAGE_NOT_MODIFIED')
+ ) {
+ return;
+ }
+
+ $this->logError('Failed to edit final status message.', $e);
+ } catch (Throwable $e) {
+ $this->logError('Failed to edit final status message.', $e);
+ }
+ }
+
+ private function writeLastBroadcastData(string $text): void
+ {
+ try {
+ $this->ensureDirectory(self::getDataDir());
+ file_put_contents(self::getDataDir() . '/LastBrodDATA.txt', $text, LOCK_EX);
+ } catch (Throwable $e) {
+ $this->logError('Failed to save last broadcast data.', $e);
+ }
+ }
+
+ private function readLastBroadcastMessageId(string $peer): int
+ {
+ $file = $this->peerDataPath($peer, 'lastBroadcast.txt');
+
+ if (!is_file($file)) {
+ return 0;
+ }
+
+ return (int) trim((string) file_get_contents($file));
+ }
+
+ private function readBroadcastLastMessageId(string $broadcastId, string $peer): int
+ {
+ $metadata = $this->loadBroadcastMetadata($broadcastId);
+ $peerData = $metadata['peers'][$peer] ?? null;
+
+ if (!is_array($peerData)) {
+ return 0;
+ }
+
+ $lastMessageId = (int) ($peerData['lastMessageId'] ?? 0);
+ if ($lastMessageId > 0) {
+ return $lastMessageId;
+ }
+
+ $messageIds = array_values(array_filter(
+ array_map('intval', $peerData['messageIds'] ?? []),
+ static fn (int $id): bool => $id > 0
+ ));
+
+ return $messageIds ? (int) end($messageIds) : 0;
+ }
+
+ private function broadcastMetadataPeers(string $broadcastId): array
+ {
+ $metadata = $this->loadBroadcastMetadata($broadcastId);
+
+ if (!isset($metadata['peers']) || !is_array($metadata['peers'])) {
+ return [];
+ }
+
+ return array_values(array_map('strval', array_keys($metadata['peers'])));
+ }
+
+ private function markBroadcastPeerMessageDeleted(string $broadcastId, string $peer, int $messageId): void
+ {
+ $metadata = $this->loadBroadcastMetadata($broadcastId);
+ $peerData = $metadata['peers'][$peer] ?? null;
+
+ if (!is_array($peerData)) {
+ return;
+ }
+
+ $messageIds = array_values(array_filter(
+ array_map('intval', $peerData['messageIds'] ?? []),
+ static fn (int $id): bool => $id > 0 && $id !== $messageId
+ ));
+
+ $metadata['peers'][$peer]['messageIds'] = $messageIds;
+ $metadata['peers'][$peer]['lastMessageId'] = $messageIds ? end($messageIds) : null;
+ $metadata['peers'][$peer]['status'] = $messageIds ? 'partial' : 'deleted';
+
+ $this->saveBroadcastMetadata($broadcastId, $metadata);
+ }
+
+ private function peerDataPath(string $peer, string $file): string
+ {
+ return self::getDataDir() . '/' . $peer . '/' . $file;
+ }
+
+ private function broadcastMetadataPath(string $broadcastId): string
+ {
+ return self::getDataDir() . '/broadcasts/' . $broadcastId . '.json';
+ }
+
+ private function scheduledBroadcastsPath(): string
+ {
+ return self::getDataDir() . '/scheduled-broadcasts.json';
+ }
+
+ private function selfDestructJobsPath(): string
+ {
+ return self::getDataDir() . '/self-destruct-jobs.json';
+ }
+
+ private function loadScheduledBroadcasts(): array
+ {
+ return $this->readJsonFile($this->scheduledBroadcastsPath(), []);
+ }
+
+ private function saveScheduledBroadcasts(array $jobs): void
+ {
+ $this->writeJsonFileAtomic($this->scheduledBroadcastsPath(), $jobs);
+ }
+
+ private function loadSelfDestructJobs(): array
+ {
+ return $this->readJsonFile($this->selfDestructJobsPath(), []);
+ }
+
+ private function saveSelfDestructJobs(array $jobs): void
+ {
+ $this->writeJsonFileAtomic($this->selfDestructJobsPath(), $jobs);
+ }
+
+ private function validateSelfDestructHours(?int $hours): void
+ {
+ if ($hours === null) {
+ return;
+ }
+
+ if ($hours < 0 || $hours > 48) {
+ throw new InvalidArgumentException('selfDestructHours must be null or an integer between 0 and 48.');
+ }
+ }
+
+ private function normalizeOptionalId(?string $id): ?string
+ {
+ if ($id === null) {
+ return null;
+ }
+
+ $id = trim($id);
+
+ return $id === '' ? null : $id;
+ }
+
+ private function assertJsonEncodable(mixed $value, string $name): void
+ {
+ try {
+ json_encode($value, JSON_THROW_ON_ERROR);
+ } catch (JsonException $e) {
+ throw new InvalidArgumentException($name . ' must be JSON encodable: ' . $e->getMessage(), 0, $e);
+ }
+ }
+
+ private function createId(string $prefix = ''): string
+ {
+ $id = bin2hex(random_bytes(8));
+
+ return $prefix === '' ? $id : $prefix . '_' . $id;
+ }
+
+ private function ensureDirectory(string $dir): void
+ {
+ if (is_dir($dir)) {
+ return;
+ }
+
+ if (!mkdir($dir, 0777, true) && !is_dir($dir)) {
+ throw new RuntimeException('Unable to create directory: ' . $dir);
+ }
+ }
+
+ private function deleteFile(string $file): void
+ {
+ try {
+ if (is_file($file)) {
+ @unlink($file);
+ }
+ } catch (Throwable $e) {
+ $this->logError('Failed to delete file.', $e, ['file' => $file]);
+ }
+ }
+}
diff --git a/patches/broadcastmanager/previous/dev8/BroadcastManager.php b/patches/broadcastmanager/previous/dev8/BroadcastManager.php
new file mode 100644
index 0000000..61bd16e
--- /dev/null
+++ b/patches/broadcastmanager/previous/dev8/BroadcastManager.php
@@ -0,0 +1,2016 @@
+
+ * @copyright - WizardLoop
+ * @license - https://opensource.org/licenses/AGPL-3.0 AGPLv3
+ */
+
+namespace BroadcastTool;
+
+use danog\MadelineProto\API;
+use danog\MadelineProto\RPCErrorException;
+use InvalidArgumentException;
+use JsonException;
+use RuntimeException;
+use SplQueue;
+use Throwable;
+
+class BroadcastManager
+{
+ private const MAX_ATTEMPTS = 3;
+ private const DEFAULT_CONCURRENCY = 10;
+ private const MAX_CONCURRENCY = 30;
+ private const PROGRESS_UPDATE_INTERVAL = 5.0;
+ private const WORKER_IDLE_SLEEP = 1.0;
+ private const WORKER_RETRY_SLEEP = 1.0;
+ private const WORKER_PAUSED_SLEEP = 1.5;
+ private const WORKER_AFTER_JOB_SLEEP = 0.75;
+ private const SEND_MESSAGE_SLEEP = 0.35;
+ private const SEND_CHUNK_SLEEP = 0.75;
+
+ private const SEND_HARD_FAIL_RPCS = [
+ 'INPUT_USER_DEACTIVATED',
+ 'USER_IS_BOT',
+ 'CHAT_WRITE_FORBIDDEN',
+ 'USER_IS_BLOCKED',
+ 'PEER_ID_INVALID',
+ ];
+
+ private const EDIT_HARD_FAIL_RPCS = [
+ 'INPUT_USER_DEACTIVATED',
+ 'USER_IS_BOT',
+ 'CHAT_WRITE_FORBIDDEN',
+ 'USER_IS_BLOCKED',
+ 'PEER_ID_INVALID',
+ 'MESSAGE_ID_INVALID',
+ 'MESSAGE_EDIT_TIME_EXPIRED',
+ 'MESSAGE_AUTHOR_REQUIRED',
+ ];
+
+ private const DELETE_HARD_FAIL_RPCS = [
+ 'INPUT_USER_DEACTIVATED',
+ 'USER_IS_BOT',
+ 'CHAT_WRITE_FORBIDDEN',
+ 'USER_IS_BLOCKED',
+ 'PEER_ID_INVALID',
+ 'MESSAGE_ID_INVALID',
+ ];
+
+ private ?\Closure $messageTransport = null;
+ private ?\Closure $statusFormatter = null;
+ private ?\Closure $statusControlsFormatter = null;
+ private API $api;
+ private array $currentBroadcastState = [];
+ private static array $sharedBroadcastState = [];
+ private static string $dataDir = '';
+
+ public function __construct(API $api)
+ {
+ $this->api = $api;
+ }
+
+ /** Optional per-instance presentation hook; default upstream output is unchanged. */
+ public function setStatusFormatter(?callable $formatter): void
+ {
+ $this->statusFormatter = $formatter === null ? null : \Closure::fromCallable($formatter);
+ }
+
+ /** Optional native-markup hook. Preserve each supplied bm callback identifier. */
+ public function setStatusControlsFormatter(?callable $formatter): void
+ {
+ $this->statusControlsFormatter = $formatter === null ? null : \Closure::fromCallable($formatter);
+ }
+
+ private function formatStatus(string $event, string $default, array $context): string
+ {
+ if ($this->statusFormatter === null) {
+ return $default;
+ }
+ $text = ($this->statusFormatter)($event, $context + ['default' => $default]);
+ if (!is_string($text) || trim($text) === '' || !mb_check_encoding($text, 'UTF-8')
+ || strlen(mb_convert_encoding($text, 'UTF-16LE', 'UTF-8')) > 8192) {
+ throw new InvalidArgumentException('Status formatter must return bounded non-empty UTF-8 text.');
+ }
+ return $text;
+ }
+ /** Optional per-instance message adapter; null preserves exact native dispatch. */
+ public function setMessageTransport(?callable $transport): void
+ {
+ $this->messageTransport = $transport === null ? null : \Closure::fromCallable($transport);
+ }
+
+ private function callMessage(string $method, array $parameters): mixed
+ {
+ if (!in_array($method, ['sendMessage', 'sendMedia', 'sendMultiMedia', 'editMessage'], true)) {
+ throw new InvalidArgumentException('Unsupported broadcast message transport method.');
+ }
+ return $this->messageTransport === null
+ ? $this->api->messages->{$method}($parameters)
+ : ($this->messageTransport)($method, $parameters);
+ }
+ /**
+ * Set data dir.
+ */
+ public static function setDataDir(string $path): void
+ {
+ self::$dataDir = rtrim($path, '/\\');
+ }
+
+ /**
+ * Get data dir.
+ */
+ private static function getDataDir(): string
+ {
+ if (!self::$dataDir) {
+ self::$dataDir = __DIR__ . '/../data';
+ }
+
+ return self::$dataDir;
+ }
+
+ /**
+ * Send broadcast.
+ *
+ * @return string ID that can be used for progress/pause/resume/cancel.
+ */
+ public function broadcastWithProgress(
+ array $allUsers,
+ array $messages,
+ $chatId = null,
+ bool $pin = false,
+ int $concurrency = self::DEFAULT_CONCURRENCY,
+ ?int $selfDestructHours = null
+ ): string {
+ $this->validateSelfDestructHours($selfDestructHours);
+
+ $concurrency = $this->clampConcurrency($concurrency);
+ $total = count($allUsers);
+ $broadcastId = $this->createId();
+ $state = $this->createState($broadcastId, 'send', $total, [
+ 'selfDestruct' => [
+ 'enabled' => $selfDestructHours !== null,
+ 'hours' => $selfDestructHours,
+ 'deleteAt' => null,
+ 'deleteJobId' => null,
+ ],
+ ]);
+
+ $this->registerCurrentState($broadcastId, $state);
+ $this->enqueuePeers($state, $allUsers);
+ $this->initializeBroadcastMetadata($broadcastId, 'send', $total, $selfDestructHours);
+
+ $statusId = $this->sendStatusMessage($chatId, $this->formatStatus('starting', 'Gathering peers...', ['type' => $state['type'], 'id' => $state['id'], 'total' => $state['total']]), $this->buildStatusControls($state));
+ $this->startProgressLoop($chatId, $statusId, $state, 'Broadcast Progress');
+
+ $this->startQueueWorkers(
+ $state,
+ $concurrency,
+ function (array $job, array &$state) use ($messages, $pin, $broadcastId): void {
+ $peer = (string) $job['peer'];
+ $messageIds = $this->sendMessagesToPeer($peer, $messages);
+ $lastMessageId = $messageIds ? end($messageIds) : null;
+
+ if ($pin && $lastMessageId) {
+ $this->api->messages->updatePinnedMessage([
+ 'peer' => $peer,
+ 'id' => $lastMessageId,
+ 'unpin' => false,
+ ]);
+ }
+
+ $state['sent']++;
+ $state['lastMessageIds'][$peer] = $lastMessageId ? (string) $lastMessageId : '';
+
+ if ($messageIds) {
+ $this->savePeerMessageIds($peer, $messageIds);
+ }
+
+ $this->saveBroadcastPeerMessageIds(
+ $broadcastId,
+ $peer,
+ $messageIds,
+ (int) $state['sent'],
+ (int) $state['failed']
+ );
+ },
+ self::SEND_HARD_FAIL_RPCS
+ );
+
+ $this->waitForCompletion($state);
+ $state['status'] = $state['cancel'] ? 'cancelled' : 'done';
+
+ $this->finalizeBroadcastMetadata(
+ $broadcastId,
+ (string) $state['status'],
+ (int) $state['sent'],
+ (int) $state['failed']
+ );
+
+ $selfDestructJobId = null;
+ if ($selfDestructHours !== null) {
+ $selfDestructJobId = $this->createSelfDestructJob($broadcastId, $selfDestructHours, $chatId, $concurrency);
+ if ($selfDestructJobId !== null) {
+ $metadata = $this->loadBroadcastMetadata($broadcastId);
+ $state['selfDestruct'] = $metadata['selfDestruct'] ?? $state['selfDestruct'];
+ }
+
+ if ($selfDestructJobId !== null && $selfDestructHours === 0) {
+ $this->runSelfDestructJob($selfDestructJobId);
+ }
+ }
+
+ $finalText = $this->buildProgressText($state, 'Broadcast Progress', true);
+ $this->editStatusMessage($chatId, $statusId, $finalText);
+ $this->writeLastBroadcastData($finalText);
+
+ return $broadcastId;
+ }
+
+ /**
+ * Edit the last saved broadcast message for all users.
+ */
+ public function editLastBroadcastForAll(
+ array $allUsers,
+ string $newText,
+ $chatId = null,
+ ?array $buttons = null,
+ $media = null,
+ int $concurrency = self::DEFAULT_CONCURRENCY,
+ string $parseMode = 'HTML',
+ ?string $broadcastId = null
+ ): string {
+ $concurrency = $this->clampConcurrency($concurrency);
+ $targetBroadcastId = $this->normalizeOptionalId($broadcastId);
+ $targets = $targetBroadcastId !== null && $allUsers === []
+ ? $this->broadcastMetadataPeers($targetBroadcastId)
+ : $allUsers;
+ $operationId = $this->createId();
+ $state = $this->createState($operationId, 'edit', count($targets), [
+ 'targetBroadcastId' => $targetBroadcastId,
+ ]);
+
+ $this->registerCurrentState($operationId, $state);
+ $this->enqueuePeers($state, $targets);
+
+ $statusId = $this->sendStatusMessage($chatId, $this->formatStatus('starting', 'Editing last broadcast...', ['type' => $state['type'], 'id' => $state['id'], 'total' => $state['total']]), $this->buildStatusControls($state));
+ $this->startProgressLoop($chatId, $statusId, $state, 'Editing Last Broadcast');
+
+ $this->startQueueWorkers(
+ $state,
+ $concurrency,
+ function (array $job, array &$state) use ($newText, $buttons, $media, $parseMode, $targetBroadcastId): void {
+ $peer = (string) $job['peer'];
+ $lastMessageId = $targetBroadcastId !== null
+ ? $this->readBroadcastLastMessageId($targetBroadcastId, $peer)
+ : $this->readLastBroadcastMessageId($peer);
+
+ if ($lastMessageId <= 0) {
+ $state['failed']++;
+ return;
+ }
+
+ $payload = [
+ 'peer' => $peer,
+ 'id' => $lastMessageId,
+ 'message' => $newText,
+ 'parse_mode' => $parseMode,
+ 'floodWaitLimit' => 172800,
+ ];
+
+ if ($buttons !== null) {
+ $payload['reply_markup'] = $buttons;
+ }
+
+ if ($media !== null) {
+ $payload['media'] = $media;
+ }
+
+ $this->callMessage('editMessage', $payload);
+ $state['edited']++;
+ },
+ self::EDIT_HARD_FAIL_RPCS,
+ function (RPCErrorException $e, array &$job, array &$state): bool {
+ unset($job);
+
+ if (($e->rpc ?? '') === 'MESSAGE_NOT_MODIFIED' || str_contains($e->getMessage(), 'MESSAGE_NOT_MODIFIED')) {
+ $state['unchanged']++;
+ return true;
+ }
+
+ return false;
+ }
+ );
+
+ $this->waitForCompletion($state);
+ $state['status'] = $state['cancel'] ? 'cancelled' : 'done';
+
+ $this->editStatusMessage($chatId, $statusId, $this->buildProgressText($state, 'Editing Last Broadcast', true));
+
+ return $operationId;
+ }
+
+ /**
+ * Schedule a broadcast. Due broadcasts can be run with runDueScheduledBroadcasts().
+ */
+ public function scheduleBroadcastForAll(
+ array $allUsers,
+ array $messages,
+ int $scheduledAt,
+ $chatId = null,
+ bool $pin = false,
+ int $concurrency = self::DEFAULT_CONCURRENCY,
+ ?int $selfDestructHours = null
+ ): string {
+ $this->validateSelfDestructHours($selfDestructHours);
+ $this->assertJsonEncodable($allUsers, 'allUsers');
+ $this->assertJsonEncodable($messages, 'messages');
+
+ $concurrency = $this->clampConcurrency($concurrency);
+ $scheduleId = $this->createId('schedule');
+ $jobs = $this->loadScheduledBroadcasts();
+
+ $jobs[$scheduleId] = [
+ 'id' => $scheduleId,
+ 'status' => 'scheduled',
+ 'scheduledAt' => $scheduledAt,
+ 'createdAt' => time(),
+ 'allUsers' => array_values($allUsers),
+ 'messages' => array_values($messages),
+ 'chatId' => $chatId,
+ 'pin' => $pin,
+ 'concurrency' => $concurrency,
+ 'selfDestructHours' => $selfDestructHours,
+ 'broadcastId' => null,
+ 'error' => null,
+ ];
+
+ $this->saveScheduledBroadcasts($jobs);
+
+ if ($scheduledAt <= time()) {
+ $this->runScheduledBroadcast($scheduleId);
+ }
+
+ return $scheduleId;
+ }
+
+ /**
+ * Run all scheduled broadcasts whose scheduledAt timestamp has passed.
+ */
+ public function runDueScheduledBroadcasts(): array
+ {
+ $jobs = $this->loadScheduledBroadcasts();
+ $results = [];
+ $now = time();
+
+ foreach ($jobs as $scheduleId => $job) {
+ if (($job['status'] ?? null) !== 'scheduled') {
+ continue;
+ }
+
+ if ((int) ($job['scheduledAt'] ?? 0) > $now) {
+ continue;
+ }
+
+ $results[$scheduleId] = $this->runScheduledBroadcast((string) $scheduleId);
+ }
+
+ return $results;
+ }
+
+ public function cancelScheduledBroadcast(string $scheduleId): bool
+ {
+ $jobs = $this->loadScheduledBroadcasts();
+
+ if (!isset($jobs[$scheduleId]) || ($jobs[$scheduleId]['status'] ?? null) !== 'scheduled') {
+ return false;
+ }
+
+ $jobs[$scheduleId]['status'] = 'cancelled';
+ $jobs[$scheduleId]['cancelledAt'] = time();
+ $this->saveScheduledBroadcasts($jobs);
+
+ return true;
+ }
+
+ public function listScheduledBroadcasts(): array
+ {
+ $items = [];
+
+ foreach ($this->loadScheduledBroadcasts() as $id => $job) {
+ $items[$id] = [
+ 'id' => (string) ($job['id'] ?? $id),
+ 'status' => (string) ($job['status'] ?? 'unknown'),
+ 'scheduledAt' => (int) ($job['scheduledAt'] ?? 0),
+ 'createdAt' => (int) ($job['createdAt'] ?? 0),
+ 'broadcastId' => $job['broadcastId'] ?? null,
+ 'pin' => (bool) ($job['pin'] ?? false),
+ 'concurrency' => (int) ($job['concurrency'] ?? self::DEFAULT_CONCURRENCY),
+ 'selfDestructHours' => $job['selfDestructHours'] ?? null,
+ 'totalUsers' => is_array($job['allUsers'] ?? null) ? count($job['allUsers']) : 0,
+ 'totalMessages' => is_array($job['messages'] ?? null) ? count($job['messages']) : 0,
+ 'error' => $job['error'] ?? null,
+ ];
+ }
+
+ return $items;
+ }
+
+ /**
+ * Deletes the last broadcast message for all users.
+ *
+ * @return string ID that can be used for progress/pause/resume/cancel.
+ */
+ public function deleteLastBroadcastForAll(
+ array $allUsers,
+ $chatId = null,
+ int $concurrency = self::DEFAULT_CONCURRENCY,
+ ?string $broadcastId = null
+ ): string {
+ $concurrency = $this->clampConcurrency($concurrency);
+ $targetBroadcastId = $this->normalizeOptionalId($broadcastId);
+ $targets = $targetBroadcastId !== null && $allUsers === []
+ ? $this->broadcastMetadataPeers($targetBroadcastId)
+ : $allUsers;
+ $operationId = $this->createId();
+ $state = $this->createState($operationId, 'deletelast', count($targets), [
+ 'targetBroadcastId' => $targetBroadcastId,
+ ]);
+
+ $this->registerCurrentState($operationId, $state);
+ $this->enqueuePeers($state, $targets);
+
+ $statusId = $this->sendStatusMessage($chatId, $this->formatStatus('starting', 'Deleting last broadcast...', ['type' => $state['type'], 'id' => $state['id'], 'total' => $state['total']]), $this->buildStatusControls($state));
+ $this->startProgressLoop($chatId, $statusId, $state, 'Deleting Last Broadcast');
+
+ $this->startQueueWorkers(
+ $state,
+ $concurrency,
+ function (array $job, array &$state) use ($targetBroadcastId): void {
+ $peer = (string) $job['peer'];
+ $lastMessageId = $targetBroadcastId !== null
+ ? $this->readBroadcastLastMessageId($targetBroadcastId, $peer)
+ : $this->readLastBroadcastMessageId($peer);
+
+ if ($lastMessageId <= 0) {
+ $state['failed']++;
+ return;
+ }
+
+ $this->api->messages->deleteMessages([
+ 'peer' => $peer,
+ 'id' => [$lastMessageId],
+ 'revoke' => true,
+ ]);
+
+ if ($targetBroadcastId !== null) {
+ $this->markBroadcastPeerMessageDeleted($targetBroadcastId, $peer, $lastMessageId);
+ } else {
+ $this->deleteFile($this->peerDataPath($peer, 'lastBroadcast.txt'));
+ }
+
+ $state['deleted']++;
+ },
+ self::DELETE_HARD_FAIL_RPCS
+ );
+
+ $this->waitForCompletion($state);
+ $state['status'] = $state['cancel'] ? 'cancelled' : 'done';
+
+ $this->editStatusMessage($chatId, $statusId, $this->buildProgressText($state, 'Deleting Last Broadcast', true));
+
+ return $operationId;
+ }
+
+ /**
+ * Deletes all broadcast messages for all users.
+ *
+ * @return string ID that can be used for progress/pause/resume/cancel.
+ */
+ public function deleteAllBroadcastsForAll(
+ array $allUsers,
+ $chatId = null,
+ int $concurrency = self::DEFAULT_CONCURRENCY
+ ): string {
+ $concurrency = $this->clampConcurrency($concurrency);
+ $broadcastId = $this->createId();
+ $state = $this->createState($broadcastId, 'deleteall', count($allUsers));
+
+ $this->registerCurrentState($broadcastId, $state);
+ $this->enqueuePeers($state, $allUsers);
+
+ $statusId = $this->sendStatusMessage($chatId, $this->formatStatus('starting', 'Deleting all broadcasts...', ['type' => $state['type'], 'id' => $state['id'], 'total' => $state['total']]), $this->buildStatusControls($state));
+ $this->startProgressLoop($chatId, $statusId, $state, 'Deleting All Broadcasts');
+
+ $this->startQueueWorkers(
+ $state,
+ $concurrency,
+ function (array $job, array &$state): void {
+ $peer = (string) $job['peer'];
+ $file = $this->peerDataPath($peer, 'messages.txt');
+
+ if (!is_file($file)) {
+ $state['failed']++;
+ return;
+ }
+
+ $msgIds = array_values(array_filter(
+ array_map('intval', explode("\n", trim((string) file_get_contents($file)))),
+ static fn (int $id): bool => $id > 0
+ ));
+
+ if (!$msgIds) {
+ $state['failed']++;
+ return;
+ }
+
+ $userDeleted = false;
+
+ foreach ($msgIds as $messageId) {
+ try {
+ $this->api->messages->deleteMessages([
+ 'peer' => $peer,
+ 'id' => [$messageId],
+ 'revoke' => true,
+ ]);
+ $userDeleted = true;
+ } catch (RPCErrorException $e) {
+ $message = $e->getMessage();
+
+ if ($this->parseFloodWait($e) !== null) {
+ throw $e;
+ }
+
+ if (str_contains($message, 'USER_IS_BLOCKED') || str_contains($message, 'PEER_ID_INVALID')) {
+ continue;
+ }
+
+ throw $e;
+ }
+ }
+
+ if ($userDeleted) {
+ $state['deleted']++;
+ } else {
+ $state['failed']++;
+ }
+
+ $this->deleteFile($this->peerDataPath($peer, 'lastBroadcast.txt'));
+ $this->deleteFile($file);
+ },
+ self::DELETE_HARD_FAIL_RPCS,
+ null,
+ true
+ );
+
+ $this->waitForCompletion($state);
+ $state['status'] = $state['cancel'] ? 'cancelled' : 'done';
+
+ $this->editStatusMessage($chatId, $statusId, $this->buildProgressText($state, 'Deleting All Broadcasts', true));
+
+ return $broadcastId;
+ }
+
+ /**
+ * Unpin all messages for all users.
+ *
+ * @return string ID that can be used for progress/pause/resume/cancel.
+ */
+ public function unpinAllMessagesForAll(
+ array $allUsers,
+ $chatId = null,
+ int $concurrency = self::DEFAULT_CONCURRENCY
+ ): string {
+ $concurrency = $this->clampConcurrency($concurrency);
+ $broadcastId = $this->createId();
+ $state = $this->createState($broadcastId, 'unpin', count($allUsers));
+
+ $this->registerCurrentState($broadcastId, $state);
+ $this->enqueuePeers($state, $allUsers);
+
+ $statusId = $this->sendStatusMessage($chatId, $this->formatStatus('starting', 'Starting unpin...', ['type' => $state['type'], 'id' => $state['id'], 'total' => $state['total']]), $this->buildStatusControls($state));
+ $this->startProgressLoop($chatId, $statusId, $state, 'Unpinning Messages');
+
+ $this->startQueueWorkers(
+ $state,
+ $concurrency,
+ function (array $job, array &$state): void {
+ $this->api->messages->unpinAllMessages([
+ 'peer' => (string) $job['peer'],
+ ]);
+
+ $state['unpin']++;
+ },
+ self::SEND_HARD_FAIL_RPCS
+ );
+
+ $this->waitForCompletion($state);
+ $state['status'] = $state['cancel'] ? 'cancelled' : 'done';
+
+ $this->editStatusMessage($chatId, $statusId, $this->buildProgressText($state, 'Unpinning Messages', true));
+
+ return $broadcastId;
+ }
+
+ public function runDueSelfDestructJobs(): array
+ {
+ $jobs = $this->loadSelfDestructJobs();
+ $results = [];
+ $now = time();
+
+ foreach ($jobs as $jobId => $job) {
+ if (($job['status'] ?? null) !== 'scheduled') {
+ continue;
+ }
+
+ if ((int) ($job['deleteAt'] ?? 0) > $now) {
+ continue;
+ }
+
+ $results[$jobId] = $this->runSelfDestructJob((string) $jobId);
+ }
+
+ return $results;
+ }
+
+ public function cancelSelfDestructJob(string $jobId): bool
+ {
+ $jobs = $this->loadSelfDestructJobs();
+
+ if (!isset($jobs[$jobId]) || ($jobs[$jobId]['status'] ?? null) !== 'scheduled') {
+ return false;
+ }
+
+ $jobs[$jobId]['status'] = 'cancelled';
+ $jobs[$jobId]['cancelledAt'] = time();
+ $this->saveSelfDestructJobs($jobs);
+
+ return true;
+ }
+
+ public function listSelfDestructJobs(): array
+ {
+ $items = [];
+
+ foreach ($this->loadSelfDestructJobs() as $id => $job) {
+ $items[$id] = [
+ 'id' => (string) ($job['id'] ?? $id),
+ 'broadcastId' => (string) ($job['broadcastId'] ?? ''),
+ 'status' => (string) ($job['status'] ?? 'unknown'),
+ 'deleteAt' => (int) ($job['deleteAt'] ?? 0),
+ 'createdAt' => (int) ($job['createdAt'] ?? 0),
+ 'concurrency' => (int) ($job['concurrency'] ?? self::DEFAULT_CONCURRENCY),
+ 'chatId' => $job['chatId'] ?? null,
+ 'totalPeers' => (int) ($job['totalPeers'] ?? 0),
+ 'stats' => $job['stats'] ?? null,
+ 'error' => $job['error'] ?? null,
+ ];
+ }
+
+ return $items;
+ }
+
+ /**
+ * Pause running broadcast.
+ */
+ public function pause(string $id): void
+ {
+ if (isset(self::$sharedBroadcastState[$id])) {
+ self::$sharedBroadcastState[$id]['paused'] = true;
+ }
+
+ if (isset($this->currentBroadcastState[$id])) {
+ $this->currentBroadcastState[$id]['paused'] = true;
+ }
+ }
+
+ /**
+ * Resume running broadcast.
+ */
+ public function resume(string $id): void
+ {
+ if (isset(self::$sharedBroadcastState[$id])) {
+ self::$sharedBroadcastState[$id]['paused'] = false;
+ }
+
+ if (isset($this->currentBroadcastState[$id])) {
+ $this->currentBroadcastState[$id]['paused'] = false;
+ }
+ }
+
+ /**
+ * Cancel running broadcast.
+ */
+ public function cancel(string $id): void
+ {
+ if (isset(self::$sharedBroadcastState[$id])) {
+ self::$sharedBroadcastState[$id]['cancel'] = true;
+ }
+
+ if (isset($this->currentBroadcastState[$id])) {
+ $this->currentBroadcastState[$id]['cancel'] = true;
+ }
+ }
+
+ /**
+ * Check if broadcast is paused.
+ */
+ public function isPaused(string $id): bool
+ {
+ return self::$sharedBroadcastState[$id]['paused'] ?? $this->currentBroadcastState[$id]['paused'] ?? false;
+ }
+
+ /**
+ * Check if broadcast is cancelled.
+ */
+ public function isCancelled(string $id): bool
+ {
+ return self::$sharedBroadcastState[$id]['cancel'] ?? $this->currentBroadcastState[$id]['cancel'] ?? false;
+ }
+
+ /**
+ * Check if broadcast is active.
+ */
+ public function isActive(?string $id = null): bool
+ {
+ if (!$id || (!isset(self::$sharedBroadcastState[$id]) && !isset($this->currentBroadcastState[$id]))) {
+ return false;
+ }
+
+ $state = self::$sharedBroadcastState[$id] ?? $this->currentBroadcastState[$id];
+
+ return (
+ empty($state['done'])
+ && empty($state['cancel'])
+ && empty($state['paused'])
+ );
+ }
+
+ /**
+ * Check if there is a last broadcast message saved for deletion.
+ */
+ public function hasLastBroadcast(): bool
+ {
+ foreach (glob(self::getDataDir() . '/*/lastBroadcast.txt') ?: [] as $file) {
+ if (is_file($file) && trim((string) file_get_contents($file)) !== '') {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Check if there are broadcast messages saved for deletion.
+ */
+ public function hasAllBroadcast(): bool
+ {
+ foreach (glob(self::getDataDir() . '/*/messages.txt') ?: [] as $file) {
+ if (is_file($file) && trim((string) file_get_contents($file)) !== '') {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Get current broadcast progress.
+ */
+ public function progress(?string $id = null): ?array
+ {
+ if (!$id || (!isset(self::$sharedBroadcastState[$id]) && !isset($this->currentBroadcastState[$id]))) {
+ return null;
+ }
+
+ $state = $this->normalizeBroadcastState(self::$sharedBroadcastState[$id] ?? $this->currentBroadcastState[$id]);
+
+ $sent = (int) $state['sent'];
+ $deleted = (int) $state['deleted'];
+ $unpin = (int) $state['unpin'];
+ $edited = (int) $state['edited'];
+ $unchanged = (int) $state['unchanged'];
+ $scheduled = (int) $state['scheduled'];
+ $failed = (int) $state['failed'];
+ $flood = (int) $state['flood'];
+
+ $processed = $this->processedCount($state);
+ $success = $sent + $deleted + $unpin + $edited + $unchanged;
+ $pending = $this->pendingCount($state, $processed);
+ $total = (int) $state['total'];
+ $elapsed = $this->elapsedSeconds($state);
+ $tps = $elapsed > 0 ? round($success / $elapsed, 2) : 0.0;
+
+ $progressPercent = $total > 0
+ ? round(($processed / $total) * 100, 2)
+ : 0.0;
+
+ return [
+ 'processed' => $processed,
+ 'success' => $success,
+ 'failed' => $failed,
+ 'pending' => $pending,
+ 'flood' => $flood,
+ 'progressPercent' => $progressPercent,
+ 'breakdown' => [
+ 'sent' => $sent,
+ 'deleted' => $deleted,
+ 'unpin' => $unpin,
+ 'edited' => $edited,
+ 'unchanged' => $unchanged,
+ 'scheduled' => $scheduled,
+ ],
+ 'edited' => $edited,
+ 'unchanged' => $unchanged,
+ 'scheduled' => $scheduled,
+ 'selfDestruct' => $state['selfDestruct'],
+ 'type' => $state['type'],
+ 'total' => $total,
+ 'elapsed' => $elapsed,
+ 'tps' => $tps,
+ 'done' => (bool) $state['done'],
+ 'paused' => (bool) $state['paused'],
+ 'cancel' => (bool) $state['cancel'],
+ 'startedAt' => $state['startedAt'],
+ ];
+ }
+
+ /**
+ * Last broadcast data.
+ */
+ public function lastBroadcastData(): string|false
+ {
+ $dir = self::getDataDir();
+
+ if (!is_dir($dir)) {
+ mkdir($dir, 0777, true);
+ }
+
+ $path = $dir . '/LastBrodDATA.txt';
+
+ if (!is_file($path)) {
+ return false;
+ }
+
+ $last = array_key_last(self::$sharedBroadcastState);
+ return $this->formatStatus('last_data', (string) file_get_contents($path), [
+ 'progress' => $last === null ? null : $this->progress((string) $last),
+ ]);
+ }
+
+ /**
+ * Filter peers.
+ *
+ * allowedTypes: all / users / groups / channels
+ *
+ * @return array{targets: array, failed: int, total: int}
+ */
+ public function filterPeers(
+ array $allUsers,
+ string $filterType = 'users'
+ ): array {
+ $allowedTypes = [
+ 'all' => ['user', 'chat', 'supergroup', 'channel'],
+ 'users' => ['user'],
+ 'groups' => ['chat', 'supergroup'],
+ 'channels' => ['channel'],
+ ];
+
+ $targets = [];
+ $failedCount = 0;
+
+ foreach ($allUsers as $peer) {
+ try {
+ $info = $this->api->getInfo($peer);
+ $type = $info['type'] ?? 'user';
+
+ if (in_array($type, $allowedTypes[$filterType] ?? ['user'], true)) {
+ $targets[] = (string) $peer;
+ }
+ } catch (Throwable $e) {
+ if ($filterType === 'all') {
+ $targets[] = (string) $peer;
+ } else {
+ $failedCount++;
+ $this->logError('Failed to inspect peer type.', $e, ['peer' => (string) $peer]);
+ }
+ }
+ }
+
+ return [
+ 'targets' => $targets,
+ 'failed' => $failedCount,
+ 'total' => count($targets),
+ ];
+ }
+
+ private function createState(string $id, string $type, int $total, array $extra = []): array
+ {
+ return $extra + [
+ 'id' => $id,
+ 'type' => $type,
+ 'status' => 'running',
+ 'total' => max(0, $total),
+ 'sent' => 0,
+ 'deleted' => 0,
+ 'unpin' => 0,
+ 'edited' => 0,
+ 'unchanged' => 0,
+ 'scheduled' => 0,
+ 'failed' => 0,
+ 'flood' => 0,
+ 'queue' => new SplQueue(),
+ 'inFlight' => [],
+ 'lastMessageIds' => [],
+ 'paused' => false,
+ 'cancel' => false,
+ 'done' => false,
+ 'startedAt' => microtime(true),
+ 'selfDestruct' => null,
+ ];
+ }
+
+ private function registerCurrentState(string $id, array &$state): void
+ {
+ $this->currentBroadcastState[$id] =& $state;
+ self::$sharedBroadcastState[$id] =& $state;
+ }
+
+ private function enqueuePeers(array &$state, array $peers): void
+ {
+ foreach ($peers as $peer) {
+ $state['queue']->enqueue([
+ 'peer' => (string) $peer,
+ 'attempts' => 0,
+ 'startedAt' => null,
+ 'availableAt' => 0.0,
+ ]);
+ }
+ }
+
+ private function clampConcurrency(int $concurrency): int
+ {
+ return max(1, min(self::MAX_CONCURRENCY, $concurrency));
+ }
+
+ private function startQueueWorkers(
+ array &$state,
+ int $concurrency,
+ callable $handler,
+ array $hardFailRpcs = [],
+ ?callable $rpcHandler = null,
+ bool $retryThrowable = false
+ ): void {
+ $concurrency = $this->clampConcurrency($concurrency);
+
+ for ($i = 0; $i < $concurrency; $i++) {
+ \Amp\async(function () use (&$state, $handler, $hardFailRpcs, $rpcHandler, $retryThrowable): void {
+ while (!$state['cancel'] && !$state['done']) {
+ if ($state['queue']->isEmpty()) {
+ $this->api->sleep(self::WORKER_IDLE_SLEEP);
+ continue;
+ }
+
+ if ($state['paused']) {
+ $this->api->sleep(self::WORKER_PAUSED_SLEEP);
+ continue;
+ }
+
+ $job = $state['queue']->dequeue();
+
+ if (($job['availableAt'] ?? 0) > microtime(true)) {
+ $state['queue']->enqueue($job);
+ $this->api->sleep(self::WORKER_RETRY_SLEEP);
+ continue;
+ }
+
+ while ($state['paused'] && !$state['cancel'] && !$state['done']) {
+ $this->api->sleep(self::WORKER_PAUSED_SLEEP);
+ }
+
+ if ($state['cancel'] || $state['done']) {
+ continue;
+ }
+
+ $peer = (string) $job['peer'];
+ $job['startedAt'] = microtime(true);
+ $state['inFlight'][$peer] = $job;
+
+ try {
+ $handler($job, $state);
+ unset($state['inFlight'][$peer]);
+ } catch (RPCErrorException $e) {
+ unset($state['inFlight'][$peer]);
+
+ if ($rpcHandler !== null && $rpcHandler($e, $job, $state)) {
+ continue;
+ }
+
+ if ($this->handleRpcRetry($e, $job, $state, $hardFailRpcs)) {
+ continue;
+ }
+
+ $this->logError('RPC error during broadcast job.', $e, [
+ 'type' => (string) ($state['type'] ?? ''),
+ 'peer' => $peer,
+ ]);
+ $this->retryOrFail($job, $state);
+ } catch (Throwable $e) {
+ unset($state['inFlight'][$peer]);
+ $this->logError('Unexpected error during broadcast job.', $e, [
+ 'type' => (string) ($state['type'] ?? ''),
+ 'peer' => $peer,
+ ]);
+
+ if ($retryThrowable) {
+ $this->retryOrFail($job, $state);
+ } else {
+ $state['failed']++;
+ }
+ }
+
+ $this->api->sleep(self::WORKER_AFTER_JOB_SLEEP);
+ }
+ });
+ }
+ }
+
+ private function waitForCompletion(array &$state): void
+ {
+ while (true) {
+ if ($state['queue']->isEmpty() && empty($state['inFlight'])) {
+ break;
+ }
+
+ if ($state['cancel'] && empty($state['inFlight'])) {
+ break;
+ }
+
+ $this->api->sleep(1);
+ }
+
+ $state['done'] = true;
+ }
+
+ private function handleRpcRetry(RPCErrorException $e, array &$job, array &$state, array $hardFailRpcs): bool
+ {
+ if ($this->isHardFailRpc((string) ($e->rpc ?? ''), $hardFailRpcs)) {
+ $state['failed']++;
+ return true;
+ }
+
+ $floodWait = $this->parseFloodWait($e);
+ if ($floodWait !== null) {
+ $state['flood']++;
+ $job['attempts']++;
+ $job['availableAt'] = microtime(true) + $floodWait;
+ $job['startedAt'] = null;
+
+ if ($job['attempts'] >= self::MAX_ATTEMPTS) {
+ $state['failed']++;
+ } else {
+ $state['queue']->enqueue($job);
+ }
+
+ return true;
+ }
+
+ return false;
+ }
+
+ private function isHardFailRpc(string $rpc, array $hardFailRpcs = self::SEND_HARD_FAIL_RPCS): bool
+ {
+ return in_array($rpc, $hardFailRpcs, true);
+ }
+
+ private function parseFloodWait(RPCErrorException $e): ?int
+ {
+ if (preg_match('/FLOOD_WAIT_(\d+)/', $e->getMessage(), $m)) {
+ return (int) $m[1];
+ }
+
+ return null;
+ }
+
+ private function retryOrFail(array &$job, array &$state): void
+ {
+ $job['attempts']++;
+ $job['startedAt'] = null;
+
+ if ($job['attempts'] >= self::MAX_ATTEMPTS) {
+ $state['failed']++;
+ return;
+ }
+
+ $state['queue']->enqueue($job);
+ }
+
+ private function sendMessagesToPeer(string $peer, array $messages): array
+ {
+ $messageIds = [];
+ $albumMessages = [];
+
+ foreach ($messages as $message) {
+ if (isset($message['albumFile']) && is_file((string) $message['albumFile'])) {
+ $decoded = json_decode((string) file_get_contents((string) $message['albumFile']), true);
+ $albumMessages = is_array($decoded) ? $decoded : [];
+ }
+ }
+
+ if ($albumMessages) {
+ foreach (array_chunk($albumMessages, 10) as $chunk) {
+ $multi = [];
+
+ foreach ($chunk as $item) {
+ $media = ($item['media']['type'] ?? null) === 'photo'
+ ? ['_' => 'inputMediaPhoto', 'id' => $item['media']['botApiFileId']]
+ : ['_' => 'inputMediaDocument', 'id' => $item['media']['botApiFileId']];
+
+ $multi[] = [
+ '_' => 'inputSingleMedia',
+ 'media' => $media,
+ 'message' => $item['caption'] ?? '',
+ 'entities' => $item['entities'] ?? [],
+ ];
+ }
+
+ foreach ($this->callMessage('sendMultiMedia', ['peer' => $peer, 'multi_media' => $multi]) as $update) {
+ $messageId = (int) $this->api->extractMessageId($update);
+
+ if ($messageId > 0) {
+ $messageIds[] = $messageId;
+ }
+ }
+
+ $this->api->sleep(self::SEND_CHUNK_SLEEP);
+ }
+
+ return $messageIds;
+ }
+
+ foreach ($messages as $message) {
+ $method = isset($message['media']) ? 'sendMedia' : 'sendMessage';
+ $payload = $message + [
+ 'peer' => $peer,
+ 'floodWaitLimit' => 172800,
+ ];
+
+ if (isset($message['buttons'])) {
+ $payload['reply_markup'] = $message['buttons'];
+ }
+
+ $result = $this->callMessage($method, $payload);
+ $messageId = (int) $this->api->extractMessageId($result);
+
+ if ($messageId > 0) {
+ $messageIds[] = $messageId;
+ }
+
+ $this->api->sleep(self::SEND_MESSAGE_SLEEP);
+ }
+
+ return $messageIds;
+ }
+
+ private function savePeerMessageIds(string $peer, array $messageIds): void
+ {
+ $messageIds = array_values(array_filter(array_map('intval', $messageIds), static fn (int $id): bool => $id > 0));
+
+ if (!$messageIds) {
+ return;
+ }
+
+ $dir = self::getDataDir() . '/' . $peer;
+ $this->ensureDirectory($dir);
+
+ try {
+ file_put_contents($dir . '/messages.txt', implode("\n", $messageIds) . "\n", FILE_APPEND | LOCK_EX);
+ file_put_contents($dir . '/lastBroadcast.txt', (string) end($messageIds), LOCK_EX);
+ } catch (Throwable $e) {
+ $this->logError('Failed to save peer message ids.', $e, ['peer' => $peer]);
+ }
+ }
+
+ private function initializeBroadcastMetadata(string $broadcastId, string $type, int $total, ?int $selfDestructHours): void
+ {
+ $metadata = [
+ 'id' => $broadcastId,
+ 'type' => $type,
+ 'createdAt' => time(),
+ 'status' => 'running',
+ 'total' => $total,
+ 'sent' => 0,
+ 'failed' => 0,
+ 'peers' => [],
+ 'selfDestruct' => [
+ 'enabled' => $selfDestructHours !== null,
+ 'hours' => $selfDestructHours,
+ 'deleteAt' => null,
+ 'deleteJobId' => null,
+ ],
+ ];
+
+ $this->saveBroadcastMetadata($broadcastId, $metadata);
+ }
+
+ private function saveBroadcastPeerMessageIds(
+ string $broadcastId,
+ string $peer,
+ array $messageIds,
+ int $sent,
+ int $failed
+ ): void {
+ $metadata = $this->loadBroadcastMetadata($broadcastId);
+ $messageIds = array_values(array_filter(array_map('intval', $messageIds), static fn (int $id): bool => $id > 0));
+
+ $metadata['sent'] = $sent;
+ $metadata['failed'] = $failed;
+ if (!isset($metadata['peers']) || !is_array($metadata['peers'])) {
+ $metadata['peers'] = [];
+ }
+
+ $metadata['peers'][$peer] = [
+ 'lastMessageId' => $messageIds ? end($messageIds) : null,
+ 'messageIds' => $messageIds,
+ 'status' => 'sent',
+ ];
+
+ $this->saveBroadcastMetadata($broadcastId, $metadata);
+ }
+
+ private function finalizeBroadcastMetadata(string $broadcastId, string $status, int $sent, int $failed): void
+ {
+ $metadata = $this->loadBroadcastMetadata($broadcastId);
+ $metadata['status'] = $status;
+ $metadata['sent'] = $sent;
+ $metadata['failed'] = $failed;
+ $metadata['finishedAt'] = time();
+
+ $this->saveBroadcastMetadata($broadcastId, $metadata);
+ }
+
+ private function saveBroadcastMetadata(string $broadcastId, array $metadata): void
+ {
+ try {
+ $this->writeJsonFileAtomic($this->broadcastMetadataPath($broadcastId), $metadata);
+ } catch (Throwable $e) {
+ $this->logError('Failed to save broadcast metadata.', $e, ['broadcastId' => $broadcastId]);
+ }
+ }
+
+ private function loadBroadcastMetadata(string $broadcastId): array
+ {
+ return $this->readJsonFile($this->broadcastMetadataPath($broadcastId), [
+ 'id' => $broadcastId,
+ 'type' => 'send',
+ 'createdAt' => time(),
+ 'status' => 'running',
+ 'total' => 0,
+ 'sent' => 0,
+ 'failed' => 0,
+ 'peers' => [],
+ 'selfDestruct' => [
+ 'enabled' => false,
+ 'hours' => null,
+ 'deleteAt' => null,
+ 'deleteJobId' => null,
+ ],
+ ]);
+ }
+
+ private function readJsonFile(string $path, array $default = []): array
+ {
+ if (!is_file($path)) {
+ return $default;
+ }
+
+ try {
+ $content = (string) file_get_contents($path);
+
+ if (trim($content) === '') {
+ return $default;
+ }
+
+ $decoded = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
+ return is_array($decoded) ? $decoded : $default;
+ } catch (Throwable $e) {
+ $this->logError('Failed to read JSON file.', $e, ['path' => $path]);
+ return $default;
+ }
+ }
+
+ private function writeJsonFileAtomic(string $path, array $data): void
+ {
+ $this->ensureDirectory(dirname($path));
+
+ $tmp = $path . '.tmp.' . bin2hex(random_bytes(4));
+
+ try {
+ $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
+
+ if (file_put_contents($tmp, $json . "\n", LOCK_EX) === false) {
+ throw new RuntimeException('Unable to write temporary JSON file.');
+ }
+
+ if (!@rename($tmp, $path)) {
+ if (is_file($path)) {
+ @unlink($path);
+ }
+
+ if (!@rename($tmp, $path)) {
+ throw new RuntimeException('Unable to rename temporary JSON file.');
+ }
+ }
+ } catch (Throwable $e) {
+ if (is_file($tmp)) {
+ @unlink($tmp);
+ }
+
+ $this->logError('Failed to write JSON file atomically.', $e, ['path' => $path]);
+ throw $e;
+ }
+ }
+
+ private function logError(string $message, ?Throwable $e = null, array $context = []): void
+ {
+ try {
+ $this->ensureDirectory(self::getDataDir());
+ $entry = [
+ 'time' => date('c'),
+ 'message' => $message,
+ 'error' => $e ? $e->getMessage() : null,
+ 'rpc' => $e instanceof RPCErrorException ? ($e->rpc ?? null) : null,
+ 'context' => $context,
+ ];
+
+ file_put_contents(
+ self::getDataDir() . '/broadcast-errors.log',
+ json_encode($entry, JSON_UNESCAPED_SLASHES) . "\n",
+ FILE_APPEND | LOCK_EX
+ );
+ } catch (Throwable) {
+ // Logging must never break bot execution.
+ }
+ }
+
+ private function runScheduledBroadcast(string $scheduleId): array
+ {
+ $jobs = $this->loadScheduledBroadcasts();
+
+ if (!isset($jobs[$scheduleId])) {
+ return ['status' => 'missing'];
+ }
+
+ if (($jobs[$scheduleId]['status'] ?? null) !== 'scheduled') {
+ return [
+ 'status' => (string) ($jobs[$scheduleId]['status'] ?? 'unknown'),
+ 'broadcastId' => $jobs[$scheduleId]['broadcastId'] ?? null,
+ ];
+ }
+
+ $jobs[$scheduleId]['status'] = 'running';
+ $jobs[$scheduleId]['startedAt'] = time();
+ $this->saveScheduledBroadcasts($jobs);
+
+ try {
+ $job = $jobs[$scheduleId];
+ $broadcastId = $this->broadcastWithProgress(
+ $job['allUsers'] ?? [],
+ $job['messages'] ?? [],
+ $job['chatId'] ?? null,
+ (bool) ($job['pin'] ?? false),
+ (int) ($job['concurrency'] ?? self::DEFAULT_CONCURRENCY),
+ $job['selfDestructHours'] ?? null
+ );
+
+ $progress = $this->progress($broadcastId);
+ $jobs = $this->loadScheduledBroadcasts();
+ $jobs[$scheduleId]['status'] = ($progress['cancel'] ?? false) ? 'cancelled' : 'done';
+ $jobs[$scheduleId]['broadcastId'] = $broadcastId;
+ $jobs[$scheduleId]['finishedAt'] = time();
+ $jobs[$scheduleId]['error'] = null;
+ $this->saveScheduledBroadcasts($jobs);
+
+ return [
+ 'status' => $jobs[$scheduleId]['status'],
+ 'broadcastId' => $broadcastId,
+ ];
+ } catch (Throwable $e) {
+ $jobs = $this->loadScheduledBroadcasts();
+ $jobs[$scheduleId]['status'] = 'failed';
+ $jobs[$scheduleId]['error'] = substr($e->getMessage(), 0, 500);
+ $jobs[$scheduleId]['failedAt'] = time();
+ $this->saveScheduledBroadcasts($jobs);
+ $this->logError('Scheduled broadcast failed.', $e, ['scheduleId' => $scheduleId]);
+
+ return [
+ 'status' => 'failed',
+ 'error' => $jobs[$scheduleId]['error'],
+ ];
+ }
+ }
+
+ private function createSelfDestructJob(string $broadcastId, int $hours, $chatId, int $concurrency): ?string
+ {
+ $metadata = $this->loadBroadcastMetadata($broadcastId);
+ $peers = array_filter(
+ $metadata['peers'] ?? [],
+ static fn ($peerData): bool => is_array($peerData) && !empty($peerData['messageIds'])
+ );
+
+ if (!$peers) {
+ return null;
+ }
+
+ $jobId = $this->createId('selfdestruct');
+ $deleteAt = time() + ($hours * 3600);
+ $jobs = $this->loadSelfDestructJobs();
+
+ $jobs[$jobId] = [
+ 'id' => $jobId,
+ 'broadcastId' => $broadcastId,
+ 'status' => 'scheduled',
+ 'deleteAt' => $deleteAt,
+ 'createdAt' => time(),
+ 'concurrency' => $this->clampConcurrency($concurrency),
+ 'chatId' => $chatId,
+ 'totalPeers' => count($peers),
+ 'stats' => null,
+ 'error' => null,
+ ];
+
+ $this->saveSelfDestructJobs($jobs);
+
+ $metadata['selfDestruct'] = [
+ 'enabled' => true,
+ 'hours' => $hours,
+ 'deleteAt' => $deleteAt,
+ 'deleteJobId' => $jobId,
+ ];
+ $this->saveBroadcastMetadata($broadcastId, $metadata);
+
+ return $jobId;
+ }
+
+ private function runSelfDestructJob(string $jobId): array
+ {
+ $jobs = $this->loadSelfDestructJobs();
+
+ if (!isset($jobs[$jobId])) {
+ return ['status' => 'missing'];
+ }
+
+ if (($jobs[$jobId]['status'] ?? null) !== 'scheduled') {
+ return [
+ 'status' => (string) ($jobs[$jobId]['status'] ?? 'unknown'),
+ 'stats' => $jobs[$jobId]['stats'] ?? null,
+ ];
+ }
+
+ $jobs[$jobId]['status'] = 'running';
+ $jobs[$jobId]['startedAt'] = time();
+ $this->saveSelfDestructJobs($jobs);
+
+ try {
+ $job = $jobs[$jobId];
+ $metadata = $this->loadBroadcastMetadata((string) $job['broadcastId']);
+ $peerJobs = [];
+
+ foreach (($metadata['peers'] ?? []) as $peer => $peerData) {
+ if (!is_array($peerData)) {
+ continue;
+ }
+
+ $messageIds = array_values(array_filter(
+ array_map('intval', $peerData['messageIds'] ?? []),
+ static fn (int $id): bool => $id > 0
+ ));
+
+ if (!$messageIds) {
+ continue;
+ }
+
+ $peerJobs[] = [
+ 'peer' => (string) $peer,
+ 'messageIds' => $messageIds,
+ 'attempts' => 0,
+ 'startedAt' => null,
+ 'availableAt' => 0.0,
+ ];
+ }
+
+ $state = $this->createState($jobId, 'selfdestruct', count($peerJobs), [
+ 'selfDestruct' => [
+ 'jobId' => $jobId,
+ 'broadcastId' => (string) $job['broadcastId'],
+ 'deleteAt' => (int) $job['deleteAt'],
+ ],
+ ]);
+ $this->registerCurrentState($jobId, $state);
+
+ foreach ($peerJobs as $peerJob) {
+ $state['queue']->enqueue($peerJob);
+ }
+
+ $this->startQueueWorkers(
+ $state,
+ (int) ($job['concurrency'] ?? self::DEFAULT_CONCURRENCY),
+ function (array $peerJob, array &$state): void {
+ $messageIds = array_values(array_filter(
+ array_map('intval', $peerJob['messageIds'] ?? []),
+ static fn (int $id): bool => $id > 0
+ ));
+
+ if (!$messageIds) {
+ return;
+ }
+
+ $this->api->messages->deleteMessages([
+ 'peer' => (string) $peerJob['peer'],
+ 'id' => $messageIds,
+ 'revoke' => true,
+ ]);
+
+ $state['deleted']++;
+ },
+ self::DELETE_HARD_FAIL_RPCS
+ );
+
+ $this->waitForCompletion($state);
+ $state['status'] = $state['cancel'] ? 'cancelled' : 'done';
+
+ $stats = [
+ 'deleted' => (int) $state['deleted'],
+ 'failed' => (int) $state['failed'],
+ 'flood' => (int) $state['flood'],
+ 'total' => (int) $state['total'],
+ ];
+
+ $jobs = $this->loadSelfDestructJobs();
+ $jobs[$jobId]['status'] = $state['cancel'] ? 'cancelled' : 'done';
+ $jobs[$jobId]['finishedAt'] = time();
+ $jobs[$jobId]['stats'] = $stats;
+ $jobs[$jobId]['error'] = null;
+ $this->saveSelfDestructJobs($jobs);
+
+ return [
+ 'status' => $jobs[$jobId]['status'],
+ 'stats' => $stats,
+ ];
+ } catch (Throwable $e) {
+ $jobs = $this->loadSelfDestructJobs();
+ $jobs[$jobId]['status'] = 'failed';
+ $jobs[$jobId]['failedAt'] = time();
+ $jobs[$jobId]['error'] = substr($e->getMessage(), 0, 500);
+ $this->saveSelfDestructJobs($jobs);
+ $this->logError('Self-destruct job failed.', $e, ['jobId' => $jobId]);
+
+ return [
+ 'status' => 'failed',
+ 'error' => $jobs[$jobId]['error'],
+ ];
+ }
+ }
+
+ private function normalizeBroadcastState(array $state): array
+ {
+ return [
+ 'id' => $state['id'] ?? null,
+ 'type' => $state['type'] ?? null,
+ 'sent' => $state['sent'] ?? 0,
+ 'deleted' => $state['deleted'] ?? 0,
+ 'unpin' => $state['unpin'] ?? 0,
+ 'edited' => $state['edited'] ?? 0,
+ 'unchanged' => $state['unchanged'] ?? 0,
+ 'scheduled' => $state['scheduled'] ?? 0,
+ 'failed' => $state['failed'] ?? 0,
+ 'flood' => $state['flood'] ?? 0,
+ 'total' => $state['total'] ?? null,
+ 'queue' => $state['queue'] ?? null,
+ 'inFlight' => $state['inFlight'] ?? [],
+ 'done' => $state['done'] ?? false,
+ 'paused' => $state['paused'] ?? false,
+ 'cancel' => $state['cancel'] ?? false,
+ 'startedAt' => $state['startedAt'] ?? null,
+ 'selfDestruct' => $state['selfDestruct'] ?? null,
+ ];
+ }
+
+ private function processedCount(array $state): int
+ {
+ return (int) $state['sent']
+ + (int) $state['deleted']
+ + (int) $state['unpin']
+ + (int) $state['edited']
+ + (int) $state['unchanged']
+ + (int) $state['scheduled']
+ + (int) $state['failed'];
+ }
+
+ private function pendingCount(array $state, int $processed): int
+ {
+ if (isset($state['total']) && is_int($state['total'])) {
+ return max(0, $state['total'] - $processed);
+ }
+
+ return ($state['queue'] instanceof SplQueue) ? $state['queue']->count() : 0;
+ }
+
+ private function elapsedSeconds(array $state): float
+ {
+ if (!isset($state['startedAt']) || !$state['startedAt']) {
+ return 0.0;
+ }
+
+ return max(0.0, microtime(true) - (float) $state['startedAt']);
+ }
+
+ private function buildProgressText(array $state, string $title, bool $final = false): string
+ {
+ $normalized = $this->normalizeBroadcastState($state);
+ $processed = $this->processedCount($normalized);
+ $total = (int) ($normalized['total'] ?? $processed);
+ $pending = $this->pendingCount($normalized, $processed);
+ $success = (int) $normalized['sent']
+ + (int) $normalized['deleted']
+ + (int) $normalized['unpin']
+ + (int) $normalized['edited']
+ + (int) $normalized['unchanged'];
+ $elapsed = $this->elapsedSeconds($normalized);
+ $tps = $elapsed > 0 ? round($success / $elapsed, 2) : 0.0;
+
+ if ($this->statusFormatter !== null) {
+ $context = array_intersect_key($normalized, array_flip([
+ 'id', 'type', 'sent', 'edited', 'unchanged', 'deleted', 'unpin', 'failed', 'flood', 'paused', 'cancel', 'done',
+ ]));
+ return $this->formatStatus('progress', '', $context + [
+ 'processed' => $processed, 'total' => $total, 'pending' => $pending, 'success' => $success,
+ 'tps' => $tps, 'progress_bar' => $this->progressBar($processed, max(1, $total)), 'final' => $final,
+ ]);
+ }
+ $lines = [
+ '' . $title . '',
+ '',
+ '' . $this->progressBar($processed, max(1, $total)) . '',
+ '',
+ 'Processed: ' . $processed . ' / ' . $total,
+ ];
+
+ foreach (['sent' => 'Sent', 'edited' => 'Edited', 'unchanged' => 'Unchanged', 'deleted' => 'Deleted', 'unpin' => 'Unpinned'] as $key => $label) {
+ if ((int) $normalized[$key] > 0 || $normalized['type'] === $key || ($key === 'edited' && $normalized['type'] === 'edit')) {
+ $lines[] = $label . ': ' . (int) $normalized[$key];
+ }
+ }
+
+ $lines[] = 'Failed: ' . (int) $normalized['failed'];
+ $lines[] = 'FLOOD_WAIT: ' . (int) $normalized['flood'];
+ $lines[] = 'Pending: ' . $pending;
+ $lines[] = 'TPS: ' . $tps . '/s';
+
+ if ($normalized['paused']) {
+ $lines[] = 'Paused';
+ }
+
+ if ($normalized['cancel']) {
+ $lines[] = 'Cancelled';
+ } elseif ($final) {
+ $lines[] = 'Finished';
+ }
+
+ return implode("\n", $lines);
+ }
+
+ /**
+ * Progress bar.
+ */
+ private function progressBar(int $current, int $total): string
+ {
+ $len = 20;
+ $filled = (int) round($current / max($total, 1) * $len);
+
+ return str_repeat('#', $filled)
+ . str_repeat('-', max(0, $len - $filled))
+ . ' '
+ . round(($current / max($total, 1)) * 100)
+ . '%';
+ }
+
+ private function buildStatusControls(array $state): ?array
+ {
+ if (($state['done'] ?? false) || empty($state['id'])) {
+ return null;
+ }
+
+ $id = (string) $state['id'];
+ $toggleAction = !empty($state['paused']) ? 'resume' : 'pause';
+ $context = ['id' => $id, 'type' => $state['type'] ?? null, 'paused' => !empty($state['paused'])];
+ $toggleText = $this->formatStatus($toggleAction, !empty($state['paused']) ? 'Resume' : 'Pause', $context);
+
+ $controls = [
+ 'inline_keyboard' => [
+ [
+ ['text' => $toggleText, 'callback_data' => 'bm:' . $toggleAction . ':' . $id],
+ ['text' => $this->formatStatus('cancel', 'Cancel', $context), 'callback_data' => 'bm:cancel:' . $id],
+ ],
+ ],
+ ];
+ return $this->statusControlsFormatter === null
+ ? $controls
+ : ($this->statusControlsFormatter)($controls, $context);
+ }
+
+ private function sendStatusMessage($chatId, string $message, ?array $replyMarkup = null): ?int
+ {
+ if (!$chatId) {
+ return null;
+ }
+
+ try {
+ $payload = [
+ 'peer' => $chatId,
+ 'message' => $message,
+ 'parse_mode' => 'HTML',
+ ];
+
+ if ($replyMarkup !== null) {
+ $payload['reply_markup'] = $replyMarkup;
+ }
+
+ $status = $this->callMessage('sendMessage', $payload);
+
+ return (int) $this->api->extractMessageId($status);
+ } catch (Throwable $e) {
+ $this->logError('Failed to send status message.', $e);
+ return null;
+ }
+ }
+
+ private function startProgressLoop($chatId, ?int $statusId, array &$state, string $title): void
+ {
+ if (!$chatId || !$statusId) {
+ return;
+ }
+
+ \Amp\async(function () use ($chatId, $statusId, &$state, $title): void {
+ $last = '';
+ $loggedFailures = 0;
+
+ while (!$state['done']) {
+ $text = $this->buildProgressText($state, $title);
+ $replyMarkup = $this->buildStatusControls($state);
+ $fingerprint = $text . "\n" . json_encode($replyMarkup);
+
+ if ($fingerprint !== $last) {
+ try {
+ $payload = [
+ 'peer' => $chatId,
+ 'id' => $statusId,
+ 'message' => $text,
+ 'parse_mode' => 'HTML',
+ ];
+
+ if ($replyMarkup !== null) {
+ $payload['reply_markup'] = $replyMarkup;
+ }
+
+ $this->callMessage('editMessage', $payload);
+ $last = $fingerprint;
+ } catch (RPCErrorException $e) {
+ if (
+ ($e->rpc ?? '') === 'MESSAGE_NOT_MODIFIED'
+ || str_contains($e->getMessage(), 'MESSAGE_NOT_MODIFIED')
+ ) {
+ $last = $fingerprint;
+ $this->api->sleep(self::PROGRESS_UPDATE_INTERVAL);
+ continue;
+ }
+
+ if ($loggedFailures < 3) {
+ $loggedFailures++;
+ $this->logError('Failed to update status message.', $e);
+ }
+ } catch (Throwable $e) {
+ if ($loggedFailures < 3) {
+ $loggedFailures++;
+ $this->logError('Failed to update status message.', $e);
+ }
+ }
+ }
+
+ $this->api->sleep(self::PROGRESS_UPDATE_INTERVAL);
+ }
+ });
+ }
+
+ private function editStatusMessage($chatId, ?int $statusId, string $text, ?array $replyMarkup = null): void
+ {
+ if (!$chatId || !$statusId) {
+ return;
+ }
+
+ try {
+ $payload = [
+ 'peer' => $chatId,
+ 'id' => $statusId,
+ 'message' => $text,
+ 'parse_mode' => 'HTML',
+ ];
+
+ if ($replyMarkup !== null) {
+ $payload['reply_markup'] = $replyMarkup;
+ }
+
+ $this->callMessage('editMessage', $payload);
+ } catch (RPCErrorException $e) {
+ if (
+ ($e->rpc ?? '') === 'MESSAGE_NOT_MODIFIED'
+ || str_contains($e->getMessage(), 'MESSAGE_NOT_MODIFIED')
+ ) {
+ return;
+ }
+
+ $this->logError('Failed to edit final status message.', $e);
+ } catch (Throwable $e) {
+ $this->logError('Failed to edit final status message.', $e);
+ }
+ }
+
+ private function writeLastBroadcastData(string $text): void
+ {
+ try {
+ $this->ensureDirectory(self::getDataDir());
+ file_put_contents(self::getDataDir() . '/LastBrodDATA.txt', $text, LOCK_EX);
+ } catch (Throwable $e) {
+ $this->logError('Failed to save last broadcast data.', $e);
+ }
+ }
+
+ private function readLastBroadcastMessageId(string $peer): int
+ {
+ $file = $this->peerDataPath($peer, 'lastBroadcast.txt');
+
+ if (!is_file($file)) {
+ return 0;
+ }
+
+ return (int) trim((string) file_get_contents($file));
+ }
+
+ private function readBroadcastLastMessageId(string $broadcastId, string $peer): int
+ {
+ $metadata = $this->loadBroadcastMetadata($broadcastId);
+ $peerData = $metadata['peers'][$peer] ?? null;
+
+ if (!is_array($peerData)) {
+ return 0;
+ }
+
+ $lastMessageId = (int) ($peerData['lastMessageId'] ?? 0);
+ if ($lastMessageId > 0) {
+ return $lastMessageId;
+ }
+
+ $messageIds = array_values(array_filter(
+ array_map('intval', $peerData['messageIds'] ?? []),
+ static fn (int $id): bool => $id > 0
+ ));
+
+ return $messageIds ? (int) end($messageIds) : 0;
+ }
+
+ private function broadcastMetadataPeers(string $broadcastId): array
+ {
+ $metadata = $this->loadBroadcastMetadata($broadcastId);
+
+ if (!isset($metadata['peers']) || !is_array($metadata['peers'])) {
+ return [];
+ }
+
+ return array_values(array_map('strval', array_keys($metadata['peers'])));
+ }
+
+ private function markBroadcastPeerMessageDeleted(string $broadcastId, string $peer, int $messageId): void
+ {
+ $metadata = $this->loadBroadcastMetadata($broadcastId);
+ $peerData = $metadata['peers'][$peer] ?? null;
+
+ if (!is_array($peerData)) {
+ return;
+ }
+
+ $messageIds = array_values(array_filter(
+ array_map('intval', $peerData['messageIds'] ?? []),
+ static fn (int $id): bool => $id > 0 && $id !== $messageId
+ ));
+
+ $metadata['peers'][$peer]['messageIds'] = $messageIds;
+ $metadata['peers'][$peer]['lastMessageId'] = $messageIds ? end($messageIds) : null;
+ $metadata['peers'][$peer]['status'] = $messageIds ? 'partial' : 'deleted';
+
+ $this->saveBroadcastMetadata($broadcastId, $metadata);
+ }
+
+ private function peerDataPath(string $peer, string $file): string
+ {
+ return self::getDataDir() . '/' . $peer . '/' . $file;
+ }
+
+ private function broadcastMetadataPath(string $broadcastId): string
+ {
+ return self::getDataDir() . '/broadcasts/' . $broadcastId . '.json';
+ }
+
+ private function scheduledBroadcastsPath(): string
+ {
+ return self::getDataDir() . '/scheduled-broadcasts.json';
+ }
+
+ private function selfDestructJobsPath(): string
+ {
+ return self::getDataDir() . '/self-destruct-jobs.json';
+ }
+
+ private function loadScheduledBroadcasts(): array
+ {
+ return $this->readJsonFile($this->scheduledBroadcastsPath(), []);
+ }
+
+ private function saveScheduledBroadcasts(array $jobs): void
+ {
+ $this->writeJsonFileAtomic($this->scheduledBroadcastsPath(), $jobs);
+ }
+
+ private function loadSelfDestructJobs(): array
+ {
+ return $this->readJsonFile($this->selfDestructJobsPath(), []);
+ }
+
+ private function saveSelfDestructJobs(array $jobs): void
+ {
+ $this->writeJsonFileAtomic($this->selfDestructJobsPath(), $jobs);
+ }
+
+ private function validateSelfDestructHours(?int $hours): void
+ {
+ if ($hours === null) {
+ return;
+ }
+
+ if ($hours < 0 || $hours > 48) {
+ throw new InvalidArgumentException('selfDestructHours must be null or an integer between 0 and 48.');
+ }
+ }
+
+ private function normalizeOptionalId(?string $id): ?string
+ {
+ if ($id === null) {
+ return null;
+ }
+
+ $id = trim($id);
+
+ return $id === '' ? null : $id;
+ }
+
+ private function assertJsonEncodable(mixed $value, string $name): void
+ {
+ try {
+ json_encode($value, JSON_THROW_ON_ERROR);
+ } catch (JsonException $e) {
+ throw new InvalidArgumentException($name . ' must be JSON encodable: ' . $e->getMessage(), 0, $e);
+ }
+ }
+
+ private function createId(string $prefix = ''): string
+ {
+ $id = bin2hex(random_bytes(8));
+
+ return $prefix === '' ? $id : $prefix . '_' . $id;
+ }
+
+ private function ensureDirectory(string $dir): void
+ {
+ if (is_dir($dir)) {
+ return;
+ }
+
+ if (!mkdir($dir, 0777, true) && !is_dir($dir)) {
+ throw new RuntimeException('Unable to create directory: ' . $dir);
+ }
+ }
+
+ private function deleteFile(string $file): void
+ {
+ try {
+ if (is_file($file)) {
+ @unlink($file);
+ }
+ } catch (Throwable $e) {
+ $this->logError('Failed to delete file.', $e, ['file' => $file]);
+ }
+ }
+}
diff --git a/patches/broadcastmanager/reviewed/BroadcastManager.php b/patches/broadcastmanager/reviewed/BroadcastManager.php
new file mode 100644
index 0000000..33ad594
--- /dev/null
+++ b/patches/broadcastmanager/reviewed/BroadcastManager.php
@@ -0,0 +1,2241 @@
+
+ * @copyright - WizardLoop
+ * @license - https://opensource.org/licenses/AGPL-3.0 AGPLv3
+ */
+
+namespace BroadcastTool;
+
+use danog\MadelineProto\API;
+use danog\MadelineProto\RPCErrorException;
+use InvalidArgumentException;
+use JsonException;
+use RuntimeException;
+use SplQueue;
+use Throwable;
+
+class BroadcastManager
+{
+ private const MAX_ATTEMPTS = 3;
+ private const DEFAULT_CONCURRENCY = 10;
+ private const MAX_CONCURRENCY = 30;
+ private const PROGRESS_UPDATE_INTERVAL = 5.0;
+ private const WORKER_IDLE_SLEEP = 1.0;
+ private const WORKER_RETRY_SLEEP = 1.0;
+ private const WORKER_PAUSED_SLEEP = 1.5;
+ private const WORKER_AFTER_JOB_SLEEP = 0.75;
+ private const SEND_MESSAGE_SLEEP = 0.35;
+ private const SEND_CHUNK_SLEEP = 0.75;
+
+ private const SEND_HARD_FAIL_RPCS = [
+ 'INPUT_USER_DEACTIVATED',
+ 'USER_IS_BOT',
+ 'CHAT_WRITE_FORBIDDEN',
+ 'USER_IS_BLOCKED',
+ 'PEER_ID_INVALID',
+ ];
+
+ private const EDIT_HARD_FAIL_RPCS = [
+ 'INPUT_USER_DEACTIVATED',
+ 'USER_IS_BOT',
+ 'CHAT_WRITE_FORBIDDEN',
+ 'USER_IS_BLOCKED',
+ 'PEER_ID_INVALID',
+ 'MESSAGE_ID_INVALID',
+ 'MESSAGE_EDIT_TIME_EXPIRED',
+ 'MESSAGE_AUTHOR_REQUIRED',
+ ];
+
+ private const DELETE_HARD_FAIL_RPCS = [
+ 'INPUT_USER_DEACTIVATED',
+ 'USER_IS_BOT',
+ 'CHAT_WRITE_FORBIDDEN',
+ 'USER_IS_BLOCKED',
+ 'PEER_ID_INVALID',
+ 'MESSAGE_ID_INVALID',
+ ];
+
+ private ?\Amp\CancelledException $runtimeGuardFailure = null;
+ private ?\Closure $runtimeAdmission = null;
+ private ?\Closure $runtimeGuard = null;
+ private ?\Amp\Cancellation $runtimeCancellation = null;
+ private ?\Closure $messageTransport = null;
+ private ?\Closure $statusFormatter = null;
+ private ?\Closure $statusControlsFormatter = null;
+ private API $api;
+ private array $currentBroadcastState = [];
+ private static array $sharedBroadcastState = [];
+ private static string $dataDir = '';
+
+ public function __construct(API $api)
+ {
+ $this->api = $api;
+ }
+
+ /** Optional local lifecycle policy. Disabled hooks preserve upstream API behavior. */
+ public function setRuntimeGuard(?callable $guard, ?\Amp\Cancellation $cancellation = null): void
+ {
+ $this->runtimeGuard = $guard === null ? null : \Closure::fromCallable($guard);
+ $this->runtimeCancellation = $cancellation;
+ $this->runtimeGuardFailure = null;
+ }
+
+ /** Optional application-wide admission. Upstream callers run immediately. */
+ public function setRuntimeAdmission(?callable $admission): void
+ {
+ $this->runtimeAdmission = $admission === null ? null : \Closure::fromCallable($admission);
+ }
+
+ private function withAdmission(callable $work): mixed
+ {
+ return $this->runtimeAdmission === null ? $work() : ($this->runtimeAdmission)($work);
+ }
+
+ /** Keep recent completed controls without retaining unbounded runtime state. */
+ private function pruneCompletedRuntimeStates(): void
+ {
+ if ($this->runtimeGuard === null && $this->runtimeAdmission === null) {
+ return;
+ }
+ $kept = 0;
+ foreach (array_reverse(array_keys(self::$sharedBroadcastState)) as $id) {
+ $state = self::$sharedBroadcastState[$id];
+ if (empty($state['done']) || !empty($state['inFlight']) || isset($state['runtimeWorkers'])) {
+ continue;
+ }
+ if (++$kept > 128) {
+ unset(self::$sharedBroadcastState[$id], $this->currentBroadcastState[$id]);
+ }
+ }
+ $kept = 0;
+ foreach (array_reverse(array_keys($this->currentBroadcastState)) as $id) {
+ $state = $this->currentBroadcastState[$id];
+ if (!empty($state['done']) && empty($state['inFlight']) && !isset($state['runtimeWorkers']) && ++$kept > 128) {
+ unset($this->currentBroadcastState[$id]);
+ }
+ }
+ }
+ private function checkRuntime(): void
+ {
+ $this->runtimeCancellation?->throwIfRequested();
+ if ($this->runtimeGuard !== null && ($this->runtimeGuard)() !== true) {
+ $this->runtimeGuardFailure ??= new \Amp\CancelledException(new RuntimeException('Broadcast runtime authorization or shutdown guard rejected work.'));
+ throw $this->runtimeGuardFailure;
+ }
+ }
+
+ private function runtimeSleep(float $seconds): void
+ {
+ if ($this->runtimeGuard === null && $this->runtimeCancellation === null) {
+ $this->api->sleep($seconds);
+ return;
+ }
+ $this->checkRuntime();
+ \Amp\delay($seconds, cancellation: $this->runtimeCancellation);
+ $this->checkRuntime();
+ }
+
+ private function workerSleep(float $seconds, array &$state): void
+ {
+ if ($this->runtimeGuard === null && $this->runtimeCancellation === null) {
+ $this->api->sleep($seconds);
+ return;
+ }
+ $this->checkRuntime();
+ $signals = [$state['runtimeWake']->getCancellation()];
+ if ($this->runtimeCancellation !== null) {
+ $signals[] = $this->runtimeCancellation;
+ }
+ \Amp\delay($seconds, cancellation: new \Amp\CompositeCancellation(...$signals));
+ $this->checkRuntime();
+ }
+ private function callNativeMessage(string $method, array $parameters): mixed
+ {
+ $this->checkRuntime();
+ if ($this->runtimeGuard !== null || $this->runtimeCancellation !== null) {
+ $parameters['floodWaitLimit'] = 0;
+ $parameters['cancellation'] = $this->runtimeCancellation;
+ }
+ return $this->api->messages->{$method}($parameters);
+ }
+
+ /** Every launched child is retained and drained by waitForCompletion. */
+ private function launchWorker(array &$state, callable $work): void
+ {
+ $state['runtimeWake'] ??= new \Amp\DeferredCancellation();
+ $state['runtimeWakeReason'] ??= new RuntimeException('Completed broadcast worker scope.');
+ $state['runtimeWorkers'][] = \Amp\async(static function () use (&$state, $work): void {
+ try {
+ $work();
+ } catch (Throwable $error) {
+ if ($error instanceof \Amp\CancelledException && $error->getPrevious() === $state['runtimeWakeReason']) {
+ return;
+ }
+ $state['cancel'] = true;
+ throw $error;
+ }
+ });
+ }
+ /** Optional per-instance presentation hook; default upstream output is unchanged. */
+ public function setStatusFormatter(?callable $formatter): void
+ {
+ $this->statusFormatter = $formatter === null ? null : \Closure::fromCallable($formatter);
+ }
+
+ /** Optional native-markup hook. Preserve each supplied bm callback identifier. */
+ public function setStatusControlsFormatter(?callable $formatter): void
+ {
+ $this->statusControlsFormatter = $formatter === null ? null : \Closure::fromCallable($formatter);
+ }
+
+ private function formatStatus(string $event, string $default, array $context): string
+ {
+ if ($this->statusFormatter === null) {
+ return $default;
+ }
+ $text = ($this->statusFormatter)($event, $context + ['default' => $default]);
+ if (!is_string($text) || trim($text) === '' || !mb_check_encoding($text, 'UTF-8')
+ || strlen(mb_convert_encoding($text, 'UTF-16LE', 'UTF-8')) > 8192) {
+ throw new InvalidArgumentException('Status formatter must return bounded non-empty UTF-8 text.');
+ }
+ return $text;
+ }
+ /** Optional per-instance message adapter; null preserves exact native dispatch. */
+ public function setMessageTransport(?callable $transport): void
+ {
+ $this->messageTransport = $transport === null ? null : \Closure::fromCallable($transport);
+ }
+
+ private function callMessage(string $method, array $parameters): mixed
+ {
+ $this->checkRuntime();
+ if ($this->runtimeCancellation !== null) {
+ $parameters['cancellation'] = $this->runtimeCancellation;
+ }
+ if (!in_array($method, ['sendMessage', 'sendMedia', 'sendMultiMedia', 'editMessage'], true)) {
+ throw new InvalidArgumentException('Unsupported broadcast message transport method.');
+ }
+ return $this->messageTransport === null
+ ? $this->callNativeMessage($method, $parameters)
+ : ($this->messageTransport)($method, $parameters);
+ }
+ /**
+ * Set data dir.
+ */
+ public static function setDataDir(string $path): void
+ {
+ self::$dataDir = rtrim($path, '/\\');
+ }
+
+ /**
+ * Get data dir.
+ */
+ private static function getDataDir(): string
+ {
+ if (!self::$dataDir) {
+ self::$dataDir = __DIR__ . '/../data';
+ }
+
+ return self::$dataDir;
+ }
+
+ /**
+ * Send broadcast.
+ *
+ * @return string ID that can be used for progress/pause/resume/cancel.
+ */
+ public function broadcastWithProgress(
+ array $allUsers,
+ array $messages,
+ $chatId = null,
+ bool $pin = false,
+ int $concurrency = self::DEFAULT_CONCURRENCY,
+ ?int $selfDestructHours = null
+ ): string {
+ return $this->withAdmission(fn(): string => $this->doBroadcastWithProgress($allUsers, $messages, $chatId, $pin, $concurrency, $selfDestructHours));
+ }
+
+ private function doBroadcastWithProgress(
+ array $allUsers,
+ array $messages,
+ $chatId = null,
+ bool $pin = false,
+ int $concurrency = self::DEFAULT_CONCURRENCY,
+ ?int $selfDestructHours = null
+ ): string {
+ $this->validateSelfDestructHours($selfDestructHours);
+
+ $concurrency = $this->clampConcurrency($concurrency);
+ $total = count($allUsers);
+ $broadcastId = $this->createId();
+ $state = $this->createState($broadcastId, 'send', $total, [
+ 'selfDestruct' => [
+ 'enabled' => $selfDestructHours !== null,
+ 'hours' => $selfDestructHours,
+ 'deleteAt' => null,
+ 'deleteJobId' => null,
+ ],
+ ]);
+
+ $this->registerCurrentState($broadcastId, $state);
+ $this->enqueuePeers($state, $allUsers);
+ $this->initializeBroadcastMetadata($broadcastId, 'send', $total, $selfDestructHours);
+
+ $statusId = $this->sendStatusMessage($chatId, $this->formatStatus('starting', 'Gathering peers...', ['type' => $state['type'], 'id' => $state['id'], 'total' => $state['total']]), $this->buildStatusControls($state));
+ $this->startProgressLoop($chatId, $statusId, $state, 'Broadcast Progress');
+
+ $this->startQueueWorkers(
+ $state,
+ $concurrency,
+ function (array $job, array &$state) use ($messages, $pin, $broadcastId): void {
+ $peer = (string) $job['peer'];
+ $messageIds = $this->sendMessagesToPeer($peer, $messages);
+ $lastMessageId = $messageIds ? end($messageIds) : null;
+
+ if ($pin && $lastMessageId) {
+ $this->callNativeMessage('updatePinnedMessage', [
+ 'peer' => $peer,
+ 'id' => $lastMessageId,
+ 'unpin' => false,
+ ]);
+ }
+
+ $state['sent']++;
+ $state['lastMessageIds'][$peer] = $lastMessageId ? (string) $lastMessageId : '';
+
+ if ($messageIds) {
+ $this->savePeerMessageIds($peer, $messageIds);
+ }
+
+ $this->saveBroadcastPeerMessageIds(
+ $broadcastId,
+ $peer,
+ $messageIds,
+ (int) $state['sent'],
+ (int) $state['failed']
+ );
+ },
+ self::SEND_HARD_FAIL_RPCS
+ );
+
+ $this->waitForCompletion($state);
+ $state['status'] = $state['cancel'] ? 'cancelled' : 'done';
+
+ $this->finalizeBroadcastMetadata(
+ $broadcastId,
+ (string) $state['status'],
+ (int) $state['sent'],
+ (int) $state['failed']
+ );
+
+ $selfDestructJobId = null;
+ if ($selfDestructHours !== null) {
+ $selfDestructJobId = $this->createSelfDestructJob($broadcastId, $selfDestructHours, $chatId, $concurrency);
+ if ($selfDestructJobId !== null) {
+ $metadata = $this->loadBroadcastMetadata($broadcastId);
+ $state['selfDestruct'] = $metadata['selfDestruct'] ?? $state['selfDestruct'];
+ }
+
+ if ($selfDestructJobId !== null && $selfDestructHours === 0) {
+ $this->runSelfDestructJob($selfDestructJobId);
+ }
+ }
+
+ $finalText = $this->buildProgressText($state, 'Broadcast Progress', true);
+ $this->editStatusMessage($chatId, $statusId, $finalText);
+ $this->writeLastBroadcastData($finalText);
+
+ return $broadcastId;
+ }
+
+ /**
+ * Edit the last saved broadcast message for all users.
+ */
+ public function editLastBroadcastForAll(
+ array $allUsers,
+ string $newText,
+ $chatId = null,
+ ?array $buttons = null,
+ $media = null,
+ int $concurrency = self::DEFAULT_CONCURRENCY,
+ string $parseMode = 'HTML',
+ ?string $broadcastId = null
+ ): string {
+ return $this->withAdmission(fn(): string => $this->doEditLastBroadcastForAll($allUsers, $newText, $chatId, $buttons, $media, $concurrency, $parseMode, $broadcastId));
+ }
+
+ private function doEditLastBroadcastForAll(
+ array $allUsers,
+ string $newText,
+ $chatId = null,
+ ?array $buttons = null,
+ $media = null,
+ int $concurrency = self::DEFAULT_CONCURRENCY,
+ string $parseMode = 'HTML',
+ ?string $broadcastId = null
+ ): string {
+ $concurrency = $this->clampConcurrency($concurrency);
+ $targetBroadcastId = $this->normalizeOptionalId($broadcastId);
+ $targets = $targetBroadcastId !== null && $allUsers === []
+ ? $this->broadcastMetadataPeers($targetBroadcastId)
+ : $allUsers;
+ $operationId = $this->createId();
+ $state = $this->createState($operationId, 'edit', count($targets), [
+ 'targetBroadcastId' => $targetBroadcastId,
+ ]);
+
+ $this->registerCurrentState($operationId, $state);
+ $this->enqueuePeers($state, $targets);
+
+ $statusId = $this->sendStatusMessage($chatId, $this->formatStatus('starting', 'Editing last broadcast...', ['type' => $state['type'], 'id' => $state['id'], 'total' => $state['total']]), $this->buildStatusControls($state));
+ $this->startProgressLoop($chatId, $statusId, $state, 'Editing Last Broadcast');
+
+ $this->startQueueWorkers(
+ $state,
+ $concurrency,
+ function (array $job, array &$state) use ($newText, $buttons, $media, $parseMode, $targetBroadcastId): void {
+ $peer = (string) $job['peer'];
+ $lastMessageId = $targetBroadcastId !== null
+ ? $this->readBroadcastLastMessageId($targetBroadcastId, $peer)
+ : $this->readLastBroadcastMessageId($peer);
+
+ if ($lastMessageId <= 0) {
+ $state['failed']++;
+ return;
+ }
+
+ $payload = [
+ 'peer' => $peer,
+ 'id' => $lastMessageId,
+ 'message' => $newText,
+ 'parse_mode' => $parseMode,
+ 'floodWaitLimit' => 172800,
+ ];
+
+ if ($buttons !== null) {
+ $payload['reply_markup'] = $buttons;
+ }
+
+ if ($media !== null) {
+ $payload['media'] = $media;
+ }
+
+ $this->callMessage('editMessage', $payload);
+ $state['edited']++;
+ },
+ self::EDIT_HARD_FAIL_RPCS,
+ function (RPCErrorException $e, array &$job, array &$state): bool {
+ unset($job);
+
+ if (($e->rpc ?? '') === 'MESSAGE_NOT_MODIFIED' || str_contains($e->getMessage(), 'MESSAGE_NOT_MODIFIED')) {
+ $state['unchanged']++;
+ return true;
+ }
+
+ return false;
+ }
+ );
+
+ $this->waitForCompletion($state);
+ $state['status'] = $state['cancel'] ? 'cancelled' : 'done';
+
+ $this->editStatusMessage($chatId, $statusId, $this->buildProgressText($state, 'Editing Last Broadcast', true));
+
+ return $operationId;
+ }
+
+ /**
+ * Schedule a broadcast. Due broadcasts can be run with runDueScheduledBroadcasts().
+ */
+ public function scheduleBroadcastForAll(
+ array $allUsers,
+ array $messages,
+ int $scheduledAt,
+ $chatId = null,
+ bool $pin = false,
+ int $concurrency = self::DEFAULT_CONCURRENCY,
+ ?int $selfDestructHours = null
+ ): string {
+ $this->validateSelfDestructHours($selfDestructHours);
+ $this->assertJsonEncodable($allUsers, 'allUsers');
+ $this->assertJsonEncodable($messages, 'messages');
+
+ $concurrency = $this->clampConcurrency($concurrency);
+ $scheduleId = $this->createId('schedule');
+ $jobs = $this->loadScheduledBroadcasts();
+
+ $jobs[$scheduleId] = [
+ 'id' => $scheduleId,
+ 'status' => 'scheduled',
+ 'scheduledAt' => $scheduledAt,
+ 'createdAt' => time(),
+ 'allUsers' => array_values($allUsers),
+ 'messages' => array_values($messages),
+ 'chatId' => $chatId,
+ 'pin' => $pin,
+ 'concurrency' => $concurrency,
+ 'selfDestructHours' => $selfDestructHours,
+ 'broadcastId' => null,
+ 'error' => null,
+ ];
+
+ $this->saveScheduledBroadcasts($jobs);
+
+ if ($scheduledAt <= time()) {
+ $this->runScheduledBroadcast($scheduleId);
+ }
+
+ return $scheduleId;
+ }
+
+ /**
+ * Run all scheduled broadcasts whose scheduledAt timestamp has passed.
+ */
+ public function runDueScheduledBroadcasts(): array
+ {
+ $jobs = $this->loadScheduledBroadcasts();
+ $results = [];
+ $now = time();
+
+ foreach ($jobs as $scheduleId => $job) {
+ try {
+ $this->checkRuntime();
+ } catch (\Amp\CancelledException $error) {
+ if ($error !== $this->runtimeGuardFailure) {
+ throw $error;
+ }
+ break;
+ }
+ if (($job['status'] ?? null) !== 'scheduled') {
+ continue;
+ }
+
+ if ((int) ($job['scheduledAt'] ?? 0) > $now) {
+ continue;
+ }
+
+ $results[$scheduleId] = $this->runScheduledBroadcast((string) $scheduleId);
+ }
+
+ return $results;
+ }
+
+ public function cancelScheduledBroadcast(string $scheduleId): bool
+ {
+ $jobs = $this->loadScheduledBroadcasts();
+
+ if (!isset($jobs[$scheduleId]) || ($jobs[$scheduleId]['status'] ?? null) !== 'scheduled') {
+ return false;
+ }
+
+ $jobs[$scheduleId]['status'] = 'cancelled';
+ $jobs[$scheduleId]['cancelledAt'] = time();
+ $this->saveScheduledBroadcasts($jobs);
+
+ return true;
+ }
+
+ public function listScheduledBroadcasts(): array
+ {
+ $items = [];
+
+ foreach ($this->loadScheduledBroadcasts() as $id => $job) {
+ $items[$id] = [
+ 'id' => (string) ($job['id'] ?? $id),
+ 'status' => (string) ($job['status'] ?? 'unknown'),
+ 'scheduledAt' => (int) ($job['scheduledAt'] ?? 0),
+ 'createdAt' => (int) ($job['createdAt'] ?? 0),
+ 'broadcastId' => $job['broadcastId'] ?? null,
+ 'pin' => (bool) ($job['pin'] ?? false),
+ 'concurrency' => (int) ($job['concurrency'] ?? self::DEFAULT_CONCURRENCY),
+ 'selfDestructHours' => $job['selfDestructHours'] ?? null,
+ 'totalUsers' => is_array($job['allUsers'] ?? null) ? count($job['allUsers']) : 0,
+ 'totalMessages' => is_array($job['messages'] ?? null) ? count($job['messages']) : 0,
+ 'error' => $job['error'] ?? null,
+ ];
+ }
+
+ return $items;
+ }
+
+ /**
+ * Deletes the last broadcast message for all users.
+ *
+ * @return string ID that can be used for progress/pause/resume/cancel.
+ */
+ public function deleteLastBroadcastForAll(
+ array $allUsers,
+ $chatId = null,
+ int $concurrency = self::DEFAULT_CONCURRENCY,
+ ?string $broadcastId = null
+ ): string {
+ return $this->withAdmission(fn(): string => $this->doDeleteLastBroadcastForAll($allUsers, $chatId, $concurrency, $broadcastId));
+ }
+
+ private function doDeleteLastBroadcastForAll(
+ array $allUsers,
+ $chatId = null,
+ int $concurrency = self::DEFAULT_CONCURRENCY,
+ ?string $broadcastId = null
+ ): string {
+ $concurrency = $this->clampConcurrency($concurrency);
+ $targetBroadcastId = $this->normalizeOptionalId($broadcastId);
+ $targets = $targetBroadcastId !== null && $allUsers === []
+ ? $this->broadcastMetadataPeers($targetBroadcastId)
+ : $allUsers;
+ $operationId = $this->createId();
+ $state = $this->createState($operationId, 'deletelast', count($targets), [
+ 'targetBroadcastId' => $targetBroadcastId,
+ ]);
+
+ $this->registerCurrentState($operationId, $state);
+ $this->enqueuePeers($state, $targets);
+
+ $statusId = $this->sendStatusMessage($chatId, $this->formatStatus('starting', 'Deleting last broadcast...', ['type' => $state['type'], 'id' => $state['id'], 'total' => $state['total']]), $this->buildStatusControls($state));
+ $this->startProgressLoop($chatId, $statusId, $state, 'Deleting Last Broadcast');
+
+ $this->startQueueWorkers(
+ $state,
+ $concurrency,
+ function (array $job, array &$state) use ($targetBroadcastId): void {
+ $peer = (string) $job['peer'];
+ $lastMessageId = $targetBroadcastId !== null
+ ? $this->readBroadcastLastMessageId($targetBroadcastId, $peer)
+ : $this->readLastBroadcastMessageId($peer);
+
+ if ($lastMessageId <= 0) {
+ $state['failed']++;
+ return;
+ }
+
+ $this->callNativeMessage('deleteMessages', [
+ 'peer' => $peer,
+ 'id' => [$lastMessageId],
+ 'revoke' => true,
+ ]);
+
+ if ($targetBroadcastId !== null) {
+ $this->markBroadcastPeerMessageDeleted($targetBroadcastId, $peer, $lastMessageId);
+ } else {
+ $this->deleteFile($this->peerDataPath($peer, 'lastBroadcast.txt'));
+ }
+
+ $state['deleted']++;
+ },
+ self::DELETE_HARD_FAIL_RPCS
+ );
+
+ $this->waitForCompletion($state);
+ $state['status'] = $state['cancel'] ? 'cancelled' : 'done';
+
+ $this->editStatusMessage($chatId, $statusId, $this->buildProgressText($state, 'Deleting Last Broadcast', true));
+
+ return $operationId;
+ }
+
+ /**
+ * Deletes all broadcast messages for all users.
+ *
+ * @return string ID that can be used for progress/pause/resume/cancel.
+ */
+ public function deleteAllBroadcastsForAll(
+ array $allUsers,
+ $chatId = null,
+ int $concurrency = self::DEFAULT_CONCURRENCY
+ ): string {
+ return $this->withAdmission(fn(): string => $this->doDeleteAllBroadcastsForAll($allUsers, $chatId, $concurrency));
+ }
+
+ private function doDeleteAllBroadcastsForAll(
+ array $allUsers,
+ $chatId = null,
+ int $concurrency = self::DEFAULT_CONCURRENCY
+ ): string {
+ $concurrency = $this->clampConcurrency($concurrency);
+ $broadcastId = $this->createId();
+ $state = $this->createState($broadcastId, 'deleteall', count($allUsers));
+
+ $this->registerCurrentState($broadcastId, $state);
+ $this->enqueuePeers($state, $allUsers);
+
+ $statusId = $this->sendStatusMessage($chatId, $this->formatStatus('starting', 'Deleting all broadcasts...', ['type' => $state['type'], 'id' => $state['id'], 'total' => $state['total']]), $this->buildStatusControls($state));
+ $this->startProgressLoop($chatId, $statusId, $state, 'Deleting All Broadcasts');
+
+ $this->startQueueWorkers(
+ $state,
+ $concurrency,
+ function (array $job, array &$state): void {
+ $peer = (string) $job['peer'];
+ $file = $this->peerDataPath($peer, 'messages.txt');
+
+ if (!is_file($file)) {
+ $state['failed']++;
+ return;
+ }
+
+ $msgIds = array_values(array_filter(
+ array_map('intval', explode("\n", trim((string) file_get_contents($file)))),
+ static fn (int $id): bool => $id > 0
+ ));
+
+ if (!$msgIds) {
+ $state['failed']++;
+ return;
+ }
+
+ $userDeleted = false;
+
+ foreach ($msgIds as $messageId) {
+ try {
+ $this->callNativeMessage('deleteMessages', [
+ 'peer' => $peer,
+ 'id' => [$messageId],
+ 'revoke' => true,
+ ]);
+ $userDeleted = true;
+ } catch (RPCErrorException $e) {
+ $message = $e->getMessage();
+
+ if ($this->parseFloodWait($e) !== null) {
+ throw $e;
+ }
+
+ if (str_contains($message, 'USER_IS_BLOCKED') || str_contains($message, 'PEER_ID_INVALID')) {
+ continue;
+ }
+
+ throw $e;
+ }
+ }
+
+ if ($userDeleted) {
+ $state['deleted']++;
+ } else {
+ $state['failed']++;
+ }
+
+ $this->deleteFile($this->peerDataPath($peer, 'lastBroadcast.txt'));
+ $this->deleteFile($file);
+ },
+ self::DELETE_HARD_FAIL_RPCS,
+ null,
+ true
+ );
+
+ $this->waitForCompletion($state);
+ $state['status'] = $state['cancel'] ? 'cancelled' : 'done';
+
+ $this->editStatusMessage($chatId, $statusId, $this->buildProgressText($state, 'Deleting All Broadcasts', true));
+
+ return $broadcastId;
+ }
+
+ /**
+ * Unpin all messages for all users.
+ *
+ * @return string ID that can be used for progress/pause/resume/cancel.
+ */
+ public function unpinAllMessagesForAll(
+ array $allUsers,
+ $chatId = null,
+ int $concurrency = self::DEFAULT_CONCURRENCY
+ ): string {
+ return $this->withAdmission(fn(): string => $this->doUnpinAllMessagesForAll($allUsers, $chatId, $concurrency));
+ }
+
+ private function doUnpinAllMessagesForAll(
+ array $allUsers,
+ $chatId = null,
+ int $concurrency = self::DEFAULT_CONCURRENCY
+ ): string {
+ $concurrency = $this->clampConcurrency($concurrency);
+ $broadcastId = $this->createId();
+ $state = $this->createState($broadcastId, 'unpin', count($allUsers));
+
+ $this->registerCurrentState($broadcastId, $state);
+ $this->enqueuePeers($state, $allUsers);
+
+ $statusId = $this->sendStatusMessage($chatId, $this->formatStatus('starting', 'Starting unpin...', ['type' => $state['type'], 'id' => $state['id'], 'total' => $state['total']]), $this->buildStatusControls($state));
+ $this->startProgressLoop($chatId, $statusId, $state, 'Unpinning Messages');
+
+ $this->startQueueWorkers(
+ $state,
+ $concurrency,
+ function (array $job, array &$state): void {
+ $this->callNativeMessage('unpinAllMessages', [
+ 'peer' => (string) $job['peer'],
+ ]);
+
+ $state['unpin']++;
+ },
+ self::SEND_HARD_FAIL_RPCS
+ );
+
+ $this->waitForCompletion($state);
+ $state['status'] = $state['cancel'] ? 'cancelled' : 'done';
+
+ $this->editStatusMessage($chatId, $statusId, $this->buildProgressText($state, 'Unpinning Messages', true));
+
+ return $broadcastId;
+ }
+
+ public function runDueSelfDestructJobs(): array
+ {
+ $jobs = $this->loadSelfDestructJobs();
+ $results = [];
+ $now = time();
+
+ foreach ($jobs as $jobId => $job) {
+ try {
+ $this->checkRuntime();
+ } catch (\Amp\CancelledException $error) {
+ if ($error !== $this->runtimeGuardFailure) {
+ throw $error;
+ }
+ break;
+ }
+ if (($job['status'] ?? null) !== 'scheduled') {
+ continue;
+ }
+
+ if ((int) ($job['deleteAt'] ?? 0) > $now) {
+ continue;
+ }
+
+ $results[$jobId] = $this->runSelfDestructJob((string) $jobId);
+ }
+
+ return $results;
+ }
+
+ public function cancelSelfDestructJob(string $jobId): bool
+ {
+ $jobs = $this->loadSelfDestructJobs();
+
+ if (!isset($jobs[$jobId]) || ($jobs[$jobId]['status'] ?? null) !== 'scheduled') {
+ return false;
+ }
+
+ $jobs[$jobId]['status'] = 'cancelled';
+ $jobs[$jobId]['cancelledAt'] = time();
+ $this->saveSelfDestructJobs($jobs);
+
+ return true;
+ }
+
+ public function listSelfDestructJobs(): array
+ {
+ $items = [];
+
+ foreach ($this->loadSelfDestructJobs() as $id => $job) {
+ $items[$id] = [
+ 'id' => (string) ($job['id'] ?? $id),
+ 'broadcastId' => (string) ($job['broadcastId'] ?? ''),
+ 'status' => (string) ($job['status'] ?? 'unknown'),
+ 'deleteAt' => (int) ($job['deleteAt'] ?? 0),
+ 'createdAt' => (int) ($job['createdAt'] ?? 0),
+ 'concurrency' => (int) ($job['concurrency'] ?? self::DEFAULT_CONCURRENCY),
+ 'chatId' => $job['chatId'] ?? null,
+ 'totalPeers' => (int) ($job['totalPeers'] ?? 0),
+ 'stats' => $job['stats'] ?? null,
+ 'error' => $job['error'] ?? null,
+ ];
+ }
+
+ return $items;
+ }
+
+ /**
+ * Pause running broadcast.
+ */
+ public function pause(string $id): void
+ {
+ if (isset(self::$sharedBroadcastState[$id])) {
+ self::$sharedBroadcastState[$id]['paused'] = true;
+ }
+
+ if (isset($this->currentBroadcastState[$id])) {
+ $this->currentBroadcastState[$id]['paused'] = true;
+ }
+ }
+
+ /**
+ * Resume running broadcast.
+ */
+ public function resume(string $id): void
+ {
+ if (isset(self::$sharedBroadcastState[$id])) {
+ self::$sharedBroadcastState[$id]['paused'] = false;
+ }
+
+ if (isset($this->currentBroadcastState[$id])) {
+ $this->currentBroadcastState[$id]['paused'] = false;
+ }
+ }
+
+ /**
+ * Cancel running broadcast.
+ */
+ public function cancel(string $id): void
+ {
+ if (isset(self::$sharedBroadcastState[$id])) {
+ self::$sharedBroadcastState[$id]['cancel'] = true;
+ }
+
+ if (isset($this->currentBroadcastState[$id])) {
+ $this->currentBroadcastState[$id]['cancel'] = true;
+ }
+ }
+
+ /**
+ * Check if broadcast is paused.
+ */
+ public function isPaused(string $id): bool
+ {
+ return self::$sharedBroadcastState[$id]['paused'] ?? $this->currentBroadcastState[$id]['paused'] ?? false;
+ }
+
+ /**
+ * Check if broadcast is cancelled.
+ */
+ public function isCancelled(string $id): bool
+ {
+ return self::$sharedBroadcastState[$id]['cancel'] ?? $this->currentBroadcastState[$id]['cancel'] ?? false;
+ }
+
+ /**
+ * Check if broadcast is active.
+ */
+ public function isActive(?string $id = null): bool
+ {
+ if (!$id || (!isset(self::$sharedBroadcastState[$id]) && !isset($this->currentBroadcastState[$id]))) {
+ return false;
+ }
+
+ $state = self::$sharedBroadcastState[$id] ?? $this->currentBroadcastState[$id];
+
+ return (
+ empty($state['done'])
+ && empty($state['cancel'])
+ && empty($state['paused'])
+ );
+ }
+
+ /**
+ * Check if there is a last broadcast message saved for deletion.
+ */
+ public function hasLastBroadcast(): bool
+ {
+ foreach (glob(self::getDataDir() . '/*/lastBroadcast.txt') ?: [] as $file) {
+ if (is_file($file) && trim((string) file_get_contents($file)) !== '') {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Check if there are broadcast messages saved for deletion.
+ */
+ public function hasAllBroadcast(): bool
+ {
+ foreach (glob(self::getDataDir() . '/*/messages.txt') ?: [] as $file) {
+ if (is_file($file) && trim((string) file_get_contents($file)) !== '') {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Get current broadcast progress.
+ */
+ public function progress(?string $id = null): ?array
+ {
+ if (!$id || (!isset(self::$sharedBroadcastState[$id]) && !isset($this->currentBroadcastState[$id]))) {
+ return null;
+ }
+
+ $state = $this->normalizeBroadcastState(self::$sharedBroadcastState[$id] ?? $this->currentBroadcastState[$id]);
+
+ $sent = (int) $state['sent'];
+ $deleted = (int) $state['deleted'];
+ $unpin = (int) $state['unpin'];
+ $edited = (int) $state['edited'];
+ $unchanged = (int) $state['unchanged'];
+ $scheduled = (int) $state['scheduled'];
+ $failed = (int) $state['failed'];
+ $flood = (int) $state['flood'];
+
+ $processed = $this->processedCount($state);
+ $success = $sent + $deleted + $unpin + $edited + $unchanged;
+ $pending = $this->pendingCount($state, $processed);
+ $total = (int) $state['total'];
+ $elapsed = $this->elapsedSeconds($state);
+ $tps = $elapsed > 0 ? round($success / $elapsed, 2) : 0.0;
+
+ $progressPercent = $total > 0
+ ? round(($processed / $total) * 100, 2)
+ : 0.0;
+
+ return [
+ 'processed' => $processed,
+ 'success' => $success,
+ 'failed' => $failed,
+ 'pending' => $pending,
+ 'flood' => $flood,
+ 'progressPercent' => $progressPercent,
+ 'breakdown' => [
+ 'sent' => $sent,
+ 'deleted' => $deleted,
+ 'unpin' => $unpin,
+ 'edited' => $edited,
+ 'unchanged' => $unchanged,
+ 'scheduled' => $scheduled,
+ ],
+ 'edited' => $edited,
+ 'unchanged' => $unchanged,
+ 'scheduled' => $scheduled,
+ 'selfDestruct' => $state['selfDestruct'],
+ 'type' => $state['type'],
+ 'total' => $total,
+ 'elapsed' => $elapsed,
+ 'tps' => $tps,
+ 'done' => (bool) $state['done'],
+ 'paused' => (bool) $state['paused'],
+ 'cancel' => (bool) $state['cancel'],
+ 'startedAt' => $state['startedAt'],
+ ];
+ }
+
+ /**
+ * Last broadcast data.
+ */
+ public function lastBroadcastData(): string|false
+ {
+ $dir = self::getDataDir();
+
+ if (!is_dir($dir)) {
+ mkdir($dir, 0777, true);
+ }
+
+ $path = $dir . '/LastBrodDATA.txt';
+
+ if (!is_file($path)) {
+ return false;
+ }
+
+ $last = array_key_last(self::$sharedBroadcastState);
+ return $this->formatStatus('last_data', (string) file_get_contents($path), [
+ 'progress' => $last === null ? null : $this->progress((string) $last),
+ ]);
+ }
+
+ /**
+ * Filter peers.
+ *
+ * allowedTypes: all / users / groups / channels
+ *
+ * @return array{targets: array, failed: int, total: int}
+ */
+ public function filterPeers(
+ array $allUsers,
+ string $filterType = 'users'
+ ): array {
+ $allowedTypes = [
+ 'all' => ['user', 'chat', 'supergroup', 'channel'],
+ 'users' => ['user'],
+ 'groups' => ['chat', 'supergroup'],
+ 'channels' => ['channel'],
+ ];
+
+ $targets = [];
+ $failedCount = 0;
+
+ foreach ($allUsers as $peer) {
+ try {
+ $info = $this->api->getInfo($peer);
+ $type = $info['type'] ?? 'user';
+
+ if (in_array($type, $allowedTypes[$filterType] ?? ['user'], true)) {
+ $targets[] = (string) $peer;
+ }
+ } catch (Throwable $e) {
+ if ($filterType === 'all') {
+ $targets[] = (string) $peer;
+ } else {
+ $failedCount++;
+ $this->logError('Failed to inspect peer type.', $e, ['peer' => (string) $peer]);
+ }
+ }
+ }
+
+ return [
+ 'targets' => $targets,
+ 'failed' => $failedCount,
+ 'total' => count($targets),
+ ];
+ }
+
+ private function createState(string $id, string $type, int $total, array $extra = []): array
+ {
+ return $extra + [
+ 'id' => $id,
+ 'type' => $type,
+ 'status' => 'running',
+ 'total' => max(0, $total),
+ 'sent' => 0,
+ 'deleted' => 0,
+ 'unpin' => 0,
+ 'edited' => 0,
+ 'unchanged' => 0,
+ 'scheduled' => 0,
+ 'failed' => 0,
+ 'flood' => 0,
+ 'queue' => new SplQueue(),
+ 'inFlight' => [],
+ 'lastMessageIds' => [],
+ 'paused' => false,
+ 'cancel' => false,
+ 'done' => false,
+ 'startedAt' => microtime(true),
+ 'selfDestruct' => null,
+ ];
+ }
+
+ private function registerCurrentState(string $id, array &$state): void
+ {
+ $this->currentBroadcastState[$id] =& $state;
+ $this->pruneCompletedRuntimeStates();
+ self::$sharedBroadcastState[$id] =& $state;
+ }
+
+ private function enqueuePeers(array &$state, array $peers): void
+ {
+ foreach ($peers as $peer) {
+ $state['queue']->enqueue([
+ 'peer' => (string) $peer,
+ 'attempts' => 0,
+ 'startedAt' => null,
+ 'availableAt' => 0.0,
+ ]);
+ }
+ }
+
+ private function clampConcurrency(int $concurrency): int
+ {
+ return max(1, min(self::MAX_CONCURRENCY, $concurrency));
+ }
+
+ private function startQueueWorkers(
+ array &$state,
+ int $concurrency,
+ callable $handler,
+ array $hardFailRpcs = [],
+ ?callable $rpcHandler = null,
+ bool $retryThrowable = false
+ ): void {
+ $concurrency = $this->clampConcurrency($concurrency);
+
+ for ($i = 0; $i < $concurrency; $i++) {
+ $this->launchWorker($state, function () use (&$state, $handler, $hardFailRpcs, $rpcHandler, $retryThrowable): void {
+ while (!$state['cancel'] && !$state['done']) {
+ $this->checkRuntime();
+ if ($state['queue']->isEmpty()) {
+ $this->workerSleep(self::WORKER_IDLE_SLEEP, $state);
+ continue;
+ }
+
+ if ($state['paused']) {
+ $this->workerSleep(self::WORKER_PAUSED_SLEEP, $state);
+ continue;
+ }
+
+ $job = $state['queue']->dequeue();
+
+ if (($job['availableAt'] ?? 0) > microtime(true)) {
+ $state['queue']->enqueue($job);
+ $this->workerSleep(self::WORKER_RETRY_SLEEP, $state);
+ continue;
+ }
+
+ while ($state['paused'] && !$state['cancel'] && !$state['done']) {
+ $this->workerSleep(self::WORKER_PAUSED_SLEEP, $state);
+ }
+
+ if ($state['cancel'] || $state['done']) {
+ continue;
+ }
+
+ $peer = (string) $job['peer'];
+ $job['startedAt'] = microtime(true);
+ $state['inFlight'][$peer] = $job;
+
+ try {
+ $handler($job, $state);
+ unset($state['inFlight'][$peer]);
+ } catch (RPCErrorException $e) {
+ unset($state['inFlight'][$peer]);
+
+ if ($rpcHandler !== null && $rpcHandler($e, $job, $state)) {
+ continue;
+ }
+
+ if ($this->handleRpcRetry($e, $job, $state, $hardFailRpcs)) {
+ continue;
+ }
+
+ $this->logError('RPC error during broadcast job.', $e, [
+ 'type' => (string) ($state['type'] ?? ''),
+ 'peer' => $peer,
+ ]);
+ $this->retryOrFail($job, $state);
+ } catch (\Amp\CancelledException $e) {
+ unset($state['inFlight'][$peer]);
+ throw $e;
+ } catch (Throwable $e) {
+ unset($state['inFlight'][$peer]);
+ $this->logError('Unexpected error during broadcast job.', $e, [
+ 'type' => (string) ($state['type'] ?? ''),
+ 'peer' => $peer,
+ ]);
+
+ if ($retryThrowable) {
+ $this->retryOrFail($job, $state);
+ } else {
+ $state['failed']++;
+ }
+ }
+
+ $this->workerSleep(self::WORKER_AFTER_JOB_SLEEP, $state);
+ }
+ });
+ }
+ }
+
+ private function waitForCompletion(array &$state): void
+ {
+ $failure = null;
+ try {
+ while (true) {
+ $this->checkRuntime();
+ if (($state['queue']->isEmpty() || $state['cancel']) && empty($state['inFlight'])) {
+ break;
+ }
+ $this->runtimeSleep(1);
+ }
+ } catch (Throwable $error) {
+ $state['cancel'] = true;
+ $failure = $error;
+ } finally {
+ $state['done'] = true;
+ if (isset($state['runtimeWake'])) {
+ $state['runtimeWake']->cancel($state['runtimeWakeReason']);
+ }
+ // Do not cancel the join itself: in-flight operations must finish unwinding.
+ foreach ($state['runtimeWorkers'] ?? [] as $worker) {
+ try {
+ $worker->await();
+ } catch (Throwable $error) {
+ $state['cancel'] = true;
+ if ($failure === null || $failure === $this->runtimeGuardFailure) {
+ $failure = $error;
+ }
+ }
+ }
+ unset($state['runtimeWorkers'], $state['runtimeWake'], $state['runtimeWakeReason']);
+ $this->pruneCompletedRuntimeStates();
+ if ($state['cancel']) {
+ $state['queue'] = new SplQueue();
+ }
+ }
+ if ($failure !== null && $failure !== $this->runtimeGuardFailure) {
+ throw $failure;
+ }
+ }
+ private function handleRpcRetry(RPCErrorException $e, array &$job, array &$state, array $hardFailRpcs): bool
+ {
+ if ($this->isHardFailRpc((string) ($e->rpc ?? ''), $hardFailRpcs)) {
+ $state['failed']++;
+ return true;
+ }
+
+ $floodWait = $this->parseFloodWait($e);
+ if ($floodWait !== null) {
+ $state['flood']++;
+ $job['attempts']++;
+ $job['availableAt'] = microtime(true) + $floodWait;
+ $job['startedAt'] = null;
+
+ if ($job['attempts'] >= self::MAX_ATTEMPTS) {
+ $state['failed']++;
+ } else {
+ $state['queue']->enqueue($job);
+ }
+
+ return true;
+ }
+
+ return false;
+ }
+
+ private function isHardFailRpc(string $rpc, array $hardFailRpcs = self::SEND_HARD_FAIL_RPCS): bool
+ {
+ return in_array($rpc, $hardFailRpcs, true);
+ }
+
+ private function parseFloodWait(RPCErrorException $e): ?int
+ {
+ if (preg_match('/FLOOD_WAIT_(\d+)/', $e->getMessage(), $m)) {
+ return (int) $m[1];
+ }
+
+ return null;
+ }
+
+ private function retryOrFail(array &$job, array &$state): void
+ {
+ $job['attempts']++;
+ $job['startedAt'] = null;
+
+ if ($job['attempts'] >= self::MAX_ATTEMPTS) {
+ $state['failed']++;
+ return;
+ }
+
+ $state['queue']->enqueue($job);
+ }
+
+ private function sendMessagesToPeer(string $peer, array $messages): array
+ {
+ $messageIds = [];
+ $albumMessages = [];
+
+ foreach ($messages as $message) {
+ if (isset($message['albumFile']) && is_file((string) $message['albumFile'])) {
+ $decoded = json_decode((string) file_get_contents((string) $message['albumFile']), true);
+ $albumMessages = is_array($decoded) ? $decoded : [];
+ }
+ }
+
+ if ($albumMessages) {
+ foreach (array_chunk($albumMessages, 10) as $chunk) {
+ $multi = [];
+
+ foreach ($chunk as $item) {
+ $media = ($item['media']['type'] ?? null) === 'photo'
+ ? ['_' => 'inputMediaPhoto', 'id' => $item['media']['botApiFileId']]
+ : ['_' => 'inputMediaDocument', 'id' => $item['media']['botApiFileId']];
+
+ $multi[] = [
+ '_' => 'inputSingleMedia',
+ 'media' => $media,
+ 'message' => $item['caption'] ?? '',
+ 'entities' => $item['entities'] ?? [],
+ ];
+ }
+
+ foreach ($this->callMessage('sendMultiMedia', ['peer' => $peer, 'multi_media' => $multi]) as $update) {
+ $messageId = (int) $this->api->extractMessageId($update);
+
+ if ($messageId > 0) {
+ $messageIds[] = $messageId;
+ }
+ }
+
+ $this->runtimeSleep(self::SEND_CHUNK_SLEEP);
+ }
+
+ return $messageIds;
+ }
+
+ foreach ($messages as $message) {
+ $method = isset($message['media']) ? 'sendMedia' : 'sendMessage';
+ $payload = $message + [
+ 'peer' => $peer,
+ 'floodWaitLimit' => 172800,
+ ];
+
+ if (isset($message['buttons'])) {
+ $payload['reply_markup'] = $message['buttons'];
+ }
+
+ $result = $this->callMessage($method, $payload);
+ $messageId = (int) $this->api->extractMessageId($result);
+
+ if ($messageId > 0) {
+ $messageIds[] = $messageId;
+ }
+
+ $this->runtimeSleep(self::SEND_MESSAGE_SLEEP);
+ }
+
+ return $messageIds;
+ }
+
+ private function savePeerMessageIds(string $peer, array $messageIds): void
+ {
+ $messageIds = array_values(array_filter(array_map('intval', $messageIds), static fn (int $id): bool => $id > 0));
+
+ if (!$messageIds) {
+ return;
+ }
+
+ $dir = self::getDataDir() . '/' . $peer;
+ $this->ensureDirectory($dir);
+
+ try {
+ file_put_contents($dir . '/messages.txt', implode("\n", $messageIds) . "\n", FILE_APPEND | LOCK_EX);
+ file_put_contents($dir . '/lastBroadcast.txt', (string) end($messageIds), LOCK_EX);
+ } catch (Throwable $e) {
+ $this->logError('Failed to save peer message ids.', $e, ['peer' => $peer]);
+ }
+ }
+
+ private function initializeBroadcastMetadata(string $broadcastId, string $type, int $total, ?int $selfDestructHours): void
+ {
+ $metadata = [
+ 'id' => $broadcastId,
+ 'type' => $type,
+ 'createdAt' => time(),
+ 'status' => 'running',
+ 'total' => $total,
+ 'sent' => 0,
+ 'failed' => 0,
+ 'peers' => [],
+ 'selfDestruct' => [
+ 'enabled' => $selfDestructHours !== null,
+ 'hours' => $selfDestructHours,
+ 'deleteAt' => null,
+ 'deleteJobId' => null,
+ ],
+ ];
+
+ $this->saveBroadcastMetadata($broadcastId, $metadata);
+ }
+
+ private function saveBroadcastPeerMessageIds(
+ string $broadcastId,
+ string $peer,
+ array $messageIds,
+ int $sent,
+ int $failed
+ ): void {
+ $metadata = $this->loadBroadcastMetadata($broadcastId);
+ $messageIds = array_values(array_filter(array_map('intval', $messageIds), static fn (int $id): bool => $id > 0));
+
+ $metadata['sent'] = $sent;
+ $metadata['failed'] = $failed;
+ if (!isset($metadata['peers']) || !is_array($metadata['peers'])) {
+ $metadata['peers'] = [];
+ }
+
+ $metadata['peers'][$peer] = [
+ 'lastMessageId' => $messageIds ? end($messageIds) : null,
+ 'messageIds' => $messageIds,
+ 'status' => 'sent',
+ ];
+
+ $this->saveBroadcastMetadata($broadcastId, $metadata);
+ }
+
+ private function finalizeBroadcastMetadata(string $broadcastId, string $status, int $sent, int $failed): void
+ {
+ $metadata = $this->loadBroadcastMetadata($broadcastId);
+ $metadata['status'] = $status;
+ $metadata['sent'] = $sent;
+ $metadata['failed'] = $failed;
+ $metadata['finishedAt'] = time();
+
+ $this->saveBroadcastMetadata($broadcastId, $metadata);
+ }
+
+ private function saveBroadcastMetadata(string $broadcastId, array $metadata): void
+ {
+ try {
+ $this->writeJsonFileAtomic($this->broadcastMetadataPath($broadcastId), $metadata);
+ } catch (Throwable $e) {
+ $this->logError('Failed to save broadcast metadata.', $e, ['broadcastId' => $broadcastId]);
+ }
+ }
+
+ private function loadBroadcastMetadata(string $broadcastId): array
+ {
+ return $this->readJsonFile($this->broadcastMetadataPath($broadcastId), [
+ 'id' => $broadcastId,
+ 'type' => 'send',
+ 'createdAt' => time(),
+ 'status' => 'running',
+ 'total' => 0,
+ 'sent' => 0,
+ 'failed' => 0,
+ 'peers' => [],
+ 'selfDestruct' => [
+ 'enabled' => false,
+ 'hours' => null,
+ 'deleteAt' => null,
+ 'deleteJobId' => null,
+ ],
+ ]);
+ }
+
+ private function readJsonFile(string $path, array $default = []): array
+ {
+ if (!is_file($path)) {
+ return $default;
+ }
+
+ try {
+ $content = (string) file_get_contents($path);
+
+ if (trim($content) === '') {
+ return $default;
+ }
+
+ $decoded = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
+ return is_array($decoded) ? $decoded : $default;
+ } catch (Throwable $e) {
+ $this->logError('Failed to read JSON file.', $e, ['path' => $path]);
+ return $default;
+ }
+ }
+
+ private function writeJsonFileAtomic(string $path, array $data): void
+ {
+ $this->ensureDirectory(dirname($path));
+
+ $tmp = $path . '.tmp.' . bin2hex(random_bytes(4));
+
+ try {
+ $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
+
+ if (file_put_contents($tmp, $json . "\n", LOCK_EX) === false) {
+ throw new RuntimeException('Unable to write temporary JSON file.');
+ }
+
+ if (!@rename($tmp, $path)) {
+ if (is_file($path)) {
+ @unlink($path);
+ }
+
+ if (!@rename($tmp, $path)) {
+ throw new RuntimeException('Unable to rename temporary JSON file.');
+ }
+ }
+ } catch (Throwable $e) {
+ if (is_file($tmp)) {
+ @unlink($tmp);
+ }
+
+ $this->logError('Failed to write JSON file atomically.', $e, ['path' => $path]);
+ throw $e;
+ }
+ }
+
+ private function logError(string $message, ?Throwable $e = null, array $context = []): void
+ {
+ try {
+ $this->ensureDirectory(self::getDataDir());
+ $entry = [
+ 'time' => date('c'),
+ 'message' => $message,
+ 'error' => $e ? $e->getMessage() : null,
+ 'rpc' => $e instanceof RPCErrorException ? ($e->rpc ?? null) : null,
+ 'context' => $context,
+ ];
+
+ file_put_contents(
+ self::getDataDir() . '/broadcast-errors.log',
+ json_encode($entry, JSON_UNESCAPED_SLASHES) . "\n",
+ FILE_APPEND | LOCK_EX
+ );
+ } catch (Throwable) {
+ // Logging must never break bot execution.
+ }
+ }
+
+ private function runScheduledBroadcast(string $scheduleId): array
+ {
+ $jobs = $this->loadScheduledBroadcasts();
+
+ if (!isset($jobs[$scheduleId])) {
+ return ['status' => 'missing'];
+ }
+
+ if (($jobs[$scheduleId]['status'] ?? null) !== 'scheduled') {
+ return [
+ 'status' => (string) ($jobs[$scheduleId]['status'] ?? 'unknown'),
+ 'broadcastId' => $jobs[$scheduleId]['broadcastId'] ?? null,
+ ];
+ }
+
+ $jobs[$scheduleId]['status'] = 'running';
+ $jobs[$scheduleId]['startedAt'] = time();
+ $this->saveScheduledBroadcasts($jobs);
+
+ try {
+ $job = $jobs[$scheduleId];
+ $broadcastId = $this->broadcastWithProgress(
+ $job['allUsers'] ?? [],
+ $job['messages'] ?? [],
+ $job['chatId'] ?? null,
+ (bool) ($job['pin'] ?? false),
+ (int) ($job['concurrency'] ?? self::DEFAULT_CONCURRENCY),
+ $job['selfDestructHours'] ?? null
+ );
+
+ $progress = $this->progress($broadcastId);
+ $jobs = $this->loadScheduledBroadcasts();
+ $jobs[$scheduleId]['status'] = ($progress['cancel'] ?? false) ? 'cancelled' : 'done';
+ $jobs[$scheduleId]['broadcastId'] = $broadcastId;
+ $jobs[$scheduleId]['finishedAt'] = time();
+ $jobs[$scheduleId]['error'] = null;
+ $this->saveScheduledBroadcasts($jobs);
+
+ return [
+ 'status' => $jobs[$scheduleId]['status'],
+ 'broadcastId' => $broadcastId,
+ ];
+ } catch (\Amp\CancelledException $e) {
+ $jobs = $this->loadScheduledBroadcasts();
+ $jobs[$scheduleId]['status'] = 'cancelled';
+ $jobs[$scheduleId]['finishedAt'] = time();
+ $jobs[$scheduleId]['error'] = 'Runtime cancelled; delivery outcome may be partial.';
+ $this->saveScheduledBroadcasts($jobs);
+ throw $e;
+ } catch (Throwable $e) {
+ $jobs = $this->loadScheduledBroadcasts();
+ $jobs[$scheduleId]['status'] = 'failed';
+ $jobs[$scheduleId]['error'] = substr($e->getMessage(), 0, 500);
+ $jobs[$scheduleId]['failedAt'] = time();
+ $this->saveScheduledBroadcasts($jobs);
+ $this->logError('Scheduled broadcast failed.', $e, ['scheduleId' => $scheduleId]);
+
+ return [
+ 'status' => 'failed',
+ 'error' => $jobs[$scheduleId]['error'],
+ ];
+ }
+ }
+
+ private function createSelfDestructJob(string $broadcastId, int $hours, $chatId, int $concurrency): ?string
+ {
+ $metadata = $this->loadBroadcastMetadata($broadcastId);
+ $peers = array_filter(
+ $metadata['peers'] ?? [],
+ static fn ($peerData): bool => is_array($peerData) && !empty($peerData['messageIds'])
+ );
+
+ if (!$peers) {
+ return null;
+ }
+
+ $jobId = $this->createId('selfdestruct');
+ $deleteAt = time() + ($hours * 3600);
+ $jobs = $this->loadSelfDestructJobs();
+
+ $jobs[$jobId] = [
+ 'id' => $jobId,
+ 'broadcastId' => $broadcastId,
+ 'status' => 'scheduled',
+ 'deleteAt' => $deleteAt,
+ 'createdAt' => time(),
+ 'concurrency' => $this->clampConcurrency($concurrency),
+ 'chatId' => $chatId,
+ 'totalPeers' => count($peers),
+ 'stats' => null,
+ 'error' => null,
+ ];
+
+ $this->saveSelfDestructJobs($jobs);
+
+ $metadata['selfDestruct'] = [
+ 'enabled' => true,
+ 'hours' => $hours,
+ 'deleteAt' => $deleteAt,
+ 'deleteJobId' => $jobId,
+ ];
+ $this->saveBroadcastMetadata($broadcastId, $metadata);
+
+ return $jobId;
+ }
+
+ private function runSelfDestructJob(string $jobId): array {
+ return $this->withAdmission(fn(): array => $this->doRunSelfDestructJob($jobId));
+ }
+
+ private function doRunSelfDestructJob(string $jobId): array {
+ $jobs = $this->loadSelfDestructJobs();
+
+ if (!isset($jobs[$jobId])) {
+ return ['status' => 'missing'];
+ }
+
+ if (($jobs[$jobId]['status'] ?? null) !== 'scheduled') {
+ return [
+ 'status' => (string) ($jobs[$jobId]['status'] ?? 'unknown'),
+ 'stats' => $jobs[$jobId]['stats'] ?? null,
+ ];
+ }
+
+ $jobs[$jobId]['status'] = 'running';
+ $jobs[$jobId]['startedAt'] = time();
+ $this->saveSelfDestructJobs($jobs);
+
+ try {
+ $job = $jobs[$jobId];
+ $metadata = $this->loadBroadcastMetadata((string) $job['broadcastId']);
+ $peerJobs = [];
+
+ foreach (($metadata['peers'] ?? []) as $peer => $peerData) {
+ if (!is_array($peerData)) {
+ continue;
+ }
+
+ $messageIds = array_values(array_filter(
+ array_map('intval', $peerData['messageIds'] ?? []),
+ static fn (int $id): bool => $id > 0
+ ));
+
+ if (!$messageIds) {
+ continue;
+ }
+
+ $peerJobs[] = [
+ 'peer' => (string) $peer,
+ 'messageIds' => $messageIds,
+ 'attempts' => 0,
+ 'startedAt' => null,
+ 'availableAt' => 0.0,
+ ];
+ }
+
+ $state = $this->createState($jobId, 'selfdestruct', count($peerJobs), [
+ 'selfDestruct' => [
+ 'jobId' => $jobId,
+ 'broadcastId' => (string) $job['broadcastId'],
+ 'deleteAt' => (int) $job['deleteAt'],
+ ],
+ ]);
+ $this->registerCurrentState($jobId, $state);
+
+ foreach ($peerJobs as $peerJob) {
+ $state['queue']->enqueue($peerJob);
+ }
+
+ $this->startQueueWorkers(
+ $state,
+ (int) ($job['concurrency'] ?? self::DEFAULT_CONCURRENCY),
+ function (array $peerJob, array &$state): void {
+ $messageIds = array_values(array_filter(
+ array_map('intval', $peerJob['messageIds'] ?? []),
+ static fn (int $id): bool => $id > 0
+ ));
+
+ if (!$messageIds) {
+ return;
+ }
+
+ $this->callNativeMessage('deleteMessages', [
+ 'peer' => (string) $peerJob['peer'],
+ 'id' => $messageIds,
+ 'revoke' => true,
+ ]);
+
+ $state['deleted']++;
+ },
+ self::DELETE_HARD_FAIL_RPCS
+ );
+
+ $this->waitForCompletion($state);
+ $state['status'] = $state['cancel'] ? 'cancelled' : 'done';
+
+ $stats = [
+ 'deleted' => (int) $state['deleted'],
+ 'failed' => (int) $state['failed'],
+ 'flood' => (int) $state['flood'],
+ 'total' => (int) $state['total'],
+ ];
+
+ $jobs = $this->loadSelfDestructJobs();
+ $jobs[$jobId]['status'] = $state['cancel'] ? 'cancelled' : 'done';
+ $jobs[$jobId]['finishedAt'] = time();
+ $jobs[$jobId]['stats'] = $stats;
+ $jobs[$jobId]['error'] = null;
+ $this->saveSelfDestructJobs($jobs);
+
+ return [
+ 'status' => $jobs[$jobId]['status'],
+ 'stats' => $stats,
+ ];
+ } catch (\Amp\CancelledException $e) {
+ $jobs = $this->loadSelfDestructJobs();
+ $jobs[$jobId]['status'] = 'cancelled';
+ $jobs[$jobId]['finishedAt'] = time();
+ $jobs[$jobId]['error'] = 'Runtime cancelled; delivery outcome may be partial.';
+ $this->saveSelfDestructJobs($jobs);
+ throw $e;
+ } catch (Throwable $e) {
+ $jobs = $this->loadSelfDestructJobs();
+ $jobs[$jobId]['status'] = 'failed';
+ $jobs[$jobId]['failedAt'] = time();
+ $jobs[$jobId]['error'] = substr($e->getMessage(), 0, 500);
+ $this->saveSelfDestructJobs($jobs);
+ $this->logError('Self-destruct job failed.', $e, ['jobId' => $jobId]);
+
+ return [
+ 'status' => 'failed',
+ 'error' => $jobs[$jobId]['error'],
+ ];
+ }
+ }
+
+ private function normalizeBroadcastState(array $state): array
+ {
+ return [
+ 'id' => $state['id'] ?? null,
+ 'type' => $state['type'] ?? null,
+ 'sent' => $state['sent'] ?? 0,
+ 'deleted' => $state['deleted'] ?? 0,
+ 'unpin' => $state['unpin'] ?? 0,
+ 'edited' => $state['edited'] ?? 0,
+ 'unchanged' => $state['unchanged'] ?? 0,
+ 'scheduled' => $state['scheduled'] ?? 0,
+ 'failed' => $state['failed'] ?? 0,
+ 'flood' => $state['flood'] ?? 0,
+ 'total' => $state['total'] ?? null,
+ 'queue' => $state['queue'] ?? null,
+ 'inFlight' => $state['inFlight'] ?? [],
+ 'done' => $state['done'] ?? false,
+ 'paused' => $state['paused'] ?? false,
+ 'cancel' => $state['cancel'] ?? false,
+ 'startedAt' => $state['startedAt'] ?? null,
+ 'selfDestruct' => $state['selfDestruct'] ?? null,
+ ];
+ }
+
+ private function processedCount(array $state): int
+ {
+ return (int) $state['sent']
+ + (int) $state['deleted']
+ + (int) $state['unpin']
+ + (int) $state['edited']
+ + (int) $state['unchanged']
+ + (int) $state['scheduled']
+ + (int) $state['failed'];
+ }
+
+ private function pendingCount(array $state, int $processed): int
+ {
+ if (isset($state['total']) && is_int($state['total'])) {
+ return max(0, $state['total'] - $processed);
+ }
+
+ return ($state['queue'] instanceof SplQueue) ? $state['queue']->count() : 0;
+ }
+
+ private function elapsedSeconds(array $state): float
+ {
+ if (!isset($state['startedAt']) || !$state['startedAt']) {
+ return 0.0;
+ }
+
+ return max(0.0, microtime(true) - (float) $state['startedAt']);
+ }
+
+ private function buildProgressText(array $state, string $title, bool $final = false): string
+ {
+ $normalized = $this->normalizeBroadcastState($state);
+ $processed = $this->processedCount($normalized);
+ $total = (int) ($normalized['total'] ?? $processed);
+ $pending = $this->pendingCount($normalized, $processed);
+ $success = (int) $normalized['sent']
+ + (int) $normalized['deleted']
+ + (int) $normalized['unpin']
+ + (int) $normalized['edited']
+ + (int) $normalized['unchanged'];
+ $elapsed = $this->elapsedSeconds($normalized);
+ $tps = $elapsed > 0 ? round($success / $elapsed, 2) : 0.0;
+
+ if ($this->statusFormatter !== null) {
+ $context = array_intersect_key($normalized, array_flip([
+ 'id', 'type', 'sent', 'edited', 'unchanged', 'deleted', 'unpin', 'failed', 'flood', 'paused', 'cancel', 'done',
+ ]));
+ return $this->formatStatus('progress', '', $context + [
+ 'processed' => $processed, 'total' => $total, 'pending' => $pending, 'success' => $success,
+ 'tps' => $tps, 'progress_bar' => $this->progressBar($processed, max(1, $total)), 'final' => $final,
+ ]);
+ }
+ $lines = [
+ '' . $title . '',
+ '',
+ '' . $this->progressBar($processed, max(1, $total)) . '',
+ '',
+ 'Processed: ' . $processed . ' / ' . $total,
+ ];
+
+ foreach (['sent' => 'Sent', 'edited' => 'Edited', 'unchanged' => 'Unchanged', 'deleted' => 'Deleted', 'unpin' => 'Unpinned'] as $key => $label) {
+ if ((int) $normalized[$key] > 0 || $normalized['type'] === $key || ($key === 'edited' && $normalized['type'] === 'edit')) {
+ $lines[] = $label . ': ' . (int) $normalized[$key];
+ }
+ }
+
+ $lines[] = 'Failed: ' . (int) $normalized['failed'];
+ $lines[] = 'FLOOD_WAIT: ' . (int) $normalized['flood'];
+ $lines[] = 'Pending: ' . $pending;
+ $lines[] = 'TPS: ' . $tps . '/s';
+
+ if ($normalized['paused']) {
+ $lines[] = 'Paused';
+ }
+
+ if ($normalized['cancel']) {
+ $lines[] = 'Cancelled';
+ } elseif ($final) {
+ $lines[] = 'Finished';
+ }
+
+ return implode("\n", $lines);
+ }
+
+ /**
+ * Progress bar.
+ */
+ private function progressBar(int $current, int $total): string
+ {
+ $len = 20;
+ $filled = (int) round($current / max($total, 1) * $len);
+
+ return str_repeat('#', $filled)
+ . str_repeat('-', max(0, $len - $filled))
+ . ' '
+ . round(($current / max($total, 1)) * 100)
+ . '%';
+ }
+
+ private function buildStatusControls(array $state): ?array
+ {
+ if (($state['done'] ?? false) || empty($state['id'])) {
+ return null;
+ }
+
+ $id = (string) $state['id'];
+ $toggleAction = !empty($state['paused']) ? 'resume' : 'pause';
+ $context = ['id' => $id, 'type' => $state['type'] ?? null, 'paused' => !empty($state['paused'])];
+ $toggleText = $this->formatStatus($toggleAction, !empty($state['paused']) ? 'Resume' : 'Pause', $context);
+
+ $controls = [
+ 'inline_keyboard' => [
+ [
+ ['text' => $toggleText, 'callback_data' => 'bm:' . $toggleAction . ':' . $id],
+ ['text' => $this->formatStatus('cancel', 'Cancel', $context), 'callback_data' => 'bm:cancel:' . $id],
+ ],
+ ],
+ ];
+ return $this->statusControlsFormatter === null
+ ? $controls
+ : ($this->statusControlsFormatter)($controls, $context);
+ }
+
+ private function sendStatusMessage($chatId, string $message, ?array $replyMarkup = null): ?int
+ {
+ if (!$chatId) {
+ return null;
+ }
+
+ try {
+ $payload = [
+ 'peer' => $chatId,
+ 'message' => $message,
+ 'parse_mode' => 'HTML',
+ ];
+
+ if ($replyMarkup !== null) {
+ $payload['reply_markup'] = $replyMarkup;
+ }
+
+ $status = $this->callMessage('sendMessage', $payload);
+
+ return (int) $this->api->extractMessageId($status);
+ } catch (Throwable $e) {
+ $this->logError('Failed to send status message.', $e);
+ return null;
+ }
+ }
+
+ private function startProgressLoop($chatId, ?int $statusId, array &$state, string $title): void
+ {
+ if (!$chatId || !$statusId) {
+ return;
+ }
+
+ $this->launchWorker($state, function () use ($chatId, $statusId, &$state, $title): void {
+ $last = '';
+ $loggedFailures = 0;
+
+ while (!$state['done']) {
+ $this->checkRuntime();
+ $text = $this->buildProgressText($state, $title);
+ $replyMarkup = $this->buildStatusControls($state);
+ $fingerprint = $text . "\n" . json_encode($replyMarkup);
+
+ if ($fingerprint !== $last) {
+ try {
+ $payload = [
+ 'peer' => $chatId,
+ 'id' => $statusId,
+ 'message' => $text,
+ 'parse_mode' => 'HTML',
+ ];
+
+ if ($replyMarkup !== null) {
+ $payload['reply_markup'] = $replyMarkup;
+ }
+
+ $this->callMessage('editMessage', $payload);
+ $last = $fingerprint;
+ } catch (RPCErrorException $e) {
+ if (
+ ($e->rpc ?? '') === 'MESSAGE_NOT_MODIFIED'
+ || str_contains($e->getMessage(), 'MESSAGE_NOT_MODIFIED')
+ ) {
+ $last = $fingerprint;
+ $this->workerSleep(self::PROGRESS_UPDATE_INTERVAL, $state);
+ continue;
+ }
+
+ if ($loggedFailures < 3) {
+ $loggedFailures++;
+ $this->logError('Failed to update status message.', $e);
+ }
+ } catch (\Amp\CancelledException $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ if ($loggedFailures < 3) {
+ $loggedFailures++;
+ $this->logError('Failed to update status message.', $e);
+ }
+ }
+ }
+
+ $this->workerSleep(self::PROGRESS_UPDATE_INTERVAL, $state);
+ }
+ });
+ }
+
+ private function editStatusMessage($chatId, ?int $statusId, string $text, ?array $replyMarkup = null): void
+ {
+ if (!$chatId || !$statusId) {
+ return;
+ }
+
+ try {
+ $payload = [
+ 'peer' => $chatId,
+ 'id' => $statusId,
+ 'message' => $text,
+ 'parse_mode' => 'HTML',
+ ];
+
+ if ($replyMarkup !== null) {
+ $payload['reply_markup'] = $replyMarkup;
+ }
+
+ $this->callMessage('editMessage', $payload);
+ } catch (RPCErrorException $e) {
+ if (
+ ($e->rpc ?? '') === 'MESSAGE_NOT_MODIFIED'
+ || str_contains($e->getMessage(), 'MESSAGE_NOT_MODIFIED')
+ ) {
+ return;
+ }
+
+ $this->logError('Failed to edit final status message.', $e);
+ } catch (Throwable $e) {
+ $this->logError('Failed to edit final status message.', $e);
+ }
+ }
+
+ private function writeLastBroadcastData(string $text): void
+ {
+ try {
+ $this->ensureDirectory(self::getDataDir());
+ file_put_contents(self::getDataDir() . '/LastBrodDATA.txt', $text, LOCK_EX);
+ } catch (Throwable $e) {
+ $this->logError('Failed to save last broadcast data.', $e);
+ }
+ }
+
+ private function readLastBroadcastMessageId(string $peer): int
+ {
+ $file = $this->peerDataPath($peer, 'lastBroadcast.txt');
+
+ if (!is_file($file)) {
+ return 0;
+ }
+
+ return (int) trim((string) file_get_contents($file));
+ }
+
+ private function readBroadcastLastMessageId(string $broadcastId, string $peer): int
+ {
+ $metadata = $this->loadBroadcastMetadata($broadcastId);
+ $peerData = $metadata['peers'][$peer] ?? null;
+
+ if (!is_array($peerData)) {
+ return 0;
+ }
+
+ $lastMessageId = (int) ($peerData['lastMessageId'] ?? 0);
+ if ($lastMessageId > 0) {
+ return $lastMessageId;
+ }
+
+ $messageIds = array_values(array_filter(
+ array_map('intval', $peerData['messageIds'] ?? []),
+ static fn (int $id): bool => $id > 0
+ ));
+
+ return $messageIds ? (int) end($messageIds) : 0;
+ }
+
+ private function broadcastMetadataPeers(string $broadcastId): array
+ {
+ $metadata = $this->loadBroadcastMetadata($broadcastId);
+
+ if (!isset($metadata['peers']) || !is_array($metadata['peers'])) {
+ return [];
+ }
+
+ return array_values(array_map('strval', array_keys($metadata['peers'])));
+ }
+
+ private function markBroadcastPeerMessageDeleted(string $broadcastId, string $peer, int $messageId): void
+ {
+ $metadata = $this->loadBroadcastMetadata($broadcastId);
+ $peerData = $metadata['peers'][$peer] ?? null;
+
+ if (!is_array($peerData)) {
+ return;
+ }
+
+ $messageIds = array_values(array_filter(
+ array_map('intval', $peerData['messageIds'] ?? []),
+ static fn (int $id): bool => $id > 0 && $id !== $messageId
+ ));
+
+ $metadata['peers'][$peer]['messageIds'] = $messageIds;
+ $metadata['peers'][$peer]['lastMessageId'] = $messageIds ? end($messageIds) : null;
+ $metadata['peers'][$peer]['status'] = $messageIds ? 'partial' : 'deleted';
+
+ $this->saveBroadcastMetadata($broadcastId, $metadata);
+ }
+
+ private function peerDataPath(string $peer, string $file): string
+ {
+ return self::getDataDir() . '/' . $peer . '/' . $file;
+ }
+
+ private function broadcastMetadataPath(string $broadcastId): string
+ {
+ return self::getDataDir() . '/broadcasts/' . $broadcastId . '.json';
+ }
+
+ private function scheduledBroadcastsPath(): string
+ {
+ return self::getDataDir() . '/scheduled-broadcasts.json';
+ }
+
+ private function selfDestructJobsPath(): string
+ {
+ return self::getDataDir() . '/self-destruct-jobs.json';
+ }
+
+ private function loadScheduledBroadcasts(): array
+ {
+ return $this->readJsonFile($this->scheduledBroadcastsPath(), []);
+ }
+
+ private function saveScheduledBroadcasts(array $jobs): void
+ {
+ $this->writeJsonFileAtomic($this->scheduledBroadcastsPath(), $jobs);
+ }
+
+ private function loadSelfDestructJobs(): array
+ {
+ return $this->readJsonFile($this->selfDestructJobsPath(), []);
+ }
+
+ private function saveSelfDestructJobs(array $jobs): void
+ {
+ $this->writeJsonFileAtomic($this->selfDestructJobsPath(), $jobs);
+ }
+
+ private function validateSelfDestructHours(?int $hours): void
+ {
+ if ($hours === null) {
+ return;
+ }
+
+ if ($hours < 0 || $hours > 48) {
+ throw new InvalidArgumentException('selfDestructHours must be null or an integer between 0 and 48.');
+ }
+ }
+
+ private function normalizeOptionalId(?string $id): ?string
+ {
+ if ($id === null) {
+ return null;
+ }
+
+ $id = trim($id);
+
+ return $id === '' ? null : $id;
+ }
+
+ private function assertJsonEncodable(mixed $value, string $name): void
+ {
+ try {
+ json_encode($value, JSON_THROW_ON_ERROR);
+ } catch (JsonException $e) {
+ throw new InvalidArgumentException($name . ' must be JSON encodable: ' . $e->getMessage(), 0, $e);
+ }
+ }
+
+ private function createId(string $prefix = ''): string
+ {
+ $id = bin2hex(random_bytes(8));
+
+ return $prefix === '' ? $id : $prefix . '_' . $id;
+ }
+
+ private function ensureDirectory(string $dir): void
+ {
+ if (is_dir($dir)) {
+ return;
+ }
+
+ if (!mkdir($dir, 0777, true) && !is_dir($dir)) {
+ throw new RuntimeException('Unable to create directory: ' . $dir);
+ }
+ }
+
+ private function deleteFile(string $file): void
+ {
+ try {
+ if (is_file($file)) {
+ @unlink($file);
+ }
+ } catch (Throwable $e) {
+ $this->logError('Failed to delete file.', $e, ['file' => $file]);
+ }
+ }
+}
diff --git a/patches/broadcastmanager/status-localization-hooks.patch b/patches/broadcastmanager/status-localization-hooks.patch
new file mode 100644
index 0000000..44018fa
--- /dev/null
+++ b/patches/broadcastmanager/status-localization-hooks.patch
@@ -0,0 +1,751 @@
+--- a/src/BroadcastManager.php
++++ b/src/BroadcastManager.php
+@@ -61,6 +61,13 @@ class BroadcastManager
+ 'MESSAGE_ID_INVALID',
+ ];
+-
++
++ private ?\Amp\CancelledException $runtimeGuardFailure = null;
++ private ?\Closure $runtimeAdmission = null;
++ private ?\Closure $runtimeGuard = null;
++ private ?\Amp\Cancellation $runtimeCancellation = null;
++ private ?\Closure $messageTransport = null;
++ private ?\Closure $statusFormatter = null;
++ private ?\Closure $statusControlsFormatter = null;
+ private API $api;
+ private array $currentBroadcastState = [];
+ private static array $sharedBroadcastState = [];
+@@ -71,6 +78,153 @@ class BroadcastManager
+ $this->api = $api;
+ }
+-
++
++ /** Optional local lifecycle policy. Disabled hooks preserve upstream API behavior. */
++ public function setRuntimeGuard(?callable $guard, ?\Amp\Cancellation $cancellation = null): void
++ {
++ $this->runtimeGuard = $guard === null ? null : \Closure::fromCallable($guard);
++ $this->runtimeCancellation = $cancellation;
++ $this->runtimeGuardFailure = null;
++ }
++
++ /** Optional application-wide admission. Upstream callers run immediately. */
++ public function setRuntimeAdmission(?callable $admission): void
++ {
++ $this->runtimeAdmission = $admission === null ? null : \Closure::fromCallable($admission);
++ }
++
++ private function withAdmission(callable $work): mixed
++ {
++ return $this->runtimeAdmission === null ? $work() : ($this->runtimeAdmission)($work);
++ }
++
++ /** Keep recent completed controls without retaining unbounded runtime state. */
++ private function pruneCompletedRuntimeStates(): void
++ {
++ if ($this->runtimeGuard === null && $this->runtimeAdmission === null) {
++ return;
++ }
++ $kept = 0;
++ foreach (array_reverse(array_keys(self::$sharedBroadcastState)) as $id) {
++ $state = self::$sharedBroadcastState[$id];
++ if (empty($state['done']) || !empty($state['inFlight']) || isset($state['runtimeWorkers'])) {
++ continue;
++ }
++ if (++$kept > 128) {
++ unset(self::$sharedBroadcastState[$id], $this->currentBroadcastState[$id]);
++ }
++ }
++ $kept = 0;
++ foreach (array_reverse(array_keys($this->currentBroadcastState)) as $id) {
++ $state = $this->currentBroadcastState[$id];
++ if (!empty($state['done']) && empty($state['inFlight']) && !isset($state['runtimeWorkers']) && ++$kept > 128) {
++ unset($this->currentBroadcastState[$id]);
++ }
++ }
++ }
++ private function checkRuntime(): void
++ {
++ $this->runtimeCancellation?->throwIfRequested();
++ if ($this->runtimeGuard !== null && ($this->runtimeGuard)() !== true) {
++ $this->runtimeGuardFailure ??= new \Amp\CancelledException(new RuntimeException('Broadcast runtime authorization or shutdown guard rejected work.'));
++ throw $this->runtimeGuardFailure;
++ }
++ }
++
++ private function runtimeSleep(float $seconds): void
++ {
++ if ($this->runtimeGuard === null && $this->runtimeCancellation === null) {
++ $this->api->sleep($seconds);
++ return;
++ }
++ $this->checkRuntime();
++ \Amp\delay($seconds, cancellation: $this->runtimeCancellation);
++ $this->checkRuntime();
++ }
++
++ private function workerSleep(float $seconds, array &$state): void
++ {
++ if ($this->runtimeGuard === null && $this->runtimeCancellation === null) {
++ $this->api->sleep($seconds);
++ return;
++ }
++ $this->checkRuntime();
++ $signals = [$state['runtimeWake']->getCancellation()];
++ if ($this->runtimeCancellation !== null) {
++ $signals[] = $this->runtimeCancellation;
++ }
++ \Amp\delay($seconds, cancellation: new \Amp\CompositeCancellation(...$signals));
++ $this->checkRuntime();
++ }
++ private function callNativeMessage(string $method, array $parameters): mixed
++ {
++ $this->checkRuntime();
++ if ($this->runtimeGuard !== null || $this->runtimeCancellation !== null) {
++ $parameters['floodWaitLimit'] = 0;
++ $parameters['cancellation'] = $this->runtimeCancellation;
++ }
++ return $this->api->messages->{$method}($parameters);
++ }
++
++ /** Every launched child is retained and drained by waitForCompletion. */
++ private function launchWorker(array &$state, callable $work): void
++ {
++ $state['runtimeWake'] ??= new \Amp\DeferredCancellation();
++ $state['runtimeWakeReason'] ??= new RuntimeException('Completed broadcast worker scope.');
++ $state['runtimeWorkers'][] = \Amp\async(static function () use (&$state, $work): void {
++ try {
++ $work();
++ } catch (Throwable $error) {
++ if ($error instanceof \Amp\CancelledException && $error->getPrevious() === $state['runtimeWakeReason']) {
++ return;
++ }
++ $state['cancel'] = true;
++ throw $error;
++ }
++ });
++ }
++ /** Optional per-instance presentation hook; default upstream output is unchanged. */
++ public function setStatusFormatter(?callable $formatter): void
++ {
++ $this->statusFormatter = $formatter === null ? null : \Closure::fromCallable($formatter);
++ }
++
++ /** Optional native-markup hook. Preserve each supplied bm callback identifier. */
++ public function setStatusControlsFormatter(?callable $formatter): void
++ {
++ $this->statusControlsFormatter = $formatter === null ? null : \Closure::fromCallable($formatter);
++ }
++
++ private function formatStatus(string $event, string $default, array $context): string
++ {
++ if ($this->statusFormatter === null) {
++ return $default;
++ }
++ $text = ($this->statusFormatter)($event, $context + ['default' => $default]);
++ if (!is_string($text) || trim($text) === '' || !mb_check_encoding($text, 'UTF-8')
++ || strlen(mb_convert_encoding($text, 'UTF-16LE', 'UTF-8')) > 8192) {
++ throw new InvalidArgumentException('Status formatter must return bounded non-empty UTF-8 text.');
++ }
++ return $text;
++ }
++ /** Optional per-instance message adapter; null preserves exact native dispatch. */
++ public function setMessageTransport(?callable $transport): void
++ {
++ $this->messageTransport = $transport === null ? null : \Closure::fromCallable($transport);
++ }
++
++ private function callMessage(string $method, array $parameters): mixed
++ {
++ $this->checkRuntime();
++ if ($this->runtimeCancellation !== null) {
++ $parameters['cancellation'] = $this->runtimeCancellation;
++ }
++ if (!in_array($method, ['sendMessage', 'sendMedia', 'sendMultiMedia', 'editMessage'], true)) {
++ throw new InvalidArgumentException('Unsupported broadcast message transport method.');
++ }
++ return $this->messageTransport === null
++ ? $this->callNativeMessage($method, $parameters)
++ : ($this->messageTransport)($method, $parameters);
++ }
+ /**
+ * Set data dir.
+ */
+@@ -103,6 +257,17 @@ class BroadcastManager
+ bool $pin = false,
+ int $concurrency = self::DEFAULT_CONCURRENCY,
+ ?int $selfDestructHours = null
++ ): string {
++ return $this->withAdmission(fn(): string => $this->doBroadcastWithProgress($allUsers, $messages, $chatId, $pin, $concurrency, $selfDestructHours));
++ }
++
++ private function doBroadcastWithProgress(
++ array $allUsers,
++ array $messages,
++ $chatId = null,
++ bool $pin = false,
++ int $concurrency = self::DEFAULT_CONCURRENCY,
++ ?int $selfDestructHours = null
+ ): string {
+ $this->validateSelfDestructHours($selfDestructHours);
+-
++
+@@ -122,7 +287,7 @@ class BroadcastManager
+ $this->enqueuePeers($state, $allUsers);
+ $this->initializeBroadcastMetadata($broadcastId, 'send', $total, $selfDestructHours);
+-
++
+- $statusId = $this->sendStatusMessage($chatId, 'Gathering peers...', $this->buildStatusControls($state));
++ $statusId = $this->sendStatusMessage($chatId, $this->formatStatus('starting', 'Gathering peers...', ['type' => $state['type'], 'id' => $state['id'], 'total' => $state['total']]), $this->buildStatusControls($state));
+ $this->startProgressLoop($chatId, $statusId, $state, 'Broadcast Progress');
+-
++
+ $this->startQueueWorkers(
+@@ -134,7 +299,7 @@ class BroadcastManager
+ $lastMessageId = $messageIds ? end($messageIds) : null;
+-
++
+ if ($pin && $lastMessageId) {
+- $this->api->messages->updatePinnedMessage([
++ $this->callNativeMessage('updatePinnedMessage', [
+ 'peer' => $peer,
+ 'id' => $lastMessageId,
+ 'unpin' => false,
+@@ -201,6 +366,19 @@ class BroadcastManager
+ int $concurrency = self::DEFAULT_CONCURRENCY,
+ string $parseMode = 'HTML',
+ ?string $broadcastId = null
++ ): string {
++ return $this->withAdmission(fn(): string => $this->doEditLastBroadcastForAll($allUsers, $newText, $chatId, $buttons, $media, $concurrency, $parseMode, $broadcastId));
++ }
++
++ private function doEditLastBroadcastForAll(
++ array $allUsers,
++ string $newText,
++ $chatId = null,
++ ?array $buttons = null,
++ $media = null,
++ int $concurrency = self::DEFAULT_CONCURRENCY,
++ string $parseMode = 'HTML',
++ ?string $broadcastId = null
+ ): string {
+ $concurrency = $this->clampConcurrency($concurrency);
+ $targetBroadcastId = $this->normalizeOptionalId($broadcastId);
+@@ -215,7 +393,7 @@ class BroadcastManager
+ $this->registerCurrentState($operationId, $state);
+ $this->enqueuePeers($state, $targets);
+-
++
+- $statusId = $this->sendStatusMessage($chatId, 'Editing last broadcast...', $this->buildStatusControls($state));
++ $statusId = $this->sendStatusMessage($chatId, $this->formatStatus('starting', 'Editing last broadcast...', ['type' => $state['type'], 'id' => $state['id'], 'total' => $state['total']]), $this->buildStatusControls($state));
+ $this->startProgressLoop($chatId, $statusId, $state, 'Editing Last Broadcast');
+-
++
+ $this->startQueueWorkers(
+@@ -248,7 +426,7 @@ class BroadcastManager
+ $payload['media'] = $media;
+ }
+-
++
+- $this->api->messages->editMessage($payload);
++ $this->callMessage('editMessage', $payload);
+ $state['edited']++;
+ },
+ self::EDIT_HARD_FAIL_RPCS,
+@@ -326,6 +504,14 @@ class BroadcastManager
+ $now = time();
+-
++
+ foreach ($jobs as $scheduleId => $job) {
++ try {
++ $this->checkRuntime();
++ } catch (\Amp\CancelledException $error) {
++ if ($error !== $this->runtimeGuardFailure) {
++ throw $error;
++ }
++ break;
++ }
+ if (($job['status'] ?? null) !== 'scheduled') {
+ continue;
+ }
+@@ -388,6 +574,15 @@ class BroadcastManager
+ $chatId = null,
+ int $concurrency = self::DEFAULT_CONCURRENCY,
+ ?string $broadcastId = null
++ ): string {
++ return $this->withAdmission(fn(): string => $this->doDeleteLastBroadcastForAll($allUsers, $chatId, $concurrency, $broadcastId));
++ }
++
++ private function doDeleteLastBroadcastForAll(
++ array $allUsers,
++ $chatId = null,
++ int $concurrency = self::DEFAULT_CONCURRENCY,
++ ?string $broadcastId = null
+ ): string {
+ $concurrency = $this->clampConcurrency($concurrency);
+ $targetBroadcastId = $this->normalizeOptionalId($broadcastId);
+@@ -402,7 +597,7 @@ class BroadcastManager
+ $this->registerCurrentState($operationId, $state);
+ $this->enqueuePeers($state, $targets);
+-
++
+- $statusId = $this->sendStatusMessage($chatId, 'Deleting last broadcast...', $this->buildStatusControls($state));
++ $statusId = $this->sendStatusMessage($chatId, $this->formatStatus('starting', 'Deleting last broadcast...', ['type' => $state['type'], 'id' => $state['id'], 'total' => $state['total']]), $this->buildStatusControls($state));
+ $this->startProgressLoop($chatId, $statusId, $state, 'Deleting Last Broadcast');
+-
++
+ $this->startQueueWorkers(
+@@ -419,7 +614,7 @@ class BroadcastManager
+ return;
+ }
+-
++
+- $this->api->messages->deleteMessages([
++ $this->callNativeMessage('deleteMessages', [
+ 'peer' => $peer,
+ 'id' => [$lastMessageId],
+ 'revoke' => true,
+@@ -453,6 +648,14 @@ class BroadcastManager
+ array $allUsers,
+ $chatId = null,
+ int $concurrency = self::DEFAULT_CONCURRENCY
++ ): string {
++ return $this->withAdmission(fn(): string => $this->doDeleteAllBroadcastsForAll($allUsers, $chatId, $concurrency));
++ }
++
++ private function doDeleteAllBroadcastsForAll(
++ array $allUsers,
++ $chatId = null,
++ int $concurrency = self::DEFAULT_CONCURRENCY
+ ): string {
+ $concurrency = $this->clampConcurrency($concurrency);
+ $broadcastId = $this->createId();
+@@ -461,7 +664,7 @@ class BroadcastManager
+ $this->registerCurrentState($broadcastId, $state);
+ $this->enqueuePeers($state, $allUsers);
+-
++
+- $statusId = $this->sendStatusMessage($chatId, 'Deleting all broadcasts...', $this->buildStatusControls($state));
++ $statusId = $this->sendStatusMessage($chatId, $this->formatStatus('starting', 'Deleting all broadcasts...', ['type' => $state['type'], 'id' => $state['id'], 'total' => $state['total']]), $this->buildStatusControls($state));
+ $this->startProgressLoop($chatId, $statusId, $state, 'Deleting All Broadcasts');
+-
++
+ $this->startQueueWorkers(
+@@ -490,7 +693,7 @@ class BroadcastManager
+-
++
+ foreach ($msgIds as $messageId) {
+ try {
+- $this->api->messages->deleteMessages([
++ $this->callNativeMessage('deleteMessages', [
+ 'peer' => $peer,
+ 'id' => [$messageId],
+ 'revoke' => true,
+@@ -542,6 +745,14 @@ class BroadcastManager
+ array $allUsers,
+ $chatId = null,
+ int $concurrency = self::DEFAULT_CONCURRENCY
++ ): string {
++ return $this->withAdmission(fn(): string => $this->doUnpinAllMessagesForAll($allUsers, $chatId, $concurrency));
++ }
++
++ private function doUnpinAllMessagesForAll(
++ array $allUsers,
++ $chatId = null,
++ int $concurrency = self::DEFAULT_CONCURRENCY
+ ): string {
+ $concurrency = $this->clampConcurrency($concurrency);
+ $broadcastId = $this->createId();
+@@ -550,14 +761,14 @@ class BroadcastManager
+ $this->registerCurrentState($broadcastId, $state);
+ $this->enqueuePeers($state, $allUsers);
+-
++
+- $statusId = $this->sendStatusMessage($chatId, 'Starting unpin...', $this->buildStatusControls($state));
++ $statusId = $this->sendStatusMessage($chatId, $this->formatStatus('starting', 'Starting unpin...', ['type' => $state['type'], 'id' => $state['id'], 'total' => $state['total']]), $this->buildStatusControls($state));
+ $this->startProgressLoop($chatId, $statusId, $state, 'Unpinning Messages');
+-
++
+ $this->startQueueWorkers(
+ $state,
+ $concurrency,
+ function (array $job, array &$state): void {
+- $this->api->messages->unpinAllMessages([
++ $this->callNativeMessage('unpinAllMessages', [
+ 'peer' => (string) $job['peer'],
+ ]);
+-
++
+@@ -581,6 +792,14 @@ class BroadcastManager
+ $now = time();
+-
++
+ foreach ($jobs as $jobId => $job) {
++ try {
++ $this->checkRuntime();
++ } catch (\Amp\CancelledException $error) {
++ if ($error !== $this->runtimeGuardFailure) {
++ throw $error;
++ }
++ break;
++ }
+ if (($job['status'] ?? null) !== 'scheduled') {
+ continue;
+ }
+@@ -814,7 +1033,10 @@ class BroadcastManager
+ return false;
+ }
+-
++
+- return (string) file_get_contents($path);
++ $last = array_key_last(self::$sharedBroadcastState);
++ return $this->formatStatus('last_data', (string) file_get_contents($path), [
++ 'progress' => $last === null ? null : $this->progress((string) $last),
++ ]);
+ }
+-
++
+ /**
+@@ -892,6 +1114,7 @@ class BroadcastManager
+ private function registerCurrentState(string $id, array &$state): void
+ {
+ $this->currentBroadcastState[$id] =& $state;
++ $this->pruneCompletedRuntimeStates();
+ self::$sharedBroadcastState[$id] =& $state;
+ }
+-
++
+@@ -923,15 +1146,16 @@ class BroadcastManager
+ $concurrency = $this->clampConcurrency($concurrency);
+-
++
+ for ($i = 0; $i < $concurrency; $i++) {
+- \Amp\async(function () use (&$state, $handler, $hardFailRpcs, $rpcHandler, $retryThrowable): void {
++ $this->launchWorker($state, function () use (&$state, $handler, $hardFailRpcs, $rpcHandler, $retryThrowable): void {
+ while (!$state['cancel'] && !$state['done']) {
++ $this->checkRuntime();
+ if ($state['queue']->isEmpty()) {
+- $this->api->sleep(self::WORKER_IDLE_SLEEP);
++ $this->workerSleep(self::WORKER_IDLE_SLEEP, $state);
+ continue;
+ }
+-
++
+ if ($state['paused']) {
+- $this->api->sleep(self::WORKER_PAUSED_SLEEP);
++ $this->workerSleep(self::WORKER_PAUSED_SLEEP, $state);
+ continue;
+ }
+-
++
+@@ -939,12 +1163,12 @@ class BroadcastManager
+-
++
+ if (($job['availableAt'] ?? 0) > microtime(true)) {
+ $state['queue']->enqueue($job);
+- $this->api->sleep(self::WORKER_RETRY_SLEEP);
++ $this->workerSleep(self::WORKER_RETRY_SLEEP, $state);
+ continue;
+ }
+-
++
+ while ($state['paused'] && !$state['cancel'] && !$state['done']) {
+- $this->api->sleep(self::WORKER_PAUSED_SLEEP);
++ $this->workerSleep(self::WORKER_PAUSED_SLEEP, $state);
+ }
+-
++
+ if ($state['cancel'] || $state['done']) {
+@@ -974,6 +1198,9 @@ class BroadcastManager
+ 'peer' => $peer,
+ ]);
+ $this->retryOrFail($job, $state);
++ } catch (\Amp\CancelledException $e) {
++ unset($state['inFlight'][$peer]);
++ throw $e;
+ } catch (Throwable $e) {
+ unset($state['inFlight'][$peer]);
+ $this->logError('Unexpected error during broadcast job.', $e, [
+@@ -988,7 +1215,7 @@ class BroadcastManager
+ }
+ }
+-
++
+- $this->api->sleep(self::WORKER_AFTER_JOB_SLEEP);
++ $this->workerSleep(self::WORKER_AFTER_JOB_SLEEP, $state);
+ }
+ });
+ }
+@@ -996,21 +1223,44 @@ class BroadcastManager
+-
++
+ private function waitForCompletion(array &$state): void
+ {
+- while (true) {
+- if ($state['queue']->isEmpty() && empty($state['inFlight'])) {
+- break;
++ $failure = null;
++ try {
++ while (true) {
++ $this->checkRuntime();
++ if (($state['queue']->isEmpty() || $state['cancel']) && empty($state['inFlight'])) {
++ break;
++ }
++ $this->runtimeSleep(1);
+ }
+-
+- if ($state['cancel'] && empty($state['inFlight'])) {
+- break;
++ } catch (Throwable $error) {
++ $state['cancel'] = true;
++ $failure = $error;
++ } finally {
++ $state['done'] = true;
++ if (isset($state['runtimeWake'])) {
++ $state['runtimeWake']->cancel($state['runtimeWakeReason']);
++ }
++ // Do not cancel the join itself: in-flight operations must finish unwinding.
++ foreach ($state['runtimeWorkers'] ?? [] as $worker) {
++ try {
++ $worker->await();
++ } catch (Throwable $error) {
++ $state['cancel'] = true;
++ if ($failure === null || $failure === $this->runtimeGuardFailure) {
++ $failure = $error;
++ }
++ }
++ }
++ unset($state['runtimeWorkers'], $state['runtimeWake'], $state['runtimeWakeReason']);
++ $this->pruneCompletedRuntimeStates();
++ if ($state['cancel']) {
++ $state['queue'] = new SplQueue();
+ }
+-
+- $this->api->sleep(1);
+ }
+-
+- $state['done'] = true;
++ if ($failure !== null && $failure !== $this->runtimeGuardFailure) {
++ throw $failure;
++ }
+ }
+-
+ private function handleRpcRetry(RPCErrorException $e, array &$job, array &$state, array $hardFailRpcs): bool
+ {
+ if ($this->isHardFailRpc((string) ($e->rpc ?? ''), $hardFailRpcs)) {
+@@ -1093,7 +1343,7 @@ class BroadcastManager
+ ];
+ }
+-
++
+- foreach ($this->api->messages->sendMultiMedia(['peer' => $peer, 'multi_media' => $multi]) as $update) {
++ foreach ($this->callMessage('sendMultiMedia', ['peer' => $peer, 'multi_media' => $multi]) as $update) {
+ $messageId = (int) $this->api->extractMessageId($update);
+-
++
+ if ($messageId > 0) {
+@@ -1101,7 +1351,7 @@ class BroadcastManager
+ }
+ }
+-
++
+- $this->api->sleep(self::SEND_CHUNK_SLEEP);
++ $this->runtimeSleep(self::SEND_CHUNK_SLEEP);
+ }
+-
++
+ return $messageIds;
+@@ -1118,14 +1368,14 @@ class BroadcastManager
+ $payload['reply_markup'] = $message['buttons'];
+ }
+-
++
+- $result = $this->api->messages->{$method}($payload);
++ $result = $this->callMessage($method, $payload);
+ $messageId = (int) $this->api->extractMessageId($result);
+-
++
+ if ($messageId > 0) {
+ $messageIds[] = $messageId;
+ }
+-
++
+- $this->api->sleep(self::SEND_MESSAGE_SLEEP);
++ $this->runtimeSleep(self::SEND_MESSAGE_SLEEP);
+ }
+-
++
+ return $messageIds;
+@@ -1354,6 +1604,13 @@ class BroadcastManager
+ 'status' => $jobs[$scheduleId]['status'],
+ 'broadcastId' => $broadcastId,
+ ];
++ } catch (\Amp\CancelledException $e) {
++ $jobs = $this->loadScheduledBroadcasts();
++ $jobs[$scheduleId]['status'] = 'cancelled';
++ $jobs[$scheduleId]['finishedAt'] = time();
++ $jobs[$scheduleId]['error'] = 'Runtime cancelled; delivery outcome may be partial.';
++ $this->saveScheduledBroadcasts($jobs);
++ throw $e;
+ } catch (Throwable $e) {
+ $jobs = $this->loadScheduledBroadcasts();
+ $jobs[$scheduleId]['status'] = 'failed';
+@@ -1411,8 +1668,11 @@ class BroadcastManager
+ return $jobId;
+ }
+-
++
+- private function runSelfDestructJob(string $jobId): array
+- {
++ private function runSelfDestructJob(string $jobId): array {
++ return $this->withAdmission(fn(): array => $this->doRunSelfDestructJob($jobId));
++ }
++
++ private function doRunSelfDestructJob(string $jobId): array {
+ $jobs = $this->loadSelfDestructJobs();
+-
++
+ if (!isset($jobs[$jobId])) {
+@@ -1484,7 +1744,7 @@ class BroadcastManager
+ return;
+ }
+-
++
+- $this->api->messages->deleteMessages([
++ $this->callNativeMessage('deleteMessages', [
+ 'peer' => (string) $peerJob['peer'],
+ 'id' => $messageIds,
+ 'revoke' => true,
+@@ -1516,6 +1776,13 @@ class BroadcastManager
+ 'status' => $jobs[$jobId]['status'],
+ 'stats' => $stats,
+ ];
++ } catch (\Amp\CancelledException $e) {
++ $jobs = $this->loadSelfDestructJobs();
++ $jobs[$jobId]['status'] = 'cancelled';
++ $jobs[$jobId]['finishedAt'] = time();
++ $jobs[$jobId]['error'] = 'Runtime cancelled; delivery outcome may be partial.';
++ $this->saveSelfDestructJobs($jobs);
++ throw $e;
+ } catch (Throwable $e) {
+ $jobs = $this->loadSelfDestructJobs();
+ $jobs[$jobId]['status'] = 'failed';
+@@ -1598,6 +1865,15 @@ class BroadcastManager
+ $elapsed = $this->elapsedSeconds($normalized);
+ $tps = $elapsed > 0 ? round($success / $elapsed, 2) : 0.0;
+-
++
++ if ($this->statusFormatter !== null) {
++ $context = array_intersect_key($normalized, array_flip([
++ 'id', 'type', 'sent', 'edited', 'unchanged', 'deleted', 'unpin', 'failed', 'flood', 'paused', 'cancel', 'done',
++ ]));
++ return $this->formatStatus('progress', '', $context + [
++ 'processed' => $processed, 'total' => $total, 'pending' => $pending, 'success' => $success,
++ 'tps' => $tps, 'progress_bar' => $this->progressBar($processed, max(1, $total)), 'final' => $final,
++ ]);
++ }
+ $lines = [
+ '' . $title . '',
+ '',
+@@ -1653,16 +1929,20 @@ class BroadcastManager
+-
++
+ $id = (string) $state['id'];
+ $toggleAction = !empty($state['paused']) ? 'resume' : 'pause';
+- $toggleText = !empty($state['paused']) ? 'Resume' : 'Pause';
++ $context = ['id' => $id, 'type' => $state['type'] ?? null, 'paused' => !empty($state['paused'])];
++ $toggleText = $this->formatStatus($toggleAction, !empty($state['paused']) ? 'Resume' : 'Pause', $context);
+-
++
+- return [
++ $controls = [
+ 'inline_keyboard' => [
+ [
+ ['text' => $toggleText, 'callback_data' => 'bm:' . $toggleAction . ':' . $id],
+- ['text' => 'Cancel', 'callback_data' => 'bm:cancel:' . $id],
++ ['text' => $this->formatStatus('cancel', 'Cancel', $context), 'callback_data' => 'bm:cancel:' . $id],
+ ],
+ ],
+ ];
++ return $this->statusControlsFormatter === null
++ ? $controls
++ : ($this->statusControlsFormatter)($controls, $context);
+ }
+-
++
+ private function sendStatusMessage($chatId, string $message, ?array $replyMarkup = null): ?int
+@@ -1682,7 +1962,7 @@ class BroadcastManager
+ $payload['reply_markup'] = $replyMarkup;
+ }
+-
++
+- $status = $this->api->messages->sendMessage($payload);
++ $status = $this->callMessage('sendMessage', $payload);
+-
++
+ return (int) $this->api->extractMessageId($status);
+ } catch (Throwable $e) {
+@@ -1697,11 +1977,12 @@ class BroadcastManager
+ return;
+ }
+-
++
+- \Amp\async(function () use ($chatId, $statusId, &$state, $title): void {
++ $this->launchWorker($state, function () use ($chatId, $statusId, &$state, $title): void {
+ $last = '';
+ $loggedFailures = 0;
+-
++
+ while (!$state['done']) {
++ $this->checkRuntime();
+ $text = $this->buildProgressText($state, $title);
+ $replyMarkup = $this->buildStatusControls($state);
+ $fingerprint = $text . "\n" . json_encode($replyMarkup);
+@@ -1719,7 +2000,7 @@ class BroadcastManager
+ $payload['reply_markup'] = $replyMarkup;
+ }
+-
++
+- $this->api->messages->editMessage($payload);
++ $this->callMessage('editMessage', $payload);
+ $last = $fingerprint;
+ } catch (RPCErrorException $e) {
+ if (
+@@ -1727,7 +2008,7 @@ class BroadcastManager
+ || str_contains($e->getMessage(), 'MESSAGE_NOT_MODIFIED')
+ ) {
+ $last = $fingerprint;
+- $this->api->sleep(self::PROGRESS_UPDATE_INTERVAL);
++ $this->workerSleep(self::PROGRESS_UPDATE_INTERVAL, $state);
+ continue;
+ }
+-
++
+@@ -1735,6 +2016,8 @@ class BroadcastManager
+ $loggedFailures++;
+ $this->logError('Failed to update status message.', $e);
+ }
++ } catch (\Amp\CancelledException $e) {
++ throw $e;
+ } catch (Throwable $e) {
+ if ($loggedFailures < 3) {
+ $loggedFailures++;
+@@ -1743,7 +2026,7 @@ class BroadcastManager
+ }
+ }
+-
++
+- $this->api->sleep(self::PROGRESS_UPDATE_INTERVAL);
++ $this->workerSleep(self::PROGRESS_UPDATE_INTERVAL, $state);
+ }
+ });
+ }
+@@ -1766,7 +2049,7 @@ class BroadcastManager
+ $payload['reply_markup'] = $replyMarkup;
+ }
+-
++
+- $this->api->messages->editMessage($payload);
++ $this->callMessage('editMessage', $payload);
+ } catch (RPCErrorException $e) {
+ if (
+ ($e->rpc ?? '') === 'MESSAGE_NOT_MODIFIED'
diff --git a/patches/madelineproto-formatted-date/README.md b/patches/madelineproto-formatted-date/README.md
new file mode 100644
index 0000000..908f48e
--- /dev/null
+++ b/patches/madelineproto-formatted-date/README.md
@@ -0,0 +1,29 @@
+# MadelineProto formatted-date entity compatibility
+
+Prepared 2026-10-14 for exact MadelineProto 8.7.0 commit 7b99dd0f2d964a492c377912ffce9f5ce61aa9ed. This reviewed upstream release is a prerelease; it is not described as the latest stable release. The installed layer 225 schema declares `messageEntityFormattedDate#904ac7c7`, but the unchanged `MessageEntity::fromRawEntity` factory throws `AssertionError` before message dispatch. The public v8 source reviewed on this date still lacked the factory arm, so no tested compatible official version eliminated this local fix.
+
+The immutable [factory source](https://github.com/danog/MadelineProto/blob/7b99dd0f2d964a492c377912ffce9f5ce61aa9ed/src/EventHandler/Message/Entities/MessageEntity.php) was downloaded and byte-matched to the locked installation. Its pristine SHA256 is 06a9d438cc198f56cfb745a2a3808932269033671146cf82f84ae88e8d6eae60. The complete original schema and upstream license are retained beside that baseline. All exact input, output, schema, diff, class and license hashes are in manifest.json.
+
+The single unified diff adds two exact factory arms and a new independently authored `FormattedDate` class. The class preserves inherited readonly UTF-16 offset/length, an integer Unix timestamp and all six native flags. It emits/accepts official BotAPI `date_time` (`unix_time`, `date_time_format`) and retains the native MTProto constructor. Integer fields and boolean flags are validated without lossy numeric coercion. Relative/other flags, simultaneous short/long time and simultaneous short/long date are rejected. Empty format preserves the linked original text. See [native constructor](https://core.telegram.org/constructor/messageEntityFormattedDate) and [official format grammar](https://core.telegram.org/bots/api#date-time-entity-formatting).
+
+The wrapper accepts nonnegative native int32 timestamp/position values. Telegram enforces its own date horizon when a real send is performed; the local wrapper does not use the machine clock to alter an incoming or replayed timestamp. No general unknown-entity fallback is added. Existing entity handlers remain unchanged. The installed generic HTML renderer preserves original text and ordinary surrounding formatting, but does not generate a special date tag; native capture/storage/resend preserves the date entity instead of routing it through HTML.
+
+## Historical reference and licensing
+
+The requested exported script at local-reference/newsearch-v2/docker/patch-madeline-formatted-date.php has SHA256 c3091f61a401d8022b8ef1d228cf8c5f31a1094982de69a0be108e21885cb967. Its wrapper has SHA256 a9e3146e49a88bec469e67e53f5e27cffb1b5127cde547590f2e385ef02dd264. The psy new/release-r21-work exports match those bytes. No recoverable Git history/reference commit was available; their metadata conflict between MIT and proprietary and no standalone grant was found. Those exports are documented as behavioral references and are not redistributed here. This is a new implementation, not the original script or patch.
+
+The unmodified factory/schema and upstream-LICENSE retain their original notices. The new WizardLoop wrapper explicitly grants AGPL-3.0-or-later, consistent with the retained MadelineProto source grant. Application/helper code remains AGPL-3.0-only. Preserve the package metadata/source-header discrepancy documented in THIRD_PARTY_NOTICES; do not flatten dependency grants into the application license. The complete diff, original source and new class ship in all release variants and the corresponding-source offer.
+
+## Installation and refusal states
+
+The installer checks exact installed version/commit, complete baseline/diff/reviewed output/license/schema hashes and contained regular targets. The factory and new class are one state: pristine factory plus absent class may be applied; exact reviewed factory plus exact reviewed class succeeds without change. Either partial combination, changed/unrecognized class/factory, symlink/directory, incorrect schema/version or tampered source artifact stops the build. No unknown hash is called upstream-supported, and partial states are not silently repaired.
+
+Run php scripts/apply-patches.php and php scripts/verify-patches.php before bot startup. Composer and Docker execute the shared five-patch lifecycle; the explicit --no-scripts installation path runs the same two commands. The application writer lock prevents mutation of a running installation. A staged addition is installed exclusively before atomic factory replacement; an interruption between files is a detectable partial state and requires explicit maintenance review. Never patch an active process or container. Vendor is not tracked in Git and no mutable patch is fetched during build.
+
+## Verification
+
+FormattedDatePatchTest covers pristine/repeated/read-only verification, both partial states, unknown class/factory, version/commit/schema, all corresponding artifacts, retained license, target types and active-writer refusal. A symlink fixture may be skipped where Windows cannot create it; report that separately. FormattedDateEntityTest exercises all 19 valid flag configurations with the real locked factory/class and native decoder, all 45 invalid combinations, strict types/format, zero/empty defaults, inherited readonly fields, existing unknown rejection, actual StrTools HTML behavior and private template capture/SQLite storage/UTF-16 expansion/native gateway resend with an injected transport.
+
+Native wire fixtures use the actual constructor descriptor and primitive integer encoder, then the actual TL decoder; they do not claim full serializeParams without its API adapter. No Telegram API, session, client rendering or live formatted-date send was exercised.
+
+Historical local-reference paths are logical project/file identifiers, not public download URLs or installation requirements.
diff --git a/patches/madelineproto-formatted-date/baseline/MessageEntity.php b/patches/madelineproto-formatted-date/baseline/MessageEntity.php
new file mode 100644
index 0000000..0e7bd57
--- /dev/null
+++ b/patches/madelineproto-formatted-date/baseline/MessageEntity.php
@@ -0,0 +1,84 @@
+ $entities
+ * @return list
+ */
+ public static function fromRawEntities(array $entities): array
+ {
+ return array_map(self::fromRawEntity(...), $entities);
+ }
+
+ public static function fromRawEntity(array|self $entity): self
+ {
+ if ($entity instanceof self) {
+ return $entity;
+ }
+ return match ($entity['_'] ?? $entity['type']) {
+ 'mention', 'messageEntityMention' => new Mention($entity['offset'], $entity['length']),
+ 'hashtag', 'messageEntityHashtag' => new Hashtag($entity['offset'], $entity['length']),
+ 'bot_command', 'messageEntityBotCommand' => new BotCommand($entity['offset'], $entity['length']),
+ 'email', 'messageEntityEmail' => new Email($entity['offset'], $entity['length']),
+ 'bold', 'messageEntityBold' => new Bold($entity['offset'], $entity['length']),
+ 'italic', 'messageEntityItalic' => new Italic($entity['offset'], $entity['length']),
+ 'url', 'messageEntityUrl' => new Url($entity['offset'], $entity['length']),
+ 'code', 'messageEntityCode' => new Code($entity['offset'], $entity['length']),
+ /** @psalm-suppress MixedArgument */
+ 'pre', 'messageEntityPre' => new Pre($entity['offset'], $entity['length'], $entity['language'] ?? ''),
+ 'text_link', 'messageEntityTextUrl' => new TextUrl($entity['offset'], $entity['length'], $entity['url']),
+ 'text_mention', 'messageEntityMentionName' => new MentionName($entity['offset'], $entity['length'], $entity['user_id'] ?? $entity['user']['id']),
+ 'inputMessageEntityMentionName' => new InputMentionName($entity['offset'], $entity['length'], $entity['user_id']),
+ 'phone_number', 'messageEntityPhone' => new Phone($entity['offset'], $entity['length']),
+ 'cashtag', 'messageEntityCashtag' => new Cashtag($entity['offset'], $entity['length']),
+ 'underline', 'messageEntityUnderline' => new Underline($entity['offset'], $entity['length']),
+ 'strikethrough', 'messageEntityStrike' => new Strike($entity['offset'], $entity['length']),
+ 'block_quote', 'messageEntityBlockquote' => new Blockquote($entity['offset'], $entity['length']),
+ 'bank_card', 'messageEntityBankCard' => new BankCard($entity['offset'], $entity['length']),
+ 'spoiler', 'messageEntitySpoiler' => new Spoiler($entity['offset'], $entity['length']),
+ 'custom_emoji', 'messageEntityCustomEmoji' => new CustomEmoji($entity['offset'], $entity['length'], $entity['document_id'] ?? $entity['custom_emoji_id']),
+ default => throw new AssertionError("Unknown entity type: ".($entity['_'] ?? $entity['type']))
+ };
+ }
+
+ /**
+ * Convert entity to bot API entity.
+ */
+ abstract public function toBotAPI(): array;
+ /**
+ * Convert entity to MTProto entity.
+ */
+ abstract public function toMTProto(): array;
+
+ /** @internal */
+ #[\Override]
+ public function jsonSerialize(): mixed
+ {
+ $res = ['_' => static::class];
+ $refl = new ReflectionClass($this);
+ foreach ($refl->getProperties(ReflectionProperty::IS_PUBLIC) as $prop) {
+ $res[$prop->getName()] = $prop->getValue($this);
+ }
+ return $res;
+ }
+}
diff --git a/patches/madelineproto-formatted-date/baseline/TL_telegram_v225.tl b/patches/madelineproto-formatted-date/baseline/TL_telegram_v225.tl
new file mode 100644
index 0000000..2ad8d9c
--- /dev/null
+++ b/patches/madelineproto-formatted-date/baseline/TL_telegram_v225.tl
@@ -0,0 +1,2979 @@
+boolFalse#bc799737 = Bool;
+boolTrue#997275b5 = Bool;
+
+true#3fedd339 = True;
+
+vector#1cb5c415 {t:Type} # [ t ] = Vector t;
+
+error#c4b9f9bb code:int text:string = Error;
+
+null#56730bcc = Null;
+
+inputPeerEmpty#7f3b18ea = InputPeer;
+inputPeerSelf#7da07ec9 = InputPeer;
+inputPeerChat#35a95cb9 chat_id:long = InputPeer;
+inputPeerUser#dde8a54c user_id:long access_hash:long = InputPeer;
+inputPeerChannel#27bcbbfc channel_id:long access_hash:long = InputPeer;
+inputPeerUserFromMessage#a87b0a1c peer:InputPeer msg_id:int user_id:long = InputPeer;
+inputPeerChannelFromMessage#bd2a0840 peer:InputPeer msg_id:int channel_id:long = InputPeer;
+
+inputUserEmpty#b98886cf = InputUser;
+inputUserSelf#f7c1b13f = InputUser;
+inputUser#f21158c6 user_id:long access_hash:long = InputUser;
+inputUserFromMessage#1da448e2 peer:InputPeer msg_id:int user_id:long = InputUser;
+
+inputPhoneContact#6a1dc4be flags:# client_id:long phone:string first_name:string last_name:string note:flags.0?TextWithEntities = InputContact;
+
+inputFile#f52ff27f id:long parts:int name:string md5_checksum:string = InputFile;
+inputFileBig#fa4f0bb5 id:long parts:int name:string = InputFile;
+inputFileStoryDocument#62dc8b48 id:InputDocument = InputFile;
+
+inputMediaEmpty#9664f57f = InputMedia;
+inputMediaUploadedPhoto#7d8375da flags:# spoiler:flags.2?true live_photo:flags.3?true file:InputFile stickers:flags.0?Vector ttl_seconds:flags.1?int video:flags.3?InputDocument = InputMedia;
+inputMediaPhoto#e3af4434 flags:# spoiler:flags.1?true live_photo:flags.2?true id:InputPhoto ttl_seconds:flags.0?int video:flags.2?InputDocument = InputMedia;
+inputMediaGeoPoint#f9c44144 geo_point:InputGeoPoint = InputMedia;
+inputMediaContact#f8ab7dfb phone_number:string first_name:string last_name:string vcard:string = InputMedia;
+inputMediaUploadedDocument#37c9330 flags:# nosound_video:flags.3?true force_file:flags.4?true spoiler:flags.5?true file:InputFile thumb:flags.2?InputFile mime_type:string attributes:Vector stickers:flags.0?Vector video_cover:flags.6?InputPhoto video_timestamp:flags.7?int ttl_seconds:flags.1?int = InputMedia;
+inputMediaDocument#a8763ab5 flags:# spoiler:flags.2?true id:InputDocument video_cover:flags.3?InputPhoto video_timestamp:flags.4?int ttl_seconds:flags.0?int query:flags.1?string = InputMedia;
+inputMediaVenue#c13d1c11 geo_point:InputGeoPoint title:string address:string provider:string venue_id:string venue_type:string = InputMedia;
+inputMediaPhotoExternal#e5bbfe1a flags:# spoiler:flags.1?true url:string ttl_seconds:flags.0?int = InputMedia;
+inputMediaDocumentExternal#779600f9 flags:# spoiler:flags.1?true url:string ttl_seconds:flags.0?int video_cover:flags.2?InputPhoto video_timestamp:flags.3?int = InputMedia;
+inputMediaGame#d33f43f3 id:InputGame = InputMedia;
+inputMediaInvoice#405fef0d flags:# title:string description:string photo:flags.0?InputWebDocument invoice:Invoice payload:bytes provider:flags.3?string provider_data:DataJSON start_param:flags.1?string extended_media:flags.2?InputMedia = InputMedia;
+inputMediaGeoLive#971fa843 flags:# stopped:flags.0?true geo_point:InputGeoPoint heading:flags.2?int period:flags.1?int proximity_notification_radius:flags.3?int = InputMedia;
+inputMediaPoll#883a4108 flags:# poll:Poll correct_answers:flags.0?Vector attached_media:flags.3?InputMedia solution:flags.1?string solution_entities:flags.1?Vector solution_media:flags.2?InputMedia = InputMedia;
+inputMediaDice#e66fbf7b emoticon:string = InputMedia;
+inputMediaStory#89fdd778 peer:InputPeer id:int = InputMedia;
+inputMediaWebPage#c21b8849 flags:# force_large_media:flags.0?true force_small_media:flags.1?true optional:flags.2?true url:string = InputMedia;
+inputMediaPaidMedia#c4103386 flags:# stars_amount:long extended_media:Vector payload:flags.0?string = InputMedia;
+inputMediaTodo#9fc55fde todo:TodoList = InputMedia;
+inputMediaStakeDice#f3a9244a game_hash:string ton_amount:long client_seed:bytes = InputMedia;
+
+inputChatPhotoEmpty#1ca48f57 = InputChatPhoto;
+inputChatUploadedPhoto#bdcdaec0 flags:# file:flags.0?InputFile video:flags.1?InputFile video_start_ts:flags.2?double video_emoji_markup:flags.3?VideoSize = InputChatPhoto;
+inputChatPhoto#8953ad37 id:InputPhoto = InputChatPhoto;
+
+inputGeoPointEmpty#e4c123d6 = InputGeoPoint;
+inputGeoPoint#48222faf flags:# lat:double long:double accuracy_radius:flags.0?int = InputGeoPoint;
+
+inputPhotoEmpty#1cd7bf0d = InputPhoto;
+inputPhoto#3bb3b94a id:long access_hash:long file_reference:bytes = InputPhoto;
+
+inputFileLocation#dfdaabe1 volume_id:long local_id:int secret:long file_reference:bytes = InputFileLocation;
+inputEncryptedFileLocation#f5235d55 id:long access_hash:long = InputFileLocation;
+inputDocumentFileLocation#bad07584 id:long access_hash:long file_reference:bytes thumb_size:string = InputFileLocation;
+inputSecureFileLocation#cbc7ee28 id:long access_hash:long = InputFileLocation;
+inputTakeoutFileLocation#29be5899 = InputFileLocation;
+inputPhotoFileLocation#40181ffe id:long access_hash:long file_reference:bytes thumb_size:string = InputFileLocation;
+inputPhotoLegacyFileLocation#d83466f3 id:long access_hash:long file_reference:bytes volume_id:long local_id:int secret:long = InputFileLocation;
+inputPeerPhotoFileLocation#37257e99 flags:# big:flags.0?true peer:InputPeer photo_id:long = InputFileLocation;
+inputStickerSetThumb#9d84f3db stickerset:InputStickerSet thumb_version:int = InputFileLocation;
+inputGroupCallStream#598a92a flags:# call:InputGroupCall time_ms:long scale:int video_channel:flags.0?int video_quality:flags.0?int = InputFileLocation;
+
+peerUser#59511722 user_id:long = Peer;
+peerChat#36c6019a chat_id:long = Peer;
+peerChannel#a2a5371e channel_id:long = Peer;
+
+storage.fileUnknown#aa963b05 = storage.FileType;
+storage.filePartial#40bc6f52 = storage.FileType;
+storage.fileJpeg#7efe0e = storage.FileType;
+storage.fileGif#cae1aadf = storage.FileType;
+storage.filePng#a4f63c0 = storage.FileType;
+storage.filePdf#ae1e508d = storage.FileType;
+storage.fileMp3#528a0677 = storage.FileType;
+storage.fileMov#4b09ebbc = storage.FileType;
+storage.fileMp4#b3cea0e4 = storage.FileType;
+storage.fileWebp#1081464c = storage.FileType;
+
+userEmpty#d3bc4b7a id:long = User;
+user#31774388 flags:# self:flags.10?true contact:flags.11?true mutual_contact:flags.12?true deleted:flags.13?true bot:flags.14?true bot_chat_history:flags.15?true bot_nochats:flags.16?true verified:flags.17?true restricted:flags.18?true min:flags.20?true bot_inline_geo:flags.21?true support:flags.23?true scam:flags.24?true apply_min_photo:flags.25?true fake:flags.26?true bot_attach_menu:flags.27?true premium:flags.28?true attach_menu_enabled:flags.29?true flags2:# bot_can_edit:flags2.1?true close_friend:flags2.2?true stories_hidden:flags2.3?true stories_unavailable:flags2.4?true contact_require_premium:flags2.10?true bot_business:flags2.11?true bot_has_main_app:flags2.13?true bot_forum_view:flags2.16?true bot_forum_can_manage_topics:flags2.17?true bot_can_manage_bots:flags2.18?true bot_guestchat:flags2.19?true id:long access_hash:flags.0?long first_name:flags.1?string last_name:flags.2?string username:flags.3?string phone:flags.4?string photo:flags.5?UserProfilePhoto status:flags.6?UserStatus bot_info_version:flags.14?int restriction_reason:flags.18?Vector bot_inline_placeholder:flags.19?string lang_code:flags.22?string emoji_status:flags.30?EmojiStatus usernames:flags2.0?Vector stories_max_id:flags2.5?RecentStory color:flags2.8?PeerColor profile_color:flags2.9?PeerColor bot_active_users:flags2.12?int bot_verification_icon:flags2.14?long send_paid_messages_stars:flags2.15?long = User;
+
+userProfilePhotoEmpty#4f11bae1 = UserProfilePhoto;
+userProfilePhoto#82d1f706 flags:# has_video:flags.0?true personal:flags.2?true photo_id:long stripped_thumb:flags.1?bytes dc_id:int = UserProfilePhoto;
+
+userStatusEmpty#9d05049 = UserStatus;
+userStatusOnline#edb93949 expires:int = UserStatus;
+userStatusOffline#8c703f was_online:int = UserStatus;
+userStatusRecently#7b197dc8 flags:# by_me:flags.0?true = UserStatus;
+userStatusLastWeek#541a1d1a flags:# by_me:flags.0?true = UserStatus;
+userStatusLastMonth#65899777 flags:# by_me:flags.0?true = UserStatus;
+
+chatEmpty#29562865 id:long = Chat;
+chat#41cbf256 flags:# creator:flags.0?true left:flags.2?true deactivated:flags.5?true call_active:flags.23?true call_not_empty:flags.24?true noforwards:flags.25?true id:long title:string photo:ChatPhoto participants_count:int date:int version:int migrated_to:flags.6?InputChannel admin_rights:flags.14?ChatAdminRights default_banned_rights:flags.18?ChatBannedRights = Chat;
+chatForbidden#6592a1a7 id:long title:string = Chat;
+channel#1c32b11c flags:# creator:flags.0?true left:flags.2?true broadcast:flags.5?true verified:flags.7?true megagroup:flags.8?true restricted:flags.9?true signatures:flags.11?true min:flags.12?true scam:flags.19?true has_link:flags.20?true has_geo:flags.21?true slowmode_enabled:flags.22?true call_active:flags.23?true call_not_empty:flags.24?true fake:flags.25?true gigagroup:flags.26?true noforwards:flags.27?true join_to_send:flags.28?true join_request:flags.29?true forum:flags.30?true flags2:# stories_hidden:flags2.1?true stories_hidden_min:flags2.2?true stories_unavailable:flags2.3?true signature_profiles:flags2.12?true autotranslation:flags2.15?true broadcast_messages_allowed:flags2.16?true monoforum:flags2.17?true forum_tabs:flags2.19?true id:long access_hash:flags.13?long title:string username:flags.6?string photo:ChatPhoto date:int restriction_reason:flags.9?Vector admin_rights:flags.14?ChatAdminRights banned_rights:flags.15?ChatBannedRights default_banned_rights:flags.18?ChatBannedRights participants_count:flags.17?int usernames:flags2.0?Vector stories_max_id:flags2.4?RecentStory color:flags2.7?PeerColor profile_color:flags2.8?PeerColor emoji_status:flags2.9?EmojiStatus level:flags2.10?int subscription_until_date:flags2.11?int bot_verification_icon:flags2.13?long send_paid_messages_stars:flags2.14?long linked_monoforum_id:flags2.18?long = Chat;
+channelForbidden#17d493d5 flags:# broadcast:flags.5?true megagroup:flags.8?true monoforum:flags.10?true id:long access_hash:long title:string until_date:flags.16?int = Chat;
+
+chatFull#2633421b flags:# can_set_username:flags.7?true has_scheduled:flags.8?true translations_disabled:flags.19?true id:long about:string participants:ChatParticipants chat_photo:flags.2?Photo notify_settings:PeerNotifySettings exported_invite:flags.13?ExportedChatInvite bot_info:flags.3?Vector pinned_msg_id:flags.6?int folder_id:flags.11?int call:flags.12?InputGroupCall ttl_period:flags.14?int groupcall_default_join_as:flags.15?Peer theme_emoticon:flags.16?string requests_pending:flags.17?int recent_requesters:flags.17?Vector available_reactions:flags.18?ChatReactions reactions_limit:flags.20?int = ChatFull;
+channelFull#e4e0b29d flags:# can_view_participants:flags.3?true can_set_username:flags.6?true can_set_stickers:flags.7?true hidden_prehistory:flags.10?true can_set_location:flags.16?true has_scheduled:flags.19?true can_view_stats:flags.20?true blocked:flags.22?true flags2:# can_delete_channel:flags2.0?true antispam:flags2.1?true participants_hidden:flags2.2?true translations_disabled:flags2.3?true stories_pinned_available:flags2.5?true view_forum_as_messages:flags2.6?true restricted_sponsored:flags2.11?true can_view_revenue:flags2.12?true paid_media_allowed:flags2.14?true can_view_stars_revenue:flags2.15?true paid_reactions_available:flags2.16?true stargifts_available:flags2.19?true paid_messages_available:flags2.20?true id:long about:string participants_count:flags.0?int admins_count:flags.1?int kicked_count:flags.2?int banned_count:flags.2?int online_count:flags.13?int read_inbox_max_id:int read_outbox_max_id:int unread_count:int chat_photo:Photo notify_settings:PeerNotifySettings exported_invite:flags.23?ExportedChatInvite bot_info:Vector migrated_from_chat_id:flags.4?long migrated_from_max_id:flags.4?int pinned_msg_id:flags.5?int stickerset:flags.8?StickerSet available_min_id:flags.9?int folder_id:flags.11?int linked_chat_id:flags.14?long location:flags.15?ChannelLocation slowmode_seconds:flags.17?int slowmode_next_send_date:flags.18?int stats_dc:flags.12?int pts:int call:flags.21?InputGroupCall ttl_period:flags.24?int pending_suggestions:flags.25?Vector groupcall_default_join_as:flags.26?Peer theme_emoticon:flags.27?string requests_pending:flags.28?int recent_requesters:flags.28?Vector default_send_as:flags.29?Peer available_reactions:flags.30?ChatReactions reactions_limit:flags2.13?int stories:flags2.4?PeerStories wallpaper:flags2.7?WallPaper boosts_applied:flags2.8?int boosts_unrestrict:flags2.9?int emojiset:flags2.10?StickerSet bot_verification:flags2.17?BotVerification stargifts_count:flags2.18?int send_paid_messages_stars:flags2.21?long main_tab:flags2.22?ProfileTab = ChatFull;
+
+chatParticipant#38e79fde flags:# user_id:long inviter_id:long date:int rank:flags.0?string = ChatParticipant;
+chatParticipantCreator#e1f867b8 flags:# user_id:long rank:flags.0?string = ChatParticipant;
+chatParticipantAdmin#360d5d2 flags:# user_id:long inviter_id:long date:int rank:flags.0?string = ChatParticipant;
+
+chatParticipantsForbidden#8763d3e1 flags:# chat_id:long self_participant:flags.0?ChatParticipant = ChatParticipants;
+chatParticipants#3cbc93f8 chat_id:long participants:Vector version:int = ChatParticipants;
+
+chatPhotoEmpty#37c1011c = ChatPhoto;
+chatPhoto#1c6e1c11 flags:# has_video:flags.0?true photo_id:long stripped_thumb:flags.1?bytes dc_id:int = ChatPhoto;
+
+messageEmpty#90a6ca84 flags:# id:int peer_id:flags.0?Peer = Message;
+message#95ef6f2b flags:# out:flags.1?true mentioned:flags.4?true media_unread:flags.5?true silent:flags.13?true post:flags.14?true from_scheduled:flags.18?true legacy:flags.19?true edit_hide:flags.21?true pinned:flags.24?true noforwards:flags.26?true invert_media:flags.27?true flags2:# offline:flags2.1?true video_processing_pending:flags2.4?true paid_suggested_post_stars:flags2.8?true paid_suggested_post_ton:flags2.9?true id:int from_id:flags.8?Peer from_boosts_applied:flags.29?int from_rank:flags2.12?string peer_id:Peer saved_peer_id:flags.28?Peer fwd_from:flags.2?MessageFwdHeader via_bot_id:flags.11?long via_business_bot_id:flags2.0?long guestchat_via_from:flags2.19?Peer reply_to:flags.3?MessageReplyHeader date:int message:string media:flags.9?MessageMedia reply_markup:flags.6?ReplyMarkup entities:flags.7?Vector views:flags.10?int forwards:flags.10?int replies:flags.23?MessageReplies edit_date:flags.15?int post_author:flags.16?string grouped_id:flags.17?long reactions:flags.20?MessageReactions restriction_reason:flags.22?Vector ttl_period:flags.25?int quick_reply_shortcut_id:flags.30?int effect:flags2.2?long factcheck:flags2.3?FactCheck report_delivery_until_date:flags2.5?int paid_message_stars:flags2.6?long suggested_post:flags2.7?SuggestedPost schedule_repeat_period:flags2.10?int summary_from_language:flags2.11?string = Message;
+messageService#7a800e0a flags:# out:flags.1?true mentioned:flags.4?true media_unread:flags.5?true reactions_are_possible:flags.9?true silent:flags.13?true post:flags.14?true legacy:flags.19?true id:int from_id:flags.8?Peer peer_id:Peer saved_peer_id:flags.28?Peer reply_to:flags.3?MessageReplyHeader date:int action:MessageAction reactions:flags.20?MessageReactions ttl_period:flags.25?int = Message;
+
+messageMediaEmpty#3ded6320 = MessageMedia;
+messageMediaPhoto#e216eb63 flags:# spoiler:flags.3?true live_photo:flags.4?true photo:flags.0?Photo ttl_seconds:flags.2?int video:flags.4?Document = MessageMedia;
+messageMediaGeo#56e0d474 geo:GeoPoint = MessageMedia;
+messageMediaContact#70322949 phone_number:string first_name:string last_name:string vcard:string user_id:long = MessageMedia;
+messageMediaUnsupported#9f84f49e = MessageMedia;
+messageMediaDocument#52d8ccd9 flags:# nopremium:flags.3?true spoiler:flags.4?true video:flags.6?true round:flags.7?true voice:flags.8?true document:flags.0?Document alt_documents:flags.5?Vector video_cover:flags.9?Photo video_timestamp:flags.10?int ttl_seconds:flags.2?int = MessageMedia;
+messageMediaWebPage#ddf10c3b flags:# force_large_media:flags.0?true force_small_media:flags.1?true manual:flags.3?true safe:flags.4?true webpage:WebPage = MessageMedia;
+messageMediaVenue#2ec0533f geo:GeoPoint title:string address:string provider:string venue_id:string venue_type:string = MessageMedia;
+messageMediaGame#fdb19008 game:Game = MessageMedia;
+messageMediaInvoice#f6a548d3 flags:# shipping_address_requested:flags.1?true test:flags.3?true title:string description:string photo:flags.0?WebDocument receipt_msg_id:flags.2?int currency:string total_amount:long start_param:string extended_media:flags.4?MessageExtendedMedia = MessageMedia;
+messageMediaGeoLive#b940c666 flags:# geo:GeoPoint heading:flags.0?int period:int proximity_notification_radius:flags.1?int = MessageMedia;
+messageMediaPoll#773f4e66 flags:# poll:Poll results:PollResults attached_media:flags.0?MessageMedia = MessageMedia;
+messageMediaDice#8cbec07 flags:# value:int emoticon:string game_outcome:flags.0?messages.EmojiGameOutcome = MessageMedia;
+messageMediaStory#68cb6283 flags:# via_mention:flags.1?true peer:Peer id:int story:flags.0?StoryItem = MessageMedia;
+messageMediaGiveaway#aa073beb flags:# only_new_subscribers:flags.0?true winners_are_visible:flags.2?true channels:Vector countries_iso2:flags.1?Vector prize_description:flags.3?string quantity:int months:flags.4?int stars:flags.5?long until_date:int = MessageMedia;
+messageMediaGiveawayResults#ceaa3ea1 flags:# only_new_subscribers:flags.0?true refunded:flags.2?true channel_id:long additional_peers_count:flags.3?int launch_msg_id:int winners_count:int unclaimed_count:int winners:Vector months:flags.4?int stars:flags.5?long prize_description:flags.1?string until_date:int = MessageMedia;
+messageMediaPaidMedia#a8852491 stars_amount:long extended_media:Vector = MessageMedia;
+messageMediaToDo#8a53b014 flags:# todo:TodoList completions:flags.0?Vector = MessageMedia;
+messageMediaVideoStream#ca5cab89 flags:# rtmp_stream:flags.0?true call:InputGroupCall = MessageMedia;
+
+messageActionEmpty#b6aef7b0 = MessageAction;
+messageActionChatCreate#bd47cbad title:string users:Vector = MessageAction;
+messageActionChatEditTitle#b5a1ce5a title:string = MessageAction;
+messageActionChatEditPhoto#7fcb13a8 photo:Photo = MessageAction;
+messageActionChatDeletePhoto#95e3fbef = MessageAction;
+messageActionChatAddUser#15cefd00 users:Vector = MessageAction;
+messageActionChatDeleteUser#a43f30cc user_id:long = MessageAction;
+messageActionChatJoinedByLink#31224c3 inviter_id:long = MessageAction;
+messageActionChannelCreate#95d2ac92 title:string = MessageAction;
+messageActionChatMigrateTo#e1037f92 channel_id:long = MessageAction;
+messageActionChannelMigrateFrom#ea3948e9 title:string chat_id:long = MessageAction;
+messageActionPinMessage#94bd38ed = MessageAction;
+messageActionHistoryClear#9fbab604 = MessageAction;
+messageActionGameScore#92a72876 game_id:long score:int = MessageAction;
+messageActionPaymentSentMe#ffa00ccc flags:# recurring_init:flags.2?true recurring_used:flags.3?true currency:string total_amount:long payload:bytes info:flags.0?PaymentRequestedInfo shipping_option_id:flags.1?string charge:PaymentCharge subscription_until_date:flags.4?int = MessageAction;
+messageActionPaymentSent#c624b16e flags:# recurring_init:flags.2?true recurring_used:flags.3?true currency:string total_amount:long invoice_slug:flags.0?string subscription_until_date:flags.4?int = MessageAction;
+messageActionPhoneCall#80e11a7f flags:# video:flags.2?true call_id:long reason:flags.0?PhoneCallDiscardReason duration:flags.1?int = MessageAction;
+messageActionScreenshotTaken#4792929b = MessageAction;
+messageActionCustomAction#fae69f56 message:string = MessageAction;
+messageActionBotAllowed#c516d679 flags:# attach_menu:flags.1?true from_request:flags.3?true domain:flags.0?string app:flags.2?BotApp = MessageAction;
+messageActionSecureValuesSentMe#1b287353 values:Vector credentials:SecureCredentialsEncrypted = MessageAction;
+messageActionSecureValuesSent#d95c6154 types:Vector = MessageAction;
+messageActionContactSignUp#f3f25f76 = MessageAction;
+messageActionGeoProximityReached#98e0d697 from_id:Peer to_id:Peer distance:int = MessageAction;
+messageActionGroupCall#7a0d7f42 flags:# call:InputGroupCall duration:flags.0?int = MessageAction;
+messageActionInviteToGroupCall#502f92f7 call:InputGroupCall users:Vector = MessageAction;
+messageActionSetMessagesTTL#3c134d7b flags:# period:int auto_setting_from:flags.0?long = MessageAction;
+messageActionGroupCallScheduled#b3a07661 call:InputGroupCall schedule_date:int = MessageAction;
+messageActionSetChatTheme#b91bbd3a theme:ChatTheme = MessageAction;
+messageActionChatJoinedByRequest#ebbca3cb = MessageAction;
+messageActionWebViewDataSentMe#47dd8079 text:string data:string = MessageAction;
+messageActionWebViewDataSent#b4c38cb5 text:string = MessageAction;
+messageActionGiftPremium#48e91302 flags:# currency:string amount:long days:int crypto_currency:flags.0?string crypto_amount:flags.0?long message:flags.1?TextWithEntities = MessageAction;
+messageActionTopicCreate#d999256 flags:# title_missing:flags.1?true title:string icon_color:int icon_emoji_id:flags.0?long = MessageAction;
+messageActionTopicEdit#c0944820 flags:# title:flags.0?string icon_emoji_id:flags.1?long closed:flags.2?Bool hidden:flags.3?Bool = MessageAction;
+messageActionSuggestProfilePhoto#57de635e photo:Photo = MessageAction;
+messageActionRequestedPeer#31518e9b button_id:int peers:Vector = MessageAction;
+messageActionSetChatWallPaper#5060a3f4 flags:# same:flags.0?true for_both:flags.1?true wallpaper:WallPaper = MessageAction;
+messageActionGiftCode#31c48347 flags:# via_giveaway:flags.0?true unclaimed:flags.5?true boost_peer:flags.1?Peer days:int slug:string currency:flags.2?string amount:flags.2?long crypto_currency:flags.3?string crypto_amount:flags.3?long message:flags.4?TextWithEntities = MessageAction;
+messageActionGiveawayLaunch#a80f51e4 flags:# stars:flags.0?long = MessageAction;
+messageActionGiveawayResults#87e2f155 flags:# stars:flags.0?true winners_count:int unclaimed_count:int = MessageAction;
+messageActionBoostApply#cc02aa6d boosts:int = MessageAction;
+messageActionRequestedPeerSentMe#93b31848 button_id:int peers:Vector = MessageAction;
+messageActionPaymentRefunded#41b3e202 flags:# peer:Peer currency:string total_amount:long payload:flags.0?bytes charge:PaymentCharge = MessageAction;
+messageActionGiftStars#45d5b021 flags:# currency:string amount:long stars:long crypto_currency:flags.0?string crypto_amount:flags.0?long transaction_id:flags.1?string = MessageAction;
+messageActionPrizeStars#b00c47a2 flags:# unclaimed:flags.0?true stars:long transaction_id:string boost_peer:Peer giveaway_msg_id:int = MessageAction;
+messageActionStarGift#ea2c31d3 flags:# name_hidden:flags.0?true saved:flags.2?true converted:flags.3?true upgraded:flags.5?true refunded:flags.9?true can_upgrade:flags.10?true prepaid_upgrade:flags.13?true upgrade_separate:flags.16?true auction_acquired:flags.17?true gift:StarGift message:flags.1?TextWithEntities convert_stars:flags.4?long upgrade_msg_id:flags.5?int upgrade_stars:flags.8?long from_id:flags.11?Peer peer:flags.12?Peer saved_id:flags.12?long prepaid_upgrade_hash:flags.14?string gift_msg_id:flags.15?int to_id:flags.18?Peer gift_num:flags.19?int = MessageAction;
+messageActionStarGiftUnique#e6c31522 flags:# upgrade:flags.0?true transferred:flags.1?true saved:flags.2?true refunded:flags.5?true prepaid_upgrade:flags.11?true assigned:flags.13?true from_offer:flags.14?true craft:flags.16?true gift:StarGift can_export_at:flags.3?int transfer_stars:flags.4?long from_id:flags.6?Peer peer:flags.7?Peer saved_id:flags.7?long resale_amount:flags.8?StarsAmount can_transfer_at:flags.9?int can_resell_at:flags.10?int drop_original_details_stars:flags.12?long can_craft_at:flags.15?int = MessageAction;
+messageActionPaidMessagesRefunded#ac1f1fcd count:int stars:long = MessageAction;
+messageActionPaidMessagesPrice#84b88578 flags:# broadcast_messages_allowed:flags.0?true stars:long = MessageAction;
+messageActionConferenceCall#2ffe2f7a flags:# missed:flags.0?true active:flags.1?true video:flags.4?true call_id:long duration:flags.2?int other_participants:flags.3?Vector = MessageAction;
+messageActionTodoCompletions#cc7c5c89 completed:Vector incompleted:Vector = MessageAction;
+messageActionTodoAppendTasks#c7edbc83 list:Vector = MessageAction;
+messageActionSuggestedPostApproval#ee7a1596 flags:# rejected:flags.0?true balance_too_low:flags.1?true reject_comment:flags.2?string schedule_date:flags.3?int price:flags.4?StarsAmount = MessageAction;
+messageActionSuggestedPostSuccess#95ddcf69 price:StarsAmount = MessageAction;
+messageActionSuggestedPostRefund#69f916f8 flags:# payer_initiated:flags.0?true = MessageAction;
+messageActionGiftTon#a8a3c699 flags:# currency:string amount:long crypto_currency:string crypto_amount:long transaction_id:flags.0?string = MessageAction;
+messageActionSuggestBirthday#2c8f2a25 birthday:Birthday = MessageAction;
+messageActionStarGiftPurchaseOffer#774278d4 flags:# accepted:flags.0?true declined:flags.1?true gift:StarGift price:StarsAmount expires_at:int = MessageAction;
+messageActionStarGiftPurchaseOfferDeclined#73ada76b flags:# expired:flags.0?true gift:StarGift price:StarsAmount = MessageAction;
+messageActionNewCreatorPending#b07ed085 new_creator_id:long = MessageAction;
+messageActionChangeCreator#e188503b new_creator_id:long = MessageAction;
+messageActionNoForwardsToggle#bf7d6572 prev_value:Bool new_value:Bool = MessageAction;
+messageActionNoForwardsRequest#3e2793ba flags:# expired:flags.0?true prev_value:Bool new_value:Bool = MessageAction;
+messageActionPollAppendAnswer#9da1cd6c answer:PollAnswer = MessageAction;
+messageActionPollDeleteAnswer#399674dc answer:PollAnswer = MessageAction;
+messageActionManagedBotCreated#16605e3e bot_id:long = MessageAction;
+
+dialog#fc89f7f3 flags:# pinned:flags.2?true unread_mark:flags.3?true view_forum_as_messages:flags.6?true peer:Peer top_message:int read_inbox_max_id:int read_outbox_max_id:int unread_count:int unread_mentions_count:int unread_reactions_count:int unread_poll_votes_count:int notify_settings:PeerNotifySettings pts:flags.0?int draft:flags.1?DraftMessage folder_id:flags.4?int ttl_period:flags.5?int = Dialog;
+dialogFolder#71bd134c flags:# pinned:flags.2?true folder:Folder peer:Peer top_message:int unread_muted_peers_count:int unread_unmuted_peers_count:int unread_muted_messages_count:int unread_unmuted_messages_count:int = Dialog;
+
+photoEmpty#2331b22d id:long = Photo;
+photo#fb197a65 flags:# has_stickers:flags.0?true id:long access_hash:long file_reference:bytes date:int sizes:Vector video_sizes:flags.1?Vector dc_id:int = Photo;
+
+photoSizeEmpty#e17e23c type:string = PhotoSize;
+photoSize#75c78e60 type:string w:int h:int size:int = PhotoSize;
+photoCachedSize#21e1ad6 type:string w:int h:int bytes:bytes = PhotoSize;
+photoStrippedSize#e0b0bc2e type:string bytes:bytes = PhotoSize;
+photoSizeProgressive#fa3efb95 type:string w:int h:int sizes:Vector = PhotoSize;
+photoPathSize#d8214d41 type:string bytes:bytes = PhotoSize;
+
+geoPointEmpty#1117dd5f = GeoPoint;
+geoPoint#b2a2f663 flags:# long:double lat:double access_hash:long accuracy_radius:flags.0?int = GeoPoint;
+
+auth.sentCode#5e002502 flags:# type:auth.SentCodeType phone_code_hash:string next_type:flags.1?auth.CodeType timeout:flags.2?int = auth.SentCode;
+auth.sentCodeSuccess#2390fe44 authorization:auth.Authorization = auth.SentCode;
+auth.sentCodePaymentRequired#f8827ebf store_product:string phone_code_hash:string support_email_address:string support_email_subject:string premium_days:int currency:string amount:long = auth.SentCode;
+
+auth.authorization#2ea2c0d4 flags:# setup_password_required:flags.1?true otherwise_relogin_days:flags.1?int tmp_sessions:flags.0?int future_auth_token:flags.2?bytes user:User = auth.Authorization;
+auth.authorizationSignUpRequired#44747e9a flags:# terms_of_service:flags.0?help.TermsOfService = auth.Authorization;
+
+auth.exportedAuthorization#b434e2b8 id:long bytes:bytes = auth.ExportedAuthorization;
+
+inputNotifyPeer#b8bc5b0c peer:InputPeer = InputNotifyPeer;
+inputNotifyUsers#193b4417 = InputNotifyPeer;
+inputNotifyChats#4a95e84e = InputNotifyPeer;
+inputNotifyBroadcasts#b1db7c7e = InputNotifyPeer;
+inputNotifyForumTopic#5c467992 peer:InputPeer top_msg_id:int = InputNotifyPeer;
+
+inputPeerNotifySettings#cacb6ae2 flags:# show_previews:flags.0?Bool silent:flags.1?Bool mute_until:flags.2?int sound:flags.3?NotificationSound stories_muted:flags.6?Bool stories_hide_sender:flags.7?Bool stories_sound:flags.8?NotificationSound = InputPeerNotifySettings;
+
+peerNotifySettings#99622c0c flags:# show_previews:flags.0?Bool silent:flags.1?Bool mute_until:flags.2?int ios_sound:flags.3?NotificationSound android_sound:flags.4?NotificationSound other_sound:flags.5?NotificationSound stories_muted:flags.6?Bool stories_hide_sender:flags.7?Bool stories_ios_sound:flags.8?NotificationSound stories_android_sound:flags.9?NotificationSound stories_other_sound:flags.10?NotificationSound = PeerNotifySettings;
+
+peerSettings#f47741f7 flags:# report_spam:flags.0?true add_contact:flags.1?true block_contact:flags.2?true share_contact:flags.3?true need_contacts_exception:flags.4?true report_geo:flags.5?true autoarchived:flags.7?true invite_members:flags.8?true request_chat_broadcast:flags.10?true business_bot_paused:flags.11?true business_bot_can_reply:flags.12?true geo_distance:flags.6?int request_chat_title:flags.9?string request_chat_date:flags.9?int business_bot_id:flags.13?long business_bot_manage_url:flags.13?string charge_paid_message_stars:flags.14?long registration_month:flags.15?string phone_country:flags.16?string name_change_date:flags.17?int photo_change_date:flags.18?int = PeerSettings;
+
+wallPaper#a437c3ed id:long flags:# creator:flags.0?true default:flags.1?true pattern:flags.3?true dark:flags.4?true access_hash:long slug:string document:Document settings:flags.2?WallPaperSettings = WallPaper;
+wallPaperNoFile#e0804116 id:long flags:# default:flags.1?true dark:flags.4?true settings:flags.2?WallPaperSettings = WallPaper;
+
+inputReportReasonSpam#58dbcab8 = ReportReason;
+inputReportReasonViolence#1e22c78d = ReportReason;
+inputReportReasonPornography#2e59d922 = ReportReason;
+inputReportReasonChildAbuse#adf44ee3 = ReportReason;
+inputReportReasonOther#c1e4a2b1 = ReportReason;
+inputReportReasonCopyright#9b89f93a = ReportReason;
+inputReportReasonGeoIrrelevant#dbd4feed = ReportReason;
+inputReportReasonFake#f5ddd6e7 = ReportReason;
+inputReportReasonIllegalDrugs#a8eb2be = ReportReason;
+inputReportReasonPersonalDetails#9ec7863d = ReportReason;
+
+userFull#6cbe645 flags:# blocked:flags.0?true phone_calls_available:flags.4?true phone_calls_private:flags.5?true can_pin_message:flags.7?true has_scheduled:flags.12?true video_calls_available:flags.13?true voice_messages_forbidden:flags.20?true translations_disabled:flags.23?true stories_pinned_available:flags.26?true blocked_my_stories_from:flags.27?true wallpaper_overridden:flags.28?true contact_require_premium:flags.29?true read_dates_private:flags.30?true flags2:# sponsored_enabled:flags2.7?true can_view_revenue:flags2.9?true bot_can_manage_emoji_status:flags2.10?true display_gifts_button:flags2.16?true noforwards_my_enabled:flags2.23?true noforwards_peer_enabled:flags2.24?true unofficial_security_risk:flags2.26?true id:long about:flags.1?string settings:PeerSettings personal_photo:flags.21?Photo profile_photo:flags.2?Photo fallback_photo:flags.22?Photo notify_settings:PeerNotifySettings bot_info:flags.3?BotInfo pinned_msg_id:flags.6?int common_chats_count:int folder_id:flags.11?int ttl_period:flags.14?int theme:flags.15?ChatTheme private_forward_name:flags.16?string bot_group_admin_rights:flags.17?ChatAdminRights bot_broadcast_admin_rights:flags.18?ChatAdminRights wallpaper:flags.24?WallPaper stories:flags.25?PeerStories business_work_hours:flags2.0?BusinessWorkHours business_location:flags2.1?BusinessLocation business_greeting_message:flags2.2?BusinessGreetingMessage business_away_message:flags2.3?BusinessAwayMessage business_intro:flags2.4?BusinessIntro birthday:flags2.5?Birthday personal_channel_id:flags2.6?long personal_channel_message:flags2.6?int stargifts_count:flags2.8?int starref_program:flags2.11?StarRefProgram bot_verification:flags2.12?BotVerification send_paid_messages_stars:flags2.14?long disallowed_gifts:flags2.15?DisallowedGiftsSettings stars_rating:flags2.17?StarsRating stars_my_pending_rating:flags2.18?StarsRating stars_my_pending_rating_date:flags2.18?int main_tab:flags2.20?ProfileTab saved_music:flags2.21?Document note:flags2.22?TextWithEntities bot_manager_id:flags2.25?long = UserFull;
+
+contact#145ade0b user_id:long mutual:Bool = Contact;
+
+importedContact#c13e3c50 user_id:long client_id:long = ImportedContact;
+
+contactStatus#16d9703b user_id:long status:UserStatus = ContactStatus;
+
+contacts.contactsNotModified#b74ba9d2 = contacts.Contacts;
+contacts.contacts#eae87e42 contacts:Vector saved_count:int users:Vector = contacts.Contacts;
+
+contacts.importedContacts#77d01c3b imported:Vector popular_invites:Vector retry_contacts:Vector users:Vector = contacts.ImportedContacts;
+
+contacts.blocked#ade1591 blocked:Vector chats:Vector users:Vector = contacts.Blocked;
+contacts.blockedSlice#e1664194 count:int blocked:Vector chats:Vector users:Vector = contacts.Blocked;
+
+messages.dialogs#15ba6c40 dialogs:Vector